this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 880 lines 32 kB view raw
1const std = @import("std"); 2const uucode = @import("uucode"); 3const vaxis = @import("../main.zig"); 4 5const vxfw = @import("vxfw.zig"); 6 7const assert = std.debug.assert; 8 9const Allocator = std.mem.Allocator; 10const Key = vaxis.Key; 11const Cell = vaxis.Cell; 12const Window = vaxis.Window; 13const unicode = vaxis.unicode; 14 15const TextField = @This(); 16 17const ellipsis: Cell.Character = .{ .grapheme = "", .width = 1 }; 18 19// Index of our cursor 20buf: Buffer, 21 22/// Style to draw the TextField with 23style: vaxis.Style = .{}, 24 25/// the number of graphemes to skip when drawing. Used for horizontal scrolling 26draw_offset: u16 = 0, 27/// the column we placed the cursor the last time we drew 28prev_cursor_col: u16 = 0, 29/// the grapheme index of the cursor the last time we drew 30prev_cursor_idx: u16 = 0, 31/// approximate distance from an edge before we scroll 32scroll_offset: u4 = 4, 33/// Previous width we drew at 34prev_width: u16 = 0, 35 36previous_val: []const u8 = "", 37 38userdata: ?*anyopaque = null, 39onChange: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null, 40onSubmit: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null, 41 42pub fn init(alloc: std.mem.Allocator) TextField { 43 return TextField{ 44 .buf = Buffer.init(alloc), 45 }; 46} 47 48pub fn deinit(self: *TextField) void { 49 self.buf.allocator.free(self.previous_val); 50 self.buf.deinit(); 51} 52 53pub fn widget(self: *TextField) vxfw.Widget { 54 return .{ 55 .userdata = self, 56 .eventHandler = typeErasedEventHandler, 57 .drawFn = typeErasedDrawFn, 58 }; 59} 60 61fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 62 const self: *TextField = @ptrCast(@alignCast(ptr)); 63 return self.handleEvent(ctx, event); 64} 65 66pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 67 switch (event) { 68 .focus_out, .focus_in => ctx.redraw = true, 69 .key_press => |key| { 70 if (key.matches(Key.backspace, .{})) { 71 self.deleteBeforeCursor(); 72 return self.checkChanged(ctx); 73 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { 74 self.deleteAfterCursor(); 75 return self.checkChanged(ctx); 76 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) { 77 self.cursorLeft(); 78 return ctx.consumeAndRedraw(); 79 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { 80 self.cursorRight(); 81 return ctx.consumeAndRedraw(); 82 } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) { 83 self.buf.moveGapLeft(self.buf.firstHalf().len); 84 return ctx.consumeAndRedraw(); 85 } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) { 86 self.buf.moveGapRight(self.buf.secondHalf().len); 87 return ctx.consumeAndRedraw(); 88 } else if (key.matches('k', .{ .ctrl = true })) { 89 self.deleteToEnd(); 90 return self.checkChanged(ctx); 91 } else if (key.matches('u', .{ .ctrl = true })) { 92 self.deleteToStart(); 93 return self.checkChanged(ctx); 94 } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) { 95 self.moveBackwardWordwise(); 96 return ctx.consumeAndRedraw(); 97 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) { 98 self.moveForwardWordwise(); 99 return ctx.consumeAndRedraw(); 100 } else if (key.matches(Key.backspace, .{ .alt = true })) { 101 self.deleteWordBefore(); 102 return self.checkChanged(ctx); 103 } else if (key.matches('w', .{ .ctrl = true })) { 104 self.deleteWordBeforeWhitespace(); 105 return self.checkChanged(ctx); 106 } else if (key.matches('d', .{ .alt = true })) { 107 self.deleteWordAfter(); 108 return self.checkChanged(ctx); 109 } else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) { 110 if (self.onSubmit) |onSubmit| { 111 const value = try self.toOwnedSlice(); 112 // Get a ref to the allocator in case onSubmit deinits the TextField 113 const allocator = self.buf.allocator; 114 defer allocator.free(value); 115 try onSubmit(self.userdata, ctx, value); 116 return ctx.consumeAndRedraw(); 117 } 118 } else if (key.text) |text| { 119 try self.insertSliceAtCursor(text); 120 return self.checkChanged(ctx); 121 } 122 }, 123 else => {}, 124 } 125} 126 127fn checkChanged(self: *TextField, ctx: *vxfw.EventContext) anyerror!void { 128 ctx.consumeAndRedraw(); 129 const onChange = self.onChange orelse return; 130 const new = try self.buf.dupe(); 131 defer { 132 self.buf.allocator.free(self.previous_val); 133 self.previous_val = new; 134 } 135 if (std.mem.eql(u8, new, self.previous_val)) return; 136 try onChange(self.userdata, ctx, new); 137} 138 139/// insert text at the cursor position 140pub fn insertSliceAtCursor(self: *TextField, data: []const u8) std.mem.Allocator.Error!void { 141 var iter = unicode.graphemeIterator(data); 142 while (iter.next()) |text| { 143 try self.buf.insertSliceAtCursor(text.bytes(data)); 144 } 145} 146 147pub fn sliceToCursor(self: *TextField, buf: []u8) []const u8 { 148 assert(buf.len >= self.buf.cursor); 149 @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf()); 150 return buf[0..self.buf.cursor]; 151} 152 153/// calculates the display width from the draw_offset to the cursor 154pub fn widthToCursor(self: *TextField, ctx: vxfw.DrawContext) u16 { 155 var width: u16 = 0; 156 const first_half = self.buf.firstHalf(); 157 var first_iter = unicode.graphemeIterator(first_half); 158 var i: usize = 0; 159 while (first_iter.next()) |grapheme| { 160 defer i += 1; 161 if (i < self.draw_offset) { 162 continue; 163 } 164 const g = grapheme.bytes(first_half); 165 width += @intCast(ctx.stringWidth(g)); 166 } 167 return width; 168} 169 170pub fn cursorLeft(self: *TextField) void { 171 // We need to find the size of the last grapheme in the first half 172 var iter = unicode.graphemeIterator(self.buf.firstHalf()); 173 var len: usize = 0; 174 while (iter.next()) |grapheme| { 175 len = grapheme.len; 176 } 177 self.buf.moveGapLeft(len); 178} 179 180pub fn cursorRight(self: *TextField) void { 181 var iter = unicode.graphemeIterator(self.buf.secondHalf()); 182 const grapheme = iter.next() orelse return; 183 self.buf.moveGapRight(grapheme.len); 184} 185 186pub fn graphemesBeforeCursor(self: *const TextField) u16 { 187 const first_half = self.buf.firstHalf(); 188 var first_iter = unicode.graphemeIterator(first_half); 189 var i: u16 = 0; 190 while (first_iter.next()) |_| { 191 i += 1; 192 } 193 return i; 194} 195 196fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 197 const self: *TextField = @ptrCast(@alignCast(ptr)); 198 return self.draw(ctx); 199} 200 201pub fn draw(self: *TextField, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 202 std.debug.assert(ctx.max.width != null); 203 const max_width = ctx.max.width.?; 204 if (max_width != self.prev_width) { 205 self.prev_width = max_width; 206 self.draw_offset = 0; 207 self.prev_cursor_col = 0; 208 } 209 // Create a surface with max width and a minimum height of 1. 210 var surface = try vxfw.Surface.init( 211 ctx.arena, 212 self.widget(), 213 .{ .width = max_width, .height = @max(ctx.min.height, 1) }, 214 ); 215 216 const base: vaxis.Cell = .{ .style = self.style }; 217 @memset(surface.buffer, base); 218 const style = self.style; 219 const cursor_idx = self.graphemesBeforeCursor(); 220 if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx; 221 if (max_width == 0) return surface; 222 while (true) { 223 const width = self.widthToCursor(ctx); 224 if (width >= max_width) { 225 self.draw_offset +|= width - max_width + 1; 226 continue; 227 } else break; 228 } 229 230 self.prev_cursor_idx = cursor_idx; 231 self.prev_cursor_col = 0; 232 233 const first_half = self.buf.firstHalf(); 234 var first_iter = unicode.graphemeIterator(first_half); 235 var col: u16 = 0; 236 var i: u16 = 0; 237 while (first_iter.next()) |grapheme| { 238 if (i < self.draw_offset) { 239 i += 1; 240 continue; 241 } 242 const g = grapheme.bytes(first_half); 243 const w: u8 = @intCast(ctx.stringWidth(g)); 244 if (col + w >= max_width) { 245 surface.writeCell(max_width - 1, 0, .{ 246 .char = ellipsis, 247 .style = style, 248 }); 249 break; 250 } 251 surface.writeCell(@intCast(col), 0, .{ 252 .char = .{ 253 .grapheme = g, 254 .width = w, 255 }, 256 .style = style, 257 }); 258 col += w; 259 i += 1; 260 if (i == cursor_idx) self.prev_cursor_col = col; 261 } 262 const second_half = self.buf.secondHalf(); 263 var second_iter = unicode.graphemeIterator(second_half); 264 while (second_iter.next()) |grapheme| { 265 if (i < self.draw_offset) { 266 i += 1; 267 continue; 268 } 269 const g = grapheme.bytes(second_half); 270 const w: u8 = @intCast(ctx.stringWidth(g)); 271 if (col + w > max_width) { 272 surface.writeCell(max_width - 1, 0, .{ 273 .char = ellipsis, 274 .style = style, 275 }); 276 break; 277 } 278 surface.writeCell(@intCast(col), 0, .{ 279 .char = .{ 280 .grapheme = g, 281 .width = w, 282 }, 283 .style = style, 284 }); 285 col += w; 286 i += 1; 287 if (i == cursor_idx) self.prev_cursor_col = col; 288 } 289 if (self.draw_offset > 0) { 290 surface.writeCell(0, 0, .{ 291 .char = ellipsis, 292 .style = style, 293 }); 294 } 295 surface.cursor = .{ .col = @intCast(self.prev_cursor_col), .row = 0 }; 296 return surface; 297 // win.showCursor(self.prev_cursor_col, 0); 298} 299 300pub fn clearAndFree(self: *TextField) void { 301 self.buf.clearAndFree(); 302 self.reset(); 303} 304 305pub fn clearRetainingCapacity(self: *TextField) void { 306 self.buf.clearRetainingCapacity(); 307 self.reset(); 308} 309 310pub fn toOwnedSlice(self: *TextField) ![]const u8 { 311 defer self.reset(); 312 return self.buf.toOwnedSlice(); 313} 314 315pub fn reset(self: *TextField) void { 316 self.draw_offset = 0; 317 self.prev_cursor_col = 0; 318 self.prev_cursor_idx = 0; 319} 320 321// returns the number of bytes before the cursor 322pub fn byteOffsetToCursor(self: TextField) usize { 323 return self.buf.cursor; 324} 325 326pub fn deleteToEnd(self: *TextField) void { 327 self.buf.growGapRight(self.buf.secondHalf().len); 328} 329 330pub fn deleteToStart(self: *TextField) void { 331 self.buf.growGapLeft(self.buf.cursor); 332} 333 334pub fn deleteBeforeCursor(self: *TextField) void { 335 // We need to find the size of the last grapheme in the first half 336 var iter = unicode.graphemeIterator(self.buf.firstHalf()); 337 var len: usize = 0; 338 while (iter.next()) |grapheme| { 339 len = grapheme.len; 340 } 341 self.buf.growGapLeft(len); 342} 343 344pub fn deleteAfterCursor(self: *TextField) void { 345 var iter = unicode.graphemeIterator(self.buf.secondHalf()); 346 const grapheme = iter.next() orelse return; 347 self.buf.growGapRight(grapheme.len); 348} 349 350const DecodedCodepoint = struct { 351 cp: u21, 352 start: usize, 353 len: usize, 354}; 355 356fn decodeCodepointAt(bytes: []const u8, start: usize) DecodedCodepoint { 357 const first = bytes[start]; 358 const len = std.unicode.utf8ByteSequenceLength(first) catch 1; 359 const capped_len = @min(len, bytes.len - start); 360 const slice = bytes[start .. start + capped_len]; 361 const cp = std.unicode.utf8Decode(slice) catch { 362 return .{ .cp = first, .start = start, .len = 1 }; 363 }; 364 return .{ .cp = cp, .start = start, .len = capped_len }; 365} 366 367fn isUtf8ContinuationByte(c: u8) bool { 368 return (c & 0b1100_0000) == 0b1000_0000; 369} 370 371fn decodeCodepointBefore(bytes: []const u8, end: usize) DecodedCodepoint { 372 var start = end - 1; 373 while (start > 0 and isUtf8ContinuationByte(bytes[start])) : (start -= 1) {} 374 const slice = bytes[start..end]; 375 const cp = std.unicode.utf8Decode(slice) catch { 376 return .{ .cp = bytes[end - 1], .start = end - 1, .len = 1 }; 377 }; 378 return .{ .cp = cp, .start = start, .len = end - start }; 379} 380 381/// Returns true if the codepoint is a readline-style word constituent. 382fn isWordCodepoint(cp: u21) bool { 383 if (cp == '_') return true; 384 return switch (uucode.get(.general_category, cp)) { 385 .letter_uppercase, 386 .letter_lowercase, 387 .letter_titlecase, 388 .letter_modifier, 389 .letter_other, 390 .number_decimal_digit, 391 .number_letter, 392 .number_other, 393 .mark_nonspacing, 394 .mark_spacing_combining, 395 .mark_enclosing, 396 .punctuation_connector, 397 => true, 398 else => false, 399 }; 400} 401 402fn isWhitespaceCodepoint(cp: u21) bool { 403 return switch (cp) { 404 ' ', '\t', '\n', '\r', 0x0b, 0x0c, 0x85 => true, 405 else => switch (uucode.get(.general_category, cp)) { 406 .separator_space, 407 .separator_line, 408 .separator_paragraph, 409 => true, 410 else => false, 411 }, 412 }; 413} 414 415/// Moves the cursor backward by one word using character-class boundaries. 416/// Skips non-word characters, then skips word characters (matching readline backward-word). 417pub fn moveBackwardWordwise(self: *TextField) void { 418 const first_half = self.buf.firstHalf(); 419 var i: usize = first_half.len; 420 // Skip non-word characters 421 while (i > 0) { 422 const decoded = decodeCodepointBefore(first_half, i); 423 if (isWordCodepoint(decoded.cp)) break; 424 i = decoded.start; 425 } 426 // Skip word characters 427 while (i > 0) { 428 const decoded = decodeCodepointBefore(first_half, i); 429 if (!isWordCodepoint(decoded.cp)) break; 430 i = decoded.start; 431 } 432 self.buf.moveGapLeft(self.buf.cursor - i); 433} 434 435/// Moves the cursor forward by one word using character-class boundaries. 436/// Skips non-word characters, then skips word characters — landing at the end of the next word 437/// (matching readline forward-word). 438pub fn moveForwardWordwise(self: *TextField) void { 439 const second_half = self.buf.secondHalf(); 440 var i: usize = 0; 441 // Skip non-word characters 442 while (i < second_half.len) { 443 const decoded = decodeCodepointAt(second_half, i); 444 if (isWordCodepoint(decoded.cp)) break; 445 i += decoded.len; 446 } 447 // Skip word characters 448 while (i < second_half.len) { 449 const decoded = decodeCodepointAt(second_half, i); 450 if (!isWordCodepoint(decoded.cp)) break; 451 i += decoded.len; 452 } 453 self.buf.moveGapRight(i); 454} 455 456/// Deletes the word before the cursor using character-class boundaries 457/// (matching readline backward-kill-word / Alt+Backspace). 458pub fn deleteWordBefore(self: *TextField) void { 459 const pre = self.buf.cursor; 460 self.moveBackwardWordwise(); 461 self.buf.growGapRight(pre - self.buf.cursor); 462} 463 464/// Deletes the word before the cursor using whitespace boundaries 465/// (matching readline unix-word-rubout / Ctrl+W). 466pub fn deleteWordBeforeWhitespace(self: *TextField) void { 467 const first_half = self.buf.firstHalf(); 468 var i: usize = first_half.len; 469 // Skip trailing whitespace 470 while (i > 0) { 471 const decoded = decodeCodepointBefore(first_half, i); 472 if (!isWhitespaceCodepoint(decoded.cp)) break; 473 i = decoded.start; 474 } 475 // Skip non-whitespace 476 while (i > 0) { 477 const decoded = decodeCodepointBefore(first_half, i); 478 if (isWhitespaceCodepoint(decoded.cp)) break; 479 i = decoded.start; 480 } 481 const to_delete = self.buf.cursor - i; 482 self.buf.moveGapLeft(to_delete); 483 self.buf.growGapRight(to_delete); 484} 485 486/// Deletes the word after the cursor using character-class boundaries 487/// (matching readline kill-word / Alt+D). 488pub fn deleteWordAfter(self: *TextField) void { 489 const second_half = self.buf.secondHalf(); 490 var i: usize = 0; 491 // Skip non-word characters 492 while (i < second_half.len) { 493 const decoded = decodeCodepointAt(second_half, i); 494 if (isWordCodepoint(decoded.cp)) break; 495 i += decoded.len; 496 } 497 // Skip word characters 498 while (i < second_half.len) { 499 const decoded = decodeCodepointAt(second_half, i); 500 if (!isWordCodepoint(decoded.cp)) break; 501 i += decoded.len; 502 } 503 self.buf.growGapRight(i); 504} 505 506test "sliceToCursor" { 507 var input = init(std.testing.allocator); 508 defer input.deinit(); 509 try input.insertSliceAtCursor("hello, world"); 510 input.cursorLeft(); 511 input.cursorLeft(); 512 input.cursorLeft(); 513 var buf: [32]u8 = undefined; 514 try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf)); 515 input.cursorRight(); 516 try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf)); 517} 518 519pub const Buffer = struct { 520 allocator: std.mem.Allocator, 521 buffer: []u8, 522 cursor: usize, 523 gap_size: usize, 524 525 pub fn init(allocator: std.mem.Allocator) Buffer { 526 return .{ 527 .allocator = allocator, 528 .buffer = &.{}, 529 .cursor = 0, 530 .gap_size = 0, 531 }; 532 } 533 534 pub fn deinit(self: *Buffer) void { 535 self.allocator.free(self.buffer); 536 } 537 538 pub fn firstHalf(self: Buffer) []const u8 { 539 return self.buffer[0..self.cursor]; 540 } 541 542 pub fn secondHalf(self: Buffer) []const u8 { 543 return self.buffer[self.cursor + self.gap_size ..]; 544 } 545 546 pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void { 547 // Always grow by 512 bytes 548 const new_size = self.buffer.len + n + 512; 549 // Allocate the new memory 550 const new_memory = try self.allocator.alloc(u8, new_size); 551 // Copy the first half 552 @memcpy(new_memory[0..self.cursor], self.firstHalf()); 553 // Copy the second half 554 const second_half = self.secondHalf(); 555 @memcpy(new_memory[new_size - second_half.len ..], second_half); 556 self.allocator.free(self.buffer); 557 self.buffer = new_memory; 558 self.gap_size = new_size - second_half.len - self.cursor; 559 } 560 561 pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void { 562 if (slice.len == 0) return; 563 if (self.gap_size <= slice.len) try self.grow(slice.len); 564 @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice); 565 self.cursor += slice.len; 566 self.gap_size -= slice.len; 567 } 568 569 /// Move the gap n bytes to the left 570 pub fn moveGapLeft(self: *Buffer, n: usize) void { 571 const new_idx = self.cursor -| n; 572 const dst = self.buffer[new_idx + self.gap_size ..]; 573 const src = self.buffer[new_idx..self.cursor]; 574 std.mem.copyForwards(u8, dst, src); 575 self.cursor = new_idx; 576 } 577 578 pub fn moveGapRight(self: *Buffer, n: usize) void { 579 const new_idx = self.cursor + n; 580 const dst = self.buffer[self.cursor..]; 581 const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size]; 582 std.mem.copyForwards(u8, dst, src); 583 self.cursor = new_idx; 584 } 585 586 /// grow the gap by moving the cursor n bytes to the left 587 pub fn growGapLeft(self: *Buffer, n: usize) void { 588 // gap grows by the delta 589 self.gap_size += n; 590 self.cursor -|= n; 591 } 592 593 /// grow the gap by removing n bytes after the cursor 594 pub fn growGapRight(self: *Buffer, n: usize) void { 595 self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor); 596 } 597 598 pub fn clearAndFree(self: *Buffer) void { 599 self.cursor = 0; 600 self.allocator.free(self.buffer); 601 self.buffer = &.{}; 602 self.gap_size = 0; 603 } 604 605 pub fn clearRetainingCapacity(self: *Buffer) void { 606 self.cursor = 0; 607 self.gap_size = self.buffer.len; 608 } 609 610 pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 { 611 const slice = try self.dupe(); 612 self.clearAndFree(); 613 return slice; 614 } 615 616 pub fn realLength(self: *const Buffer) usize { 617 return self.firstHalf().len + self.secondHalf().len; 618 } 619 620 pub fn dupe(self: *const Buffer) std.mem.Allocator.Error![]const u8 { 621 const first_half = self.firstHalf(); 622 const second_half = self.secondHalf(); 623 const buf = try self.allocator.alloc(u8, first_half.len + second_half.len); 624 @memcpy(buf[0..first_half.len], first_half); 625 @memcpy(buf[first_half.len..], second_half); 626 return buf; 627 } 628}; 629 630test "TextField.zig: Buffer" { 631 var gap_buf = Buffer.init(std.testing.allocator); 632 defer gap_buf.deinit(); 633 634 try gap_buf.insertSliceAtCursor("abc"); 635 try std.testing.expectEqualStrings("abc", gap_buf.firstHalf()); 636 try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 637 638 gap_buf.moveGapLeft(1); 639 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 640 try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 641 642 try gap_buf.insertSliceAtCursor(" "); 643 try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf()); 644 try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 645 646 gap_buf.growGapLeft(1); 647 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 648 try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 649 try std.testing.expectEqual(2, gap_buf.cursor); 650 651 gap_buf.growGapRight(1); 652 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 653 try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 654 try std.testing.expectEqual(2, gap_buf.cursor); 655} 656 657test TextField { 658 const io = std.testing.io; 659 660 // Boiler plate draw context init 661 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 662 defer arena.deinit(); 663 vxfw.DrawContext.init(.unicode); 664 665 // Create some object which reacts to text field changes 666 const Foo = struct { 667 allocator: std.mem.Allocator, 668 text: []const u8, 669 670 fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, str: []const u8) anyerror!void { 671 const foo: *@This() = @ptrCast(@alignCast(ptr)); 672 foo.text = try foo.allocator.dupe(u8, str); 673 ctx.consumeAndRedraw(); 674 } 675 }; 676 var foo: Foo = .{ .text = "", .allocator = arena.allocator() }; 677 678 // Text field expands to the width, so it can't be null. It is always 1 line tall 679 const draw_ctx: vxfw.DrawContext = .{ 680 .arena = arena.allocator(), 681 .min = .{}, 682 .max = .{ .width = 8, .height = 1 }, 683 .cell_size = .{ .width = 10, .height = 20 }, 684 }; 685 _ = draw_ctx; 686 687 var ctx: vxfw.EventContext = .{ 688 .io = io, 689 .alloc = arena.allocator(), 690 .cmds = .empty, 691 }; 692 693 // Enough boiler plate...Create the text field 694 var text_field = TextField.init(std.testing.allocator); 695 defer text_field.deinit(); 696 text_field.onChange = Foo.onChange; 697 text_field.onSubmit = Foo.onChange; 698 text_field.userdata = &foo; 699 700 const tf_widget = text_field.widget(); 701 // Send some key events to the widget 702 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'H', .text = "H" } }); 703 // The foo object stores the last text that we saw from an onChange call 704 try std.testing.expectEqualStrings("H", foo.text); 705 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'e', .text = "e" } }); 706 try std.testing.expectEqualStrings("He", foo.text); 707 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } }); 708 try std.testing.expectEqualStrings("Hel", foo.text); 709 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } }); 710 try std.testing.expectEqualStrings("Hell", foo.text); 711 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'o', .text = "o" } }); 712 try std.testing.expectEqualStrings("Hello", foo.text); 713 714 // An arrow moves the cursor. The text doesn't change 715 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.left } }); 716 try std.testing.expectEqualStrings("Hello", foo.text); 717 718 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = '_', .text = "_" } }); 719 try std.testing.expectEqualStrings("Hell_o", foo.text); 720} 721 722test "moveBackwardWordwise stops at word boundary" { 723 var input = TextField.init(std.testing.allocator); 724 defer input.deinit(); 725 try input.insertSliceAtCursor("hello-world"); 726 input.moveBackwardWordwise(); 727 try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 728 try std.testing.expectEqualStrings("world", input.buf.secondHalf()); 729 input.moveBackwardWordwise(); 730 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 731 try std.testing.expectEqualStrings("hello-world", input.buf.secondHalf()); 732} 733 734test "moveForwardWordwise stops at end of word" { 735 var input = TextField.init(std.testing.allocator); 736 defer input.deinit(); 737 try input.insertSliceAtCursor("hello-world"); 738 input.buf.moveGapLeft(input.buf.firstHalf().len); 739 input.moveForwardWordwise(); 740 // Stops at end of "hello": "hello|-world" 741 try std.testing.expectEqualStrings("hello", input.buf.firstHalf()); 742 try std.testing.expectEqualStrings("-world", input.buf.secondHalf()); 743 input.moveForwardWordwise(); 744 // Skips "-" then stops at end of "world": "hello-world|" 745 try std.testing.expectEqualStrings("hello-world", input.buf.firstHalf()); 746 try std.testing.expectEqualStrings("", input.buf.secondHalf()); 747} 748 749test "moveBackwardWordwise with path separators" { 750 var input = TextField.init(std.testing.allocator); 751 defer input.deinit(); 752 try input.insertSliceAtCursor("/usr/local/bin"); 753 input.moveBackwardWordwise(); 754 try std.testing.expectEqualStrings("/usr/local/", input.buf.firstHalf()); 755 input.moveBackwardWordwise(); 756 try std.testing.expectEqualStrings("/usr/", input.buf.firstHalf()); 757 input.moveBackwardWordwise(); 758 try std.testing.expectEqualStrings("/", input.buf.firstHalf()); 759 input.moveBackwardWordwise(); 760 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 761} 762 763test "deleteWordBefore with hyphens" { 764 var input = TextField.init(std.testing.allocator); 765 defer input.deinit(); 766 try input.insertSliceAtCursor("hello-world"); 767 input.deleteWordBefore(); 768 try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 769 try std.testing.expectEqualStrings("", input.buf.secondHalf()); 770 input.deleteWordBefore(); 771 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 772} 773 774test "deleteWordBeforeWhitespace deletes to whitespace" { 775 var input = TextField.init(std.testing.allocator); 776 defer input.deinit(); 777 try input.insertSliceAtCursor("hello-world foo.bar"); 778 input.deleteWordBeforeWhitespace(); 779 try std.testing.expectEqualStrings("hello-world ", input.buf.firstHalf()); 780 input.deleteWordBeforeWhitespace(); 781 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 782} 783 784test "deleteWordAfter with mixed punctuation" { 785 var input = TextField.init(std.testing.allocator); 786 defer input.deinit(); 787 try input.insertSliceAtCursor("foo.bar baz"); 788 input.buf.moveGapLeft(input.buf.firstHalf().len); 789 input.deleteWordAfter(); 790 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 791 try std.testing.expectEqualStrings(".bar baz", input.buf.secondHalf()); 792 input.deleteWordAfter(); 793 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 794 try std.testing.expectEqualStrings(" baz", input.buf.secondHalf()); 795} 796 797test "moveForwardWordwise with dots" { 798 var input = TextField.init(std.testing.allocator); 799 defer input.deinit(); 800 try input.insertSliceAtCursor("foo.bar.baz"); 801 input.buf.moveGapLeft(input.buf.firstHalf().len); 802 input.moveForwardWordwise(); 803 try std.testing.expectEqualStrings("foo", input.buf.firstHalf()); 804 input.moveForwardWordwise(); 805 try std.testing.expectEqualStrings("foo.bar", input.buf.firstHalf()); 806 input.moveForwardWordwise(); 807 try std.testing.expectEqualStrings("foo.bar.baz", input.buf.firstHalf()); 808} 809 810test "word motion with underscores treats them as word chars" { 811 var input = TextField.init(std.testing.allocator); 812 defer input.deinit(); 813 try input.insertSliceAtCursor("hello_world-test"); 814 input.moveBackwardWordwise(); 815 try std.testing.expectEqualStrings("hello_world-", input.buf.firstHalf()); 816 input.moveBackwardWordwise(); 817 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 818} 819 820test "word motion with non-ASCII text" { 821 var input = TextField.init(std.testing.allocator); 822 defer input.deinit(); 823 try input.insertSliceAtCursor("café-latte"); 824 input.moveBackwardWordwise(); 825 try std.testing.expectEqualStrings("café-", input.buf.firstHalf()); 826 try std.testing.expectEqualStrings("latte", input.buf.secondHalf()); 827 input.moveBackwardWordwise(); 828 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 829 830 input.moveForwardWordwise(); 831 try std.testing.expectEqualStrings("caf\xc3\xa9", input.buf.firstHalf()); 832 try std.testing.expectEqualStrings("-latte", input.buf.secondHalf()); 833} 834 835test "non-ASCII punctuation acts as a separator" { 836 var input = TextField.init(std.testing.allocator); 837 defer input.deinit(); 838 try input.insertSliceAtCursor("hello\u{2014}world"); 839 input.moveBackwardWordwise(); 840 try std.testing.expectEqualStrings("hello\u{2014}", input.buf.firstHalf()); 841 try std.testing.expectEqualStrings("world", input.buf.secondHalf()); 842 843 input.buf.moveGapLeft(input.buf.firstHalf().len); 844 input.moveForwardWordwise(); 845 try std.testing.expectEqualStrings("hello", input.buf.firstHalf()); 846 try std.testing.expectEqualStrings("\u{2014}world", input.buf.secondHalf()); 847} 848 849test "deleteWordBeforeWhitespace handles unicode whitespace" { 850 var input = TextField.init(std.testing.allocator); 851 defer input.deinit(); 852 try input.insertSliceAtCursor("hello\u{3000}world"); 853 input.deleteWordBeforeWhitespace(); 854 try std.testing.expectEqualStrings("hello\u{3000}", input.buf.firstHalf()); 855 try std.testing.expectEqualStrings("", input.buf.secondHalf()); 856} 857 858test "deleteWordBefore with non-ASCII text" { 859 var input = TextField.init(std.testing.allocator); 860 defer input.deinit(); 861 try input.insertSliceAtCursor("über-cool"); 862 input.deleteWordBefore(); 863 try std.testing.expectEqualStrings("über-", input.buf.firstHalf()); 864 input.deleteWordBefore(); 865 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 866} 867 868test "word motion with spaces" { 869 var input = TextField.init(std.testing.allocator); 870 defer input.deinit(); 871 try input.insertSliceAtCursor("hello world"); 872 input.moveBackwardWordwise(); 873 try std.testing.expectEqualStrings("hello ", input.buf.firstHalf()); 874 input.moveBackwardWordwise(); 875 try std.testing.expectEqualStrings("", input.buf.firstHalf()); 876} 877 878test "refAllDecls" { 879 std.testing.refAllDecls(@This()); 880}