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