a modern tui library written in zig
at main 15 kB view raw
1const std = @import("std"); 2const assert = std.debug.assert; 3const Key = @import("../Key.zig"); 4const Cell = @import("../Cell.zig"); 5const Window = @import("../Window.zig"); 6const unicode = @import("../unicode.zig"); 7 8const TextInput = @This(); 9 10/// The events that this widget handles 11const Event = union(enum) { 12 key_press: Key, 13}; 14 15const ellipsis: Cell.Character = .{ .grapheme = "", .width = 1 }; 16 17// Index of our cursor 18buf: Buffer, 19 20/// the number of graphemes to skip when drawing. Used for horizontal scrolling 21draw_offset: u16 = 0, 22/// the column we placed the cursor the last time we drew 23prev_cursor_col: u16 = 0, 24/// the grapheme index of the cursor the last time we drew 25prev_cursor_idx: u16 = 0, 26/// approximate distance from an edge before we scroll 27scroll_offset: u16 = 4, 28 29pub fn init(alloc: std.mem.Allocator) TextInput { 30 return TextInput{ 31 .buf = Buffer.init(alloc), 32 }; 33} 34 35pub fn deinit(self: *TextInput) void { 36 self.buf.deinit(); 37} 38 39pub fn update(self: *TextInput, event: Event) !void { 40 switch (event) { 41 .key_press => |key| { 42 if (key.matches(Key.backspace, .{})) { 43 self.deleteBeforeCursor(); 44 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { 45 self.deleteAfterCursor(); 46 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) { 47 self.cursorLeft(); 48 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { 49 self.cursorRight(); 50 } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) { 51 self.buf.moveGapLeft(self.buf.firstHalf().len); 52 } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) { 53 self.buf.moveGapRight(self.buf.secondHalf().len); 54 } else if (key.matches('k', .{ .ctrl = true })) { 55 self.deleteToEnd(); 56 } else if (key.matches('u', .{ .ctrl = true })) { 57 self.deleteToStart(); 58 } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) { 59 self.moveBackwardWordwise(); 60 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) { 61 self.moveForwardWordwise(); 62 } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) { 63 self.deleteWordBefore(); 64 } else if (key.matches('d', .{ .alt = true })) { 65 self.deleteWordAfter(); 66 } else if (key.text) |text| { 67 try self.insertSliceAtCursor(text); 68 } 69 }, 70 } 71} 72 73/// insert text at the cursor position 74pub fn insertSliceAtCursor(self: *TextInput, data: []const u8) std.mem.Allocator.Error!void { 75 var iter = unicode.graphemeIterator(data); 76 while (iter.next()) |text| { 77 try self.buf.insertSliceAtCursor(text.bytes(data)); 78 } 79} 80 81pub fn sliceToCursor(self: *TextInput, buf: []u8) []const u8 { 82 assert(buf.len >= self.buf.cursor); 83 @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf()); 84 return buf[0..self.buf.cursor]; 85} 86 87/// calculates the display width from the draw_offset to the cursor 88pub fn widthToCursor(self: *TextInput, win: Window) u16 { 89 var width: u16 = 0; 90 const first_half = self.buf.firstHalf(); 91 var first_iter = unicode.graphemeIterator(first_half); 92 var i: usize = 0; 93 while (first_iter.next()) |grapheme| { 94 defer i += 1; 95 if (i < self.draw_offset) { 96 continue; 97 } 98 const g = grapheme.bytes(first_half); 99 width += win.gwidth(g); 100 } 101 return width; 102} 103 104pub fn cursorLeft(self: *TextInput) void { 105 // We need to find the size of the last grapheme in the first half 106 var iter = unicode.graphemeIterator(self.buf.firstHalf()); 107 var len: usize = 0; 108 while (iter.next()) |grapheme| { 109 len = grapheme.len; 110 } 111 self.buf.moveGapLeft(len); 112} 113 114pub fn cursorRight(self: *TextInput) void { 115 var iter = unicode.graphemeIterator(self.buf.secondHalf()); 116 const grapheme = iter.next() orelse return; 117 self.buf.moveGapRight(grapheme.len); 118} 119 120pub fn graphemesBeforeCursor(self: *const TextInput) u16 { 121 const first_half = self.buf.firstHalf(); 122 var first_iter = unicode.graphemeIterator(first_half); 123 var i: u16 = 0; 124 while (first_iter.next()) |_| { 125 i += 1; 126 } 127 return i; 128} 129 130pub fn draw(self: *TextInput, win: Window) void { 131 self.drawWithStyle(win, .{}); 132} 133 134pub fn drawWithStyle(self: *TextInput, win: Window, style: Cell.Style) void { 135 const cursor_idx = self.graphemesBeforeCursor(); 136 if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx; 137 if (win.width == 0) return; 138 while (true) { 139 const width = self.widthToCursor(win); 140 if (width >= win.width) { 141 self.draw_offset +|= width - win.width + 1; 142 continue; 143 } else break; 144 } 145 146 self.prev_cursor_idx = cursor_idx; 147 self.prev_cursor_col = 0; 148 149 // assumption!! the gap is never within a grapheme 150 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 151 const first_half = self.buf.firstHalf(); 152 var first_iter = unicode.graphemeIterator(first_half); 153 var col: u16 = 0; 154 var i: u16 = 0; 155 while (first_iter.next()) |grapheme| { 156 if (i < self.draw_offset) { 157 i += 1; 158 continue; 159 } 160 const g = grapheme.bytes(first_half); 161 const w = win.gwidth(g); 162 if (col + w >= win.width) { 163 win.writeCell(win.width - 1, 0, .{ 164 .char = ellipsis, 165 .style = style, 166 }); 167 break; 168 } 169 win.writeCell(col, 0, .{ 170 .char = .{ 171 .grapheme = g, 172 .width = @intCast(w), 173 }, 174 .style = style, 175 }); 176 col += w; 177 i += 1; 178 if (i == cursor_idx) self.prev_cursor_col = col; 179 } 180 const second_half = self.buf.secondHalf(); 181 var second_iter = unicode.graphemeIterator(second_half); 182 while (second_iter.next()) |grapheme| { 183 if (i < self.draw_offset) { 184 i += 1; 185 continue; 186 } 187 const g = grapheme.bytes(second_half); 188 const w = win.gwidth(g); 189 if (col + w > win.width) { 190 win.writeCell(win.width - 1, 0, .{ 191 .char = ellipsis, 192 .style = style, 193 }); 194 break; 195 } 196 win.writeCell(col, 0, .{ 197 .char = .{ 198 .grapheme = g, 199 .width = @intCast(w), 200 }, 201 .style = style, 202 }); 203 col += w; 204 i += 1; 205 if (i == cursor_idx) self.prev_cursor_col = col; 206 } 207 if (self.draw_offset > 0) { 208 win.writeCell(0, 0, .{ 209 .char = ellipsis, 210 .style = style, 211 }); 212 } 213 win.showCursor(self.prev_cursor_col, 0); 214} 215 216pub fn clearAndFree(self: *TextInput) void { 217 self.buf.clearAndFree(); 218 self.reset(); 219} 220 221pub fn clearRetainingCapacity(self: *TextInput) void { 222 self.buf.clearRetainingCapacity(); 223 self.reset(); 224} 225 226pub fn toOwnedSlice(self: *TextInput) ![]const u8 { 227 defer self.reset(); 228 return self.buf.toOwnedSlice(); 229} 230 231pub fn reset(self: *TextInput) void { 232 self.draw_offset = 0; 233 self.prev_cursor_col = 0; 234 self.prev_cursor_idx = 0; 235} 236 237// returns the number of bytes before the cursor 238pub fn byteOffsetToCursor(self: TextInput) usize { 239 return self.buf.cursor; 240} 241 242pub fn deleteToEnd(self: *TextInput) void { 243 self.buf.growGapRight(self.buf.secondHalf().len); 244} 245 246pub fn deleteToStart(self: *TextInput) void { 247 self.buf.growGapLeft(self.buf.cursor); 248} 249 250pub fn deleteBeforeCursor(self: *TextInput) void { 251 // We need to find the size of the last grapheme in the first half 252 var iter = unicode.graphemeIterator(self.buf.firstHalf()); 253 var len: usize = 0; 254 while (iter.next()) |grapheme| { 255 len = grapheme.len; 256 } 257 self.buf.growGapLeft(len); 258} 259 260pub fn deleteAfterCursor(self: *TextInput) void { 261 var iter = unicode.graphemeIterator(self.buf.secondHalf()); 262 const grapheme = iter.next() orelse return; 263 self.buf.growGapRight(grapheme.len); 264} 265 266/// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is 267/// positioned just after the next previous space 268pub fn moveBackwardWordwise(self: *TextInput) void { 269 const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " "); 270 const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last| 271 last + 1 272 else 273 0; 274 self.buf.moveGapLeft(self.buf.cursor - idx); 275} 276 277pub fn moveForwardWordwise(self: *TextInput) void { 278 const second_half = self.buf.secondHalf(); 279 var i: usize = 0; 280 while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 281 const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 282 self.buf.moveGapRight(idx); 283} 284 285pub fn deleteWordBefore(self: *TextInput) void { 286 // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 287 // moved 288 const pre = self.buf.cursor; 289 self.moveBackwardWordwise(); 290 self.buf.growGapRight(pre - self.buf.cursor); 291} 292 293pub fn deleteWordAfter(self: *TextInput) void { 294 // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 295 // moved 296 const second_half = self.buf.secondHalf(); 297 var i: usize = 0; 298 while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 299 const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 300 self.buf.growGapRight(idx); 301} 302 303test "assertion" { 304 const astronaut = "👩‍🚀"; 305 const astronaut_emoji: Key = .{ 306 .text = astronaut, 307 .codepoint = try std.unicode.utf8Decode(astronaut[0..4]), 308 }; 309 var input = TextInput.init(std.testing.allocator); 310 defer input.deinit(); 311 for (0..6) |_| { 312 try input.update(.{ .key_press = astronaut_emoji }); 313 } 314} 315 316test "sliceToCursor" { 317 var input = init(std.testing.allocator); 318 defer input.deinit(); 319 try input.insertSliceAtCursor("hello, world"); 320 input.cursorLeft(); 321 input.cursorLeft(); 322 input.cursorLeft(); 323 var buf: [32]u8 = undefined; 324 try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf)); 325 input.cursorRight(); 326 try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf)); 327} 328 329pub const Buffer = struct { 330 allocator: std.mem.Allocator, 331 buffer: []u8, 332 cursor: usize, 333 gap_size: usize, 334 335 pub fn init(allocator: std.mem.Allocator) Buffer { 336 return .{ 337 .allocator = allocator, 338 .buffer = &.{}, 339 .cursor = 0, 340 .gap_size = 0, 341 }; 342 } 343 344 pub fn deinit(self: *Buffer) void { 345 self.allocator.free(self.buffer); 346 } 347 348 pub fn firstHalf(self: Buffer) []const u8 { 349 return self.buffer[0..self.cursor]; 350 } 351 352 pub fn secondHalf(self: Buffer) []const u8 { 353 return self.buffer[self.cursor + self.gap_size ..]; 354 } 355 356 pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void { 357 // Always grow by 512 bytes 358 const new_size = self.buffer.len + n + 512; 359 // Allocate the new memory 360 const new_memory = try self.allocator.alloc(u8, new_size); 361 // Copy the first half 362 @memcpy(new_memory[0..self.cursor], self.firstHalf()); 363 // Copy the second half 364 const second_half = self.secondHalf(); 365 @memcpy(new_memory[new_size - second_half.len ..], second_half); 366 self.allocator.free(self.buffer); 367 self.buffer = new_memory; 368 self.gap_size = new_size - second_half.len - self.cursor; 369 } 370 371 pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void { 372 if (slice.len == 0) return; 373 if (self.gap_size <= slice.len) try self.grow(slice.len); 374 @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice); 375 self.cursor += slice.len; 376 self.gap_size -= slice.len; 377 } 378 379 /// Move the gap n bytes to the left 380 pub fn moveGapLeft(self: *Buffer, n: usize) void { 381 const new_idx = self.cursor -| n; 382 const dst = self.buffer[new_idx + self.gap_size ..]; 383 const src = self.buffer[new_idx..self.cursor]; 384 std.mem.copyForwards(u8, dst, src); 385 self.cursor = new_idx; 386 } 387 388 pub fn moveGapRight(self: *Buffer, n: usize) void { 389 const new_idx = self.cursor + n; 390 const dst = self.buffer[self.cursor..]; 391 const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size]; 392 std.mem.copyForwards(u8, dst, src); 393 self.cursor = new_idx; 394 } 395 396 /// grow the gap by moving the cursor n bytes to the left 397 pub fn growGapLeft(self: *Buffer, n: usize) void { 398 // gap grows by the delta 399 self.gap_size += n; 400 self.cursor -|= n; 401 } 402 403 /// grow the gap by removing n bytes after the cursor 404 pub fn growGapRight(self: *Buffer, n: usize) void { 405 self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor); 406 } 407 408 pub fn clearAndFree(self: *Buffer) void { 409 self.cursor = 0; 410 self.allocator.free(self.buffer); 411 self.buffer = &.{}; 412 self.gap_size = 0; 413 } 414 415 pub fn clearRetainingCapacity(self: *Buffer) void { 416 self.cursor = 0; 417 self.gap_size = self.buffer.len; 418 } 419 420 pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 { 421 const first_half = self.firstHalf(); 422 const second_half = self.secondHalf(); 423 const buf = try self.allocator.alloc(u8, first_half.len + second_half.len); 424 @memcpy(buf[0..first_half.len], first_half); 425 @memcpy(buf[first_half.len..], second_half); 426 self.clearAndFree(); 427 return buf; 428 } 429 430 pub fn realLength(self: *const Buffer) usize { 431 return self.firstHalf().len + self.secondHalf().len; 432 } 433}; 434 435test "TextInput.zig: Buffer" { 436 var gap_buf = Buffer.init(std.testing.allocator); 437 defer gap_buf.deinit(); 438 439 try gap_buf.insertSliceAtCursor("abc"); 440 try std.testing.expectEqualStrings("abc", gap_buf.firstHalf()); 441 try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 442 443 gap_buf.moveGapLeft(1); 444 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 445 try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 446 447 try gap_buf.insertSliceAtCursor(" "); 448 try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf()); 449 try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 450 451 gap_buf.growGapLeft(1); 452 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 453 try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 454 try std.testing.expectEqual(2, gap_buf.cursor); 455 456 gap_buf.growGapRight(1); 457 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 458 try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 459 try std.testing.expectEqual(2, gap_buf.cursor); 460}