╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
445 switch (key) { 446 .arrow_up => index -|= cols, 447 .arrow_down => if (index + cols < count) { 448 index += cols; 449 }, 450 .arrow_left => index -|= 1, 451 .arrow_right => if (index + 1 < count) { 452 index += 1; 453 }, 454 .home => index = 0, 455 .end => index = count - 1, 456 .page_up => { 457 if (self.scroll.grid) |grid| if (slot_height > 0) { 458 const rows_per_page = grid.size.height / slot_height; 459 index -|= rows_per_page * cols; 460 }; 461 }, 462 .page_down => { 463 if (self.scroll.grid) |grid| if (slot_height > 0) { 464 const rows_per_page = grid.size.height / slot_height; 465 index = @min(index + rows_per_page * cols, count - 1); 466 }; 467 }, 468 // scroll wheel moves the focused cell by a full row so the 469 // viewport (via scrollToRect) follows in row-sized steps, 470 // matching how a scroll wheel feels in a grid view. 471 .mouse => |mouse| switch (mouse.action) { 472 .scroll => |dir| switch (dir) { 473 .up => index -|= cols, 474 .down => if (index + cols < count) { 475 index += cols; 476 }, 477 }, 478 else => {}, 479 }, 480 else => {}, 481 } 482 483 if (index != current_index) { 484 try root_focus.setFocus(in.text_boxes.items[index].getFocus().id); 485 if (in.cellRect(index)) |rect| { 486 self.scroll.scrollToRect(rect); 487 } 488 } 489 } 490 491 pub fn clearGrid(self: *Scroll) void { 492 self.scroll.clearGrid(); 493 } 494 495 pub fn getGrid(self: Scroll) ?Grid { 496 return self.scroll.getGrid(); 497 } 498 499 pub fn getFocus(self: *Scroll) *Focus { 500 return self.scroll.getFocus(); 501 } 502 503 pub fn getSelectedIndex(self: Scroll) ?usize { 504 const in = self.scroll.child.flow_box; 505 const child_id = in.focus.child_id orelse return null; 506 return in.indexOfFocusId(child_id); 507 } 508 509 fn inner(self: *Scroll) *FlowBox { 510 return &self.scroll.child.flow_box; 511 } 512 }; 513 }; 514 515 // an invisible widget that fills the horizontal space granted by its parent. 516 // used inside Box(horiz) with a min_size so the box reserves space for the 517 // children that follow, pushing them to the right. 518 pub const Spacer = struct { 519 focus: Focus, 520 grid: ?Grid, 521 522 pub fn init() Spacer { 523 return .{ 524 .focus = Focus.init(.container), 525 .grid = null, 526 }; 527 } 528 529 pub fn deinit(self: *Spacer, allocator: std.mem.Allocator) void { 530 self.focus.deinit(allocator); 531 if (self.grid) |*grid| { 532 grid.deinit(); 533 self.grid = null; 534 } 535 } 536 537 pub fn build(self: *Spacer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 538 _ = root_focus; 539 self.clearGrid(); 540 const width = constraint.max_size.width orelse return; 541 if (width == 0) return; 542 self.grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 543 } 544 545 pub fn input(self: *Spacer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 546 _ = self; 547 _ = allocator; 548 _ = key; 549 _ = root_focus; 550 } 551 552 pub fn clearGrid(self: *Spacer) void { 553 if (self.grid) |*grid| { 554 grid.deinit(); 555 self.grid = null; 556 } 557 } 558 559 pub fn getGrid(self: Spacer) ?Grid { 560 return self.grid; 561 } 562 563 pub fn getFocus(self: *Spacer) *Focus { 564 return &self.focus; 565 } 566 }; 567 568 // a single-child wrapper that builds the child at its natural size and 569 // positions its grid in the middle of the area granted by the parent 570 pub const Center = struct { 571 focus: Focus, 572 grid: ?Grid, 573 child: *Widget, 574 direction: Direction, 575 576 pub const Direction = enum { both, horiz, vert }; 577 578 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, direction: Direction) !Center { 579 const child = try allocator.create(Widget); 580 errdefer allocator.destroy(child); 581 child.* = child_widget; 582 return .{ 583 .focus = Focus.init(.container), 584 .grid = null, 585 .child = child, 586 .direction = direction, 587 }; 588 } 589 590 pub fn deinit(self: *Center, allocator: std.mem.Allocator) void { 591 self.focus.deinit(allocator); 592 if (self.grid) |*grid| { 593 grid.deinit(); 594 self.grid = null; 595 } 596 self.child.deinit(allocator); 597 allocator.destroy(self.child); 598 } 599 600 pub fn build(self: *Center, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 601 self.clearGrid(); 602 self.getFocus().clear(); 603 604 // build the child without forcing it to fill min_size; it sizes 605 // itself to its content within the available max. 606 try self.child.build(allocator, .{ 607 .min_size = .{ .width = null, .height = null }, 608 .max_size = constraint.max_size, 609 }, root_focus); 610 611 const child_grid = self.child.getGrid() orelse return; 612 613 // prefer max when given; otherwise grow to max(min, child) so that 614 // when only a min is set (e.g. the wasm path passes viewport rows 615 // as min_height with no max), we can still vertically center while 616 // letting taller content extend past the min. 617 const width = if (constraint.max_size.width) |w| w else if (constraint.min_size.width) |min_w| @max(min_w, child_grid.size.width) else child_grid.size.width; 618 const height = if (constraint.max_size.height) |h| h else if (constraint.min_size.height) |min_h| @max(min_h, child_grid.size.height) else child_grid.size.height; 619 if (width == 0 or height == 0) return; 620 621 const offset_x: usize = if (self.direction == .horiz or self.direction == .both) 622 (width -| child_grid.size.width) / 2 623 else 624 0; 625 const offset_y: usize = if (self.direction == .vert or self.direction == .both) 626 (height -| child_grid.size.height) / 2 627 else 628 0; 629 630 var grid = try Grid.init(allocator, .{ .width = width, .height = height }); 631 errdefer grid.deinit(); 632 try grid.drawGrid(child_grid, offset_x, offset_y); 633 try self.getFocus().addChild(allocator, self.child.getFocus(), child_grid.size, offset_x, offset_y); 634 self.getFocus().child_id = self.child.getFocus().id; 635 636 self.grid = grid; 637 } 638 639 pub fn input(self: *Center, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 640 try self.child.input(allocator, key, root_focus); 641 } 642 643 pub fn clearGrid(self: *Center) void { 644 if (self.grid) |*grid| { 645 grid.deinit(); 646 self.grid = null; 647 } 648 self.child.clearGrid(); 649 } 650 651 pub fn getGrid(self: Center) ?Grid { 652 return self.grid; 653 } 654 655 pub fn getFocus(self: *Center) *Focus { 656 return &self.focus; 657 } 658 }; 659 660 // renders truecolor ANSI art 661 pub const AnsiArt = struct { 662 focus: Focus, 663 grid: ?Grid, 664 content: []const u8, 665 666 pub fn init(content: []const u8) AnsiArt { 667 return .{ 668 .focus = Focus.init(.container), 669 .grid = null, 670 .content = content, 671 }; 672 } 673 674 pub fn deinit(self: *AnsiArt, allocator: std.mem.Allocator) void { 675 self.focus.deinit(allocator); 676 if (self.grid) |*grid| { 677 grid.deinit(); 678 self.grid = null; 679 } 680 } 681 682 pub fn build(self: *AnsiArt, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 683 _ = root_focus; 684 self.clearGrid(); 685 686 // parse into rows of cells, then copy into a rectangular grid 687 var rows: std.ArrayList(std.ArrayList(Grid.Cell)) = .empty; 688 defer { 689 for (rows.items) |*row| row.deinit(allocator); 690 rows.deinit(allocator); 691 } 692 var row: std.ArrayList(Grid.Cell) = .empty; 693 errdefer row.deinit(allocator); 694 695 var style: Grid.Style = .{}; 696 var width: usize = 0; 697 const content = self.content; 698 var i: usize = 0; 699 while (i < content.len) { 700 const byte = content[i]; 701 if (byte == '\n') { 702 width = @max(width, row.items.len); 703 try rows.append(allocator, row); 704 row = .empty; 705 i += 1; 706 } else if (byte == 0x1B and i + 1 < content.len and content[i + 1] == '[') { 707 // CSI: scan to the final byte (0x40..0x7E); apply it if it's 'm' 708 var j = i + 2; 709 while (j < content.len and !(content[j] >= 0x40 and content[j] <= 0x7E)) j += 1; 710 if (j >= content.len) { 711 i = content.len; // malformed trailing escape; stop 712 } else { 713 if (content[j] == 'm') applySgr(&style, content[i + 2 .. j]); 714 i = j + 1; 715 } 716 } else { 717 const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; 718 const end = @min(content.len, i + len); 719 const rune = content[i..end]; 720 const transparent = end - i == 1 and rune[0] == ' ' and 721 style.fg == null and style.bg == null and !style.inverted; 722 try row.append(allocator, .{ 723 .rune = if (transparent) null else rune, 724 .style = style, 725 }); 726 i = end; 727 } 728 } 729 // a trailing row with content but no closing newline 730 if (row.items.len > 0) { 731 width = @max(width, row.items.len); 732 try rows.append(allocator, row); 733 } else { 734 row.deinit(allocator); 735 } 736 row = .empty; // ownership moved into rows (or freed); keep errdefer safe 737 738 const height = rows.items.len; 739 if (width == 0 or height == 0) return; 740 741 const clamped_w = @min(width, constraint.max_size.width orelse width); 742 const clamped_h = @min(height, constraint.max_size.height orelse height); 743 744 var grid = try Grid.init(allocator, .{ .width = clamped_w, .height = clamped_h }); 745 errdefer grid.deinit(); 746 for (rows.items[0..clamped_h], 0..) |r, y| { 747 const n = @min(r.items.len, clamped_w); 748 for (r.items[0..n], 0..) |cell, x| { 749 grid.cells.items[try grid.cells.at(.{ y, x })] = cell; 750 } 751 } 752 self.grid = grid; 753 } 754 755 pub fn input(self: *AnsiArt, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 756 _ = self; 757 _ = allocator; 758 _ = key; 759 _ = root_focus; 760 } 761 762 pub fn clearGrid(self: *AnsiArt) void { 763 if (self.grid) |*grid| { 764 grid.deinit(); 765 self.grid = null; 766 } 767 } 768 769 pub fn getGrid(self: AnsiArt) ?Grid { 770 return self.grid; 771 } 772 773 pub fn getFocus(self: *AnsiArt) *Focus { 774 return &self.focus; 775 } 776 777 fn applySgr(style: *Grid.Style, params: []const u8) void { 778 var nums: [16]u32 = undefined; 779 var n: usize = 0; 780 var it = std.mem.splitScalar(u8, params, ';'); 781 while (it.next()) |tok| { 782 if (n >= nums.len) break; 783 // an empty parameter (e.g. bare "\x1b[m") means 0 784 nums[n] = std.fmt.parseInt(u32, tok, 10) catch 0; 785 n += 1; 786 } 787 if (n == 0) { 788 style.* = .{}; 789 return; 790 } 791 var i: usize = 0; 792 while (i < n) : (i += 1) { 793 switch (nums[i]) { 794 0 => style.* = .{}, 795 7 => style.inverted = true, 796 27 => style.inverted = false, 797 39 => style.fg = null, 798 49 => style.bg = null, 799 38, 48 => { 800 // truecolor form: 38;2;r;g;b — anything else is ignored 801 if (i + 4 < n and nums[i + 1] == 2) { 802 const c = Grid.Color{ 803 .r = @truncate(nums[i + 2]), 804 .g = @truncate(nums[i + 3]), 805 .b = @truncate(nums[i + 4]), 806 }; 807 if (nums[i] == 38) style.fg = c else style.bg = c; 808 i += 4; 809 } 810 }, 811 else => {}, 812 } 813 } 814 } 815 }; 816 817 // a full-screen wrapper that renders ANSI art behind whatever page it wraps 818 pub const AnsiBackground = struct { 819 grid: ?Grid, 820 child: *Widget, 821 art: AnsiArt, 822 session: *Session, 823 824 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, art_content: []const u8, session: *Session) !AnsiBackground { 825 var cw = child_widget; 826 const child = allocator.create(Widget) catch |e| { 827 cw.deinit(allocator); 828 return e; 829 }; 830 child.* = cw; 831 return .{ .grid = null, .child = child, .art = AnsiArt.init(art_content), .session = session }; 832 } 833 834 pub fn deinit(self: *AnsiBackground, allocator: std.mem.Allocator) void { 835 if (self.grid) |*grid| grid.deinit(); 836 self.art.deinit(allocator); 837 self.child.deinit(allocator); 838 allocator.destroy(self.child); 839 } 840 841 pub fn build(self: *AnsiBackground, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 842 self.clearGrid(); 843 844 // make the wrapped page fill the whole available area 845 try self.child.build(allocator, .{ 846 .min_size = .{ 847 .width = constraint.max_size.width orelse constraint.min_size.width, 848 .height = constraint.max_size.height orelse constraint.min_size.height, 849 }, 850 .max_size = constraint.max_size, 851 }, root_focus); 852 853 if (self.session.data.enable_ansi) { 854 if (self.child.getGrid()) |fg| { 855 self.grid = try artBehind(allocator, fg, &self.art, .top_right, root_focus); 856 } 857 } 858 } 859 860 pub fn input(self: *AnsiBackground, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 861 try self.child.input(allocator, key, root_focus); 862 } 863 864 pub fn clearGrid(self: *AnsiBackground) void { 865 if (self.grid) |*grid| { 866 grid.deinit(); 867 self.grid = null; 868 } 869 self.child.clearGrid(); 870 self.art.clearGrid(); 871 } 872 873 pub fn getGrid(self: AnsiBackground) ?Grid { 874 return self.grid orelse self.child.getGrid(); 875 } 876 877 pub fn getFocus(self: *AnsiBackground) *Focus { 878 return self.child.getFocus(); 879 } 880 881 // which top corner the art hugs. 882 const ArtAnchor = enum { top_left, top_right }; 883 884 // a foreground cell counts as blank if it's empty or a bare space without styling 885 fn cellIsBlank(cell: Grid.Cell) bool { 886 if (cell.rune) |rune| { 887 return std.mem.eql(u8, rune, " ") and 888 cell.style.fg == null and cell.style.bg == null and !cell.style.inverted; 889 } 890 return true; 891 } 892 893 // a single representative color for an art cell 894 fn artCellColor(style: Grid.Style) ?Grid.Color { 895 const f = style.fg orelse return style.bg; 896 const b = style.bg orelse return f; 897 return .{ 898 .r = @intCast((@as(u16, f.r) + b.r) / 2), 899 .g = @intCast((@as(u16, f.g) + b.g) / 2), 900 .b = @intCast((@as(u16, f.b) + b.b) / 2), 901 }; 902 } 903 904 // behind a visible glyph the art color is dimmed to this fraction of its 905 // brightness so light terminal text stays legible on top of it 906 const glyph_bg_brightness = 45; // percent 907 908 // a glyph sitting over the art also gets its foreground pinned to this color 909 // (unless it set its own), so the text stays legible 910 const glyph_fg_over_art = Grid.Color{ .r = 235, .g = 235, .b = 235 }; 911 912 fn dimColor(color: ?Grid.Color) ?Grid.Color { 913 const c = color orelse return null; 914 return .{ 915 .r = @intCast(@as(u16, c.r) * glyph_bg_brightness / 100), 916 .g = @intCast(@as(u16, c.g) * glyph_bg_brightness / 100), 917 .b = @intCast(@as(u16, c.b) * glyph_bg_brightness / 100), 918 }; 919 } 920 921 // terminals that don't support truecolor misparse a "38;2;r;g;b"/"48;2;…" 922 // SGR as a list of plain SGR codes, so any channel value in 1..9 turns on 923 // a text attribute (5/6 = blink, 7 = inverse, 8 = conceal, …). 924 // snap such channels clear of that range so the backdrop doesn't blink or 925 // hide text there; the ≤9/255 shift is imperceptible on terminals that 926 // actually render truecolor. 927 fn sgrSafe(color: ?Grid.Color) ?Grid.Color { 928 const c = color orelse return null; 929 const snap = struct { 930 fn f(v: u8) u8 { 931 return if (v >= 1 and v <= 9) 10 else v; 932 } 933 }.f; 934 return .{ .r = snap(c.r), .g = snap(c.g), .b = snap(c.b) }; 935 } 936 937 // composites ANSI art behind a foreground grid 938 fn artBehind(allocator: std.mem.Allocator, foreground: Grid, art: *AnsiArt, anchor: ArtAnchor, root_focus: *Focus) !Grid { 939 art.clearGrid(); 940 try art.build(allocator, .{ 941 .min_size = .{ .width = null, .height = null }, 942 .max_size = .{ .width = foreground.size.width, .height = foreground.size.height }, 943 }, root_focus); 944 945 var out = try Grid.initFromGrid(allocator, foreground, foreground.size, 0, 0); 946 errdefer out.deinit(); 947 948 if (art.getGrid()) |art_grid| { 949 const anchor_x = switch (anchor) { 950 .top_left => 0, 951 .top_right => foreground.size.width -| art_grid.size.width, 952 }; 953 for (0..art_grid.size.height) |y| { 954 for (0..art_grid.size.width) |x| { 955 const src = art_grid.cells.items[try art_grid.cells.at(.{ y, x })]; 956 if (src.rune == null) continue; 957 const idx = out.cells.at(.{ y, anchor_x + x }) catch continue; 958 const dst = &out.cells.items[idx]; 959 if (cellIsBlank(dst.*)) { 960 dst.* = src; 961 dst.style.fg = sgrSafe(dst.style.fg); 962 dst.style.bg = sgrSafe(dst.style.bg); 963 } else if (dst.style.bg == null) { 964 dst.style.bg = sgrSafe(dimColor(artCellColor(src.style))); 965 if (dst.style.fg == null) dst.style.fg = glyph_fg_over_art; 966 } 967 } 968 } 969 } 970 return out; 971 } 972 }; 973