a modern tui library written in zig
at v0.2.0 12 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 GapBuffer = @import("gap_buffer").GapBuffer; 7const Unicode = @import("../Unicode.zig"); 8 9const log = std.log.scoped(.text_input); 10 11const TextInput = @This(); 12 13/// The events that this widget handles 14const Event = union(enum) { 15 key_press: Key, 16}; 17 18const ellipsis: Cell.Character = .{ .grapheme = "", .width = 1 }; 19 20// Index of our cursor 21cursor_idx: usize = 0, 22grapheme_count: usize = 0, 23buf: GapBuffer(u8), 24 25/// the number of graphemes to skip when drawing. Used for horizontal scrolling 26draw_offset: usize = 0, 27/// the column we placed the cursor the last time we drew 28prev_cursor_col: usize = 0, 29/// the grapheme index of the cursor the last time we drew 30prev_cursor_idx: usize = 0, 31/// approximate distance from an edge before we scroll 32scroll_offset: usize = 4, 33 34unicode: *const Unicode, 35 36pub fn init(alloc: std.mem.Allocator, unicode: *const Unicode) TextInput { 37 return TextInput{ 38 .buf = GapBuffer(u8).init(alloc), 39 .unicode = unicode, 40 }; 41} 42 43pub fn deinit(self: *TextInput) void { 44 self.buf.deinit(); 45} 46 47pub fn update(self: *TextInput, event: Event) !void { 48 switch (event) { 49 .key_press => |key| { 50 if (key.matches(Key.backspace, .{})) { 51 if (self.cursor_idx == 0) return; 52 try self.deleteBeforeCursor(); 53 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { 54 if (self.cursor_idx == self.grapheme_count) return; 55 try self.deleteAtCursor(); 56 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) { 57 if (self.cursor_idx > 0) self.cursor_idx -= 1; 58 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { 59 if (self.cursor_idx < self.grapheme_count) self.cursor_idx += 1; 60 } else if (key.matches('a', .{ .ctrl = true })) { 61 self.cursor_idx = 0; 62 } else if (key.matches('e', .{ .ctrl = true })) { 63 self.cursor_idx = self.grapheme_count; 64 } else if (key.matches('k', .{ .ctrl = true })) { 65 try self.deleteToEnd(); 66 } else if (key.matches('u', .{ .ctrl = true })) { 67 try self.deleteToStart(); 68 } else if (key.text) |text| { 69 try self.buf.insertSliceBefore(self.byteOffsetToCursor(), text); 70 self.cursor_idx += 1; 71 self.grapheme_count += 1; 72 } 73 }, 74 } 75} 76 77/// insert text at the cursor position 78pub fn insertSliceAtCursor(self: *TextInput, data: []const u8) !void { 79 var iter = self.unicode.graphemeIterator(data); 80 var byte_offset_to_cursor = self.byteOffsetToCursor(); 81 while (iter.next()) |text| { 82 try self.buf.insertSliceBefore(byte_offset_to_cursor, text.bytes(data)); 83 byte_offset_to_cursor += text.len; 84 self.cursor_idx += 1; 85 self.grapheme_count += 1; 86 } 87} 88 89pub fn sliceToCursor(self: *TextInput, buf: []u8) []const u8 { 90 const offset = self.byteOffsetToCursor(); 91 assert(offset <= buf.len); // provided buf was too small 92 93 if (offset <= self.buf.items.len) { 94 @memcpy(buf[0..offset], self.buf.items[0..offset]); 95 } else { 96 @memcpy(buf[0..self.buf.items.len], self.buf.items); 97 const second_half = self.buf.secondHalf(); 98 const copy_len = offset - self.buf.items.len; 99 @memcpy(buf[self.buf.items.len .. self.buf.items.len + copy_len], second_half[0..copy_len]); 100 } 101 return buf[0..offset]; 102} 103 104/// calculates the display width from the draw_offset to the cursor 105fn widthToCursor(self: *TextInput, win: Window) usize { 106 var width: usize = 0; 107 var first_iter = self.unicode.graphemeIterator(self.buf.items); 108 var i: usize = 0; 109 while (first_iter.next()) |grapheme| { 110 defer i += 1; 111 if (i < self.draw_offset) { 112 continue; 113 } 114 if (i == self.cursor_idx) return width; 115 const g = grapheme.bytes(self.buf.items); 116 width += win.gwidth(g); 117 } 118 const second_half = self.buf.secondHalf(); 119 var second_iter = self.unicode.graphemeIterator(second_half); 120 while (second_iter.next()) |grapheme| { 121 defer i += 1; 122 if (i < self.draw_offset) { 123 continue; 124 } 125 if (i == self.cursor_idx) return width; 126 const g = grapheme.bytes(second_half); 127 width += win.gwidth(g); 128 } 129 return width; 130} 131 132pub fn draw(self: *TextInput, win: Window) void { 133 if (self.cursor_idx < self.draw_offset) self.draw_offset = self.cursor_idx; 134 if (win.width == 0) return; 135 while (true) { 136 const width = self.widthToCursor(win); 137 if (width >= win.width) { 138 self.draw_offset +|= width - win.width + 1; 139 continue; 140 } else break; 141 } 142 143 self.prev_cursor_idx = self.cursor_idx; 144 self.prev_cursor_col = 0; 145 146 // assumption!! the gap is never within a grapheme 147 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 148 var first_iter = self.unicode.graphemeIterator(self.buf.items); 149 var col: usize = 0; 150 var i: usize = 0; 151 while (first_iter.next()) |grapheme| { 152 if (i < self.draw_offset) { 153 i += 1; 154 continue; 155 } 156 const g = grapheme.bytes(self.buf.items); 157 const w = win.gwidth(g); 158 if (col + w >= win.width) { 159 win.writeCell(win.width - 1, 0, .{ .char = ellipsis }); 160 break; 161 } 162 win.writeCell(col, 0, .{ 163 .char = .{ 164 .grapheme = g, 165 .width = w, 166 }, 167 }); 168 col += w; 169 i += 1; 170 if (i == self.cursor_idx) self.prev_cursor_col = col; 171 } 172 const second_half = self.buf.secondHalf(); 173 var second_iter = self.unicode.graphemeIterator(second_half); 174 while (second_iter.next()) |grapheme| { 175 if (i < self.draw_offset) { 176 i += 1; 177 continue; 178 } 179 const g = grapheme.bytes(second_half); 180 const w = win.gwidth(g); 181 if (col + w > win.width) { 182 win.writeCell(win.width - 1, 0, .{ .char = ellipsis }); 183 break; 184 } 185 win.writeCell(col, 0, .{ 186 .char = .{ 187 .grapheme = g, 188 .width = w, 189 }, 190 }); 191 col += w; 192 i += 1; 193 if (i == self.cursor_idx) self.prev_cursor_col = col; 194 } 195 if (self.draw_offset > 0) { 196 win.writeCell(0, 0, .{ .char = ellipsis }); 197 } 198 win.showCursor(self.prev_cursor_col, 0); 199} 200 201pub fn clearAndFree(self: *TextInput) void { 202 self.buf.clearAndFree(); 203 self.reset(); 204} 205 206pub fn clearRetainingCapacity(self: *TextInput) void { 207 self.buf.clearRetainingCapacity(); 208 self.reset(); 209} 210 211pub fn toOwnedSlice(self: *TextInput) ![]const u8 { 212 defer self.reset(); 213 return self.buf.toOwnedSlice(); 214} 215 216fn reset(self: *TextInput) void { 217 self.cursor_idx = 0; 218 self.grapheme_count = 0; 219 self.draw_offset = 0; 220 self.prev_cursor_col = 0; 221 self.prev_cursor_idx = 0; 222} 223 224// returns the number of bytes before the cursor 225// (since GapBuffers are strictly speaking not contiguous, this is a number in 0..realLength() 226// which would need to be fed to realIndex() to get an actual offset into self.buf.items.ptr) 227pub fn byteOffsetToCursor(self: TextInput) usize { 228 // assumption! the gap is never in the middle of a grapheme 229 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 230 var iter = self.unicode.graphemeIterator(self.buf.items); 231 var offset: usize = 0; 232 var i: usize = 0; 233 while (iter.next()) |grapheme| { 234 if (i == self.cursor_idx) break; 235 offset += grapheme.len; 236 i += 1; 237 } else { 238 var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 239 while (second_iter.next()) |grapheme| { 240 if (i == self.cursor_idx) break; 241 offset += grapheme.len; 242 i += 1; 243 } 244 } 245 return offset; 246} 247 248fn deleteToEnd(self: *TextInput) !void { 249 const offset = self.byteOffsetToCursor(); 250 try self.buf.replaceRangeAfter(offset, self.buf.realLength() - offset, &.{}); 251 self.grapheme_count = self.cursor_idx; 252} 253 254fn deleteToStart(self: *TextInput) !void { 255 const offset = self.byteOffsetToCursor(); 256 try self.buf.replaceRangeBefore(0, offset, &.{}); 257 self.grapheme_count -= self.cursor_idx; 258 self.cursor_idx = 0; 259} 260 261fn deleteBeforeCursor(self: *TextInput) !void { 262 // assumption! the gap is never in the middle of a grapheme 263 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 264 var iter = self.unicode.graphemeIterator(self.buf.items); 265 var offset: usize = 0; 266 var i: usize = 1; 267 while (iter.next()) |grapheme| { 268 if (i == self.cursor_idx) { 269 try self.buf.replaceRangeBefore(offset, grapheme.len, &.{}); 270 self.cursor_idx -= 1; 271 self.grapheme_count -= 1; 272 return; 273 } 274 offset += grapheme.len; 275 i += 1; 276 } else { 277 var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 278 while (second_iter.next()) |grapheme| { 279 if (i == self.cursor_idx) { 280 try self.buf.replaceRangeBefore(offset, grapheme.len, &.{}); 281 self.cursor_idx -= 1; 282 self.grapheme_count -= 1; 283 return; 284 } 285 offset += grapheme.len; 286 i += 1; 287 } 288 } 289} 290 291fn deleteAtCursor(self: *TextInput) !void { 292 // assumption! the gap is never in the middle of a grapheme 293 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 294 var iter = self.unicode.graphemeIterator(self.buf.items); 295 var offset: usize = 0; 296 var i: usize = 1; 297 while (iter.next()) |grapheme| { 298 if (i == self.cursor_idx + 1) { 299 try self.buf.replaceRangeAfter(offset, grapheme.len, &.{}); 300 self.grapheme_count -= 1; 301 return; 302 } 303 offset += grapheme.len; 304 i += 1; 305 } else { 306 var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 307 while (second_iter.next()) |grapheme| { 308 if (i == self.cursor_idx + 1) { 309 try self.buf.replaceRangeAfter(offset, grapheme.len, &.{}); 310 self.grapheme_count -= 1; 311 return; 312 } 313 offset += grapheme.len; 314 i += 1; 315 } 316 } 317} 318 319test "assertion" { 320 const alloc = std.testing.allocator_instance.allocator(); 321 const unicode = try Unicode.init(alloc); 322 defer unicode.deinit(); 323 const astronaut = "👩‍🚀"; 324 const astronaut_emoji: Key = .{ 325 .text = astronaut, 326 .codepoint = try std.unicode.utf8Decode(astronaut[0..4]), 327 }; 328 var input = TextInput.init(std.testing.allocator, &unicode); 329 defer input.deinit(); 330 for (0..6) |_| { 331 try input.update(.{ .key_press = astronaut_emoji }); 332 } 333} 334 335test "sliceToCursor" { 336 const alloc = std.testing.allocator_instance.allocator(); 337 const unicode = try Unicode.init(alloc); 338 defer unicode.deinit(); 339 var input = init(alloc, &unicode); 340 defer input.deinit(); 341 try input.insertSliceAtCursor("hello, world"); 342 input.cursor_idx = 2; 343 var buf: [32]u8 = undefined; 344 try std.testing.expectEqualStrings("he", input.sliceToCursor(&buf)); 345 input.buf.moveGap(3); 346 input.cursor_idx = 5; 347 try std.testing.expectEqualStrings("hello", input.sliceToCursor(&buf)); 348}