╭ widget.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const ui = @import("../ui.zig"); 3 const xit = @import("xit"); 4 const xitui = xit.xitui; 5 const wgt = xitui.widget; 6 const layout = xitui.layout; 7 const Key = xitui.input.Key; 8 const Grid = xitui.grid.Grid; 9 const Focus = xitui.focus.Focus; 10 const inp = @import("input.zig"); 11 12 pub const thread = @import("widget/thread.zig"); 13 14 pub const Widget = union(enum) { 15 text: wgt.Text, 16 box: wgt.Box(Widget), 17 text_box: wgt.TextBox, 18 text_input: wgt.TextInput, 19 scroll: wgt.Scroll(Widget), 20 stack: wgt.Stack(Widget), 21 flow_box: FlowBox, 22 flow_box_scroll: FlowBox.Scroll, 23 tag_flow: TagFlow, 24 spacer: Spacer, 25 center: Center, 26 section_label: SectionLabel, 27 submit_button: SubmitButton, 28 background: AnsiBackground, 29 home: ui.Home.View, 30 user: ui.User.View, 31 repo: ui.Repo.View, 32 fork: ui.Fork.View, 33 quit: ui.Quit.View, 34 unauthorized: ui.Unauthorized.View, 35 title: ui.Title.View, 36 sub_title: ui.SubTitle.View, 37 home_header: ui.Home.Header.View, 38 user_header: ui.User.Header.View, 39 repo_header: ui.Repo.Header.View, 40 fork_header: ui.Fork.Header.View, 41 repo_files_header: ui.Repo.Files.Header.View, 42 repo_commits_header: ui.Repo.Commits.Header.View, 43 repo_issues_header: ui.Repo.Issues.Header, 44 repo_patches_header: ui.Repo.Patches.Header, 45 repo_discussions_header: ui.Repo.Discussions.Header, 46 repo_files: ui.Repo.Files.View, 47 repo_commits: ui.Repo.Commits.View, 48 repo_refs: ui.Repo.Refs.View, 49 repo_issues: ui.Repo.Issues.View, 50 repo_patches: ui.Repo.Patches.View, 51 repo_discussions: ui.Repo.Discussions.View, 52 repo_issue_detail: ui.Repo.Issues.Detail, 53 repo_patch_detail: ui.Repo.Patches.Detail, 54 repo_discussion_detail: ui.Repo.Discussions.Detail, 55 repo_events_header: ui.Repo.Events.Header.View, 56 repo_events: ui.Repo.Events.View, 57 repo_comment: ui.Repo.Comment.Item, 58 copyable_text: CopyableText, 59 home_users: ui.Home.Users.View, 60 home_repos: ui.Home.Repos.View, 61 auth_tab: ui.Home.Header.AuthTab.View, 62 home_settings: ui.Home.Settings.View, 63 home_auth: ui.Home.Auth.View, 64 footer: Footer, 65 66 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void { 67 switch (self.*) { 68 inline else => |*case| case.deinit(allocator), 69 } 70 } 71 72 pub fn build(self: *Widget, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 73 switch (self.*) { 74 inline else => |*case| try case.build(allocator, constraint, root_focus), 75 } 76 } 77 78 pub fn input(self: *Widget, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) anyerror!void { 79 switch (self.*) { 80 inline else => |*case| try case.input(allocator, key, root_focus), 81 } 82 } 83 84 pub fn clearGrid(self: *Widget) void { 85 switch (self.*) { 86 inline else => |*case| case.clearGrid(), 87 } 88 } 89 90 pub fn getGrid(self: Widget) ?Grid { 91 switch (self) { 92 inline else => |*case| return case.getGrid(), 93 } 94 } 95 96 pub fn getFocus(self: *Widget) *Focus { 97 switch (self.*) { 98 inline else => |*case| return case.getFocus(), 99 } 100 } 101 }; 102 103 pub const back_button_width = 3; 104 const back_button_height = 3; 105 const back_kind = "back"; 106 107 pub fn addBackButton(allocator: std.mem.Allocator, box: *wgt.Box(Widget), session: *ui.Session) !void { 108 if (!session.is_terminal) return; 109 var back = try wgt.TextBox.init(allocator, "←", .{ .border_style = .hidden, .wrap_kind = .none }); 110 errdefer back.deinit(allocator); 111 back.getFocus().kind = .{ .custom = back_kind }; 112 try box.children.put(allocator, back.getFocus().id, .{ 113 .widget = .{ .text_box = back }, 114 .rect = null, 115 .min_size = .{ .width = back_button_width, .height = back_button_height }, 116 .max_size = .{ .width = back_button_width, .height = back_button_height }, 117 .hidden = session.back != .available, 118 }); 119 } 120 121 pub fn setBackButtonVisible(box: *wgt.Box(Widget), visible: bool) void { 122 for (box.children.values()) |*child| { 123 if (isBackButton(child.widget.getFocus())) { 124 child.hidden = !visible; 125 continue; 126 } 127 switch (child.widget) { 128 .box => |*nested| setBackButtonVisible(nested, visible), 129 else => {}, 130 } 131 } 132 } 133 134 pub fn isBackButton(focus: *Focus) bool { 135 return switch (focus.kind) { 136 .custom => |kind| std.mem.eql(u8, kind, back_kind), 137 else => false, 138 }; 139 } 140 141 // move the focused row in `box` by `delta`, clamped to the ends, scrolling 142 // `scroll` to keep it visible. 143 pub fn moveRowFocus(box: *wgt.Box(Widget), scroll: *wgt.Scroll(Widget), root_focus: *Focus, delta: isize) void { 144 const keys = box.children.keys(); 145 if (keys.len == 0) return; 146 const cur_id = box.getFocus().child_id orelse return; 147 const cur: isize = @intCast(box.children.getIndex(cur_id) orelse return); 148 const last: isize = @intCast(keys.len - 1); 149 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 150 if (next == @as(usize, @intCast(cur))) return; 151 root_focus.setFocus(keys[next]); 152 if (box.children.values()[next].rect) |rect| scroll.scrollToRect(rect); 153 } 154 155 pub const FlowBox = struct { 156 focus: *Focus, 157 grid: ?Grid, 158 text_boxes: std.ArrayList(wgt.TextBox), 159 // backs each link item's `.custom` focus kind (e.g. "a:/user/foo"). reset 160 // wholesale on each setItems so there's no per-string ownership to track. 161 arena: std.heap.ArenaAllocator, 162 // column count from the last build — FlowBox.Scroll.input uses it so arrow 163 // up/down can step by a row's worth of items. 164 last_cols: usize, 165 options: Options, 166 167 const border_rows: usize = 2; 168 169 pub const Options = struct { 170 cell_width: usize = 40, 171 cell_height: usize = 3, 172 }; 173 174 pub fn init(allocator: std.mem.Allocator, options: Options) !FlowBox { 175 return .{ 176 .focus = try Focus.create(allocator, .container), 177 .grid = null, 178 .text_boxes = .empty, 179 .arena = std.heap.ArenaAllocator.init(allocator), 180 .last_cols = 1, 181 .options = options, 182 }; 183 } 184 185 pub fn deinit(self: *FlowBox, allocator: std.mem.Allocator) void { 186 self.focus.destroy(allocator); 187 if (self.grid) |*grid| { 188 grid.deinit(); 189 self.grid = null; 190 } 191 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 192 self.text_boxes.deinit(allocator); 193 self.arena.deinit(); 194 } 195 196 // one flow item: its display text plus an optional link. a non-empty `link` 197 // sets the item's focus kind such as `.{ .custom = "a:/user/foo" }`, which 198 // the web renderer turns into an anchor. 199 pub const Item = struct { text: []const u8, link: []const u8 = "" }; 200 201 // the text box copies the item text; links are copied into the arena, so the 202 // caller's slices needn't outlive this call. 203 pub fn setItems(self: *FlowBox, allocator: std.mem.Allocator, items: []const Item) !void { 204 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 205 self.text_boxes.clearAndFree(allocator); 206 207 // the old text boxes and their borrowed links are gone now, so the link 208 // arena can be reclaimed. 209 _ = self.arena.reset(.retain_capacity); 210 const aa = self.arena.allocator(); 211 212 self.focus.clear(); 213 self.focus.child_id = null; 214 215 for (items) |item| { 216 var text_box = try wgt.TextBox.init(allocator, item.text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 217 errdefer text_box.deinit(allocator); 218 text_box.getFocus().focusable = true; 219 220 if (item.link.len > 0) { 221 text_box.getFocus().kind = .{ .custom = try aa.dupe(u8, item.link) }; 222 } 223 224 try self.text_boxes.append(allocator, text_box); 225 } 226 227 if (self.text_boxes.items.len > 0) { 228 self.focus.child_id = self.text_boxes.items[0].getFocus().id; 229 } 230 } 231 232 pub fn build(self: *FlowBox, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 233 self.clearGrid(); 234 self.focus.clear(); 235 236 const cell_width = self.options.cell_width; 237 const max_width = constraint.max_size.width orelse cell_width; 238 const cols = if (cell_width == 0) 1 else @max(1, max_width / cell_width); 239 self.last_cols = cols; 240 241 const count = self.text_boxes.items.len; 242 if (count == 0) return; 243 const slot_height = self.options.cell_height + border_rows; 244 if (slot_height == 0) return; 245 246 // build at the slot size so every text box fits a single grid cell 247 for (self.text_boxes.items) |*tb| { 248 tb.options.border_style = if (root_focus.grandchild_id == tb.getFocus().id) .double else .hidden; 249 try tb.build(allocator, .{ 250 .min_size = .{ .width = cell_width, .height = null }, 251 .max_size = .{ .width = cell_width, .height = slot_height }, 252 }, root_focus); 253 } 254 255 const rows = (count + cols - 1) / cols; 256 const content_height = rows * slot_height; 257 const total_width = cols * cell_width; 258 if (total_width == 0 or content_height == 0) return; 259 260 var grid = try Grid.init(allocator, .{ .width = total_width, .height = content_height }); 261 errdefer grid.deinit(); 262 263 for (self.text_boxes.items, 0..) |*tb, i| { 264 const tb_grid = tb.getGrid() orelse continue; 265 const col = i % cols; 266 const row = i / cols; 267 const cell_x = col * cell_width; 268 const cell_y = row * slot_height; 269 try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, cell_x, cell_y); 270 try grid.drawGrid(tb_grid, cell_x, cell_y); 271 } 272 273 self.grid = grid; 274 } 275 276 pub fn input(self: *FlowBox, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 277 _ = self; 278 _ = allocator; 279 _ = key; 280 _ = root_focus; 281 } 282 283 pub fn clearGrid(self: *FlowBox) void { 284 if (self.grid) |*grid| { 285 grid.deinit(); 286 self.grid = null; 287 } 288 for (self.text_boxes.items) |*tb| tb.clearGrid(); 289 } 290 291 pub fn getGrid(self: FlowBox) ?Grid { 292 return self.grid; 293 } 294 295 pub fn getFocus(self: *FlowBox) *Focus { 296 return self.focus; 297 } 298 299 pub fn cellRect(self: FlowBox, index: usize) ?layout.IRect { 300 if (self.last_cols == 0 or index >= self.text_boxes.items.len) return null; 301 const slot_height = self.options.cell_height + border_rows; 302 const col = index % self.last_cols; 303 const row = index / self.last_cols; 304 return .{ 305 .x = @intCast(col * self.options.cell_width), 306 .y = @intCast(row * slot_height), 307 .size = .{ .width = self.options.cell_width, .height = slot_height }, 308 }; 309 } 310 311 pub fn indexOfFocusId(self: FlowBox, focus_id: usize) ?usize { 312 for (self.text_boxes.items, 0..) |tb, i| { 313 if (tb.focus.id == focus_id) return i; 314 } 315 return null; 316 } 317 318 pub const Scroll = struct { 319 scroll: wgt.Scroll(Widget), 320 321 pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options, web_native: bool) !Scroll { 322 var layout_inner = try FlowBox.init(allocator, options); 323 errdefer layout_inner.deinit(allocator); 324 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .{ .direction = .vert, .web_native = web_native }); 325 errdefer scroll.deinit(allocator); 326 return .{ .scroll = scroll }; 327 } 328 329 pub fn deinit(self: *Scroll, allocator: std.mem.Allocator) void { 330 self.scroll.deinit(allocator); 331 } 332 333 pub fn setItems(self: *Scroll, allocator: std.mem.Allocator, items: []const Item) !void { 334 self.scroll.x = 0; 335 self.scroll.y = 0; 336 try self.inner().setItems(allocator, items); 337 } 338 339 pub fn build(self: *Scroll, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 340 try self.scroll.build(allocator, constraint, root_focus); 341 } 342 343 pub fn input(self: *Scroll, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 344 _ = allocator; 345 const in = self.inner(); 346 const child_id = in.focus.child_id orelse return; 347 const current_index = in.indexOfFocusId(child_id) orelse return; 348 const count = in.text_boxes.items.len; 349 if (count == 0) return; 350 const cols = in.last_cols; 351 const slot_height = in.options.cell_height + FlowBox.border_rows; 352 353 var index = current_index; 354 switch (key) { 355 .arrow_up => index -|= cols, 356 .arrow_down => if (index + cols < count) { 357 index += cols; 358 }, 359 .arrow_left => index -|= 1, 360 .arrow_right => if (index + 1 < count) { 361 index += 1; 362 }, 363 .home => index = 0, 364 .end => index = count - 1, 365 .page_up => { 366 if (self.scroll.grid) |grid| if (slot_height > 0) { 367 const rows_per_page = grid.size.height / slot_height; 368 index -|= rows_per_page * cols; 369 }; 370 }, 371 .page_down => { 372 if (self.scroll.grid) |grid| if (slot_height > 0) { 373 const rows_per_page = grid.size.height / slot_height; 374 index = @min(index + rows_per_page * cols, count - 1); 375 }; 376 }, 377 // scroll wheel moves the focused cell by a full row so the 378 // viewport (via scrollToRect) follows in row-sized steps, 379 // matching how a scroll wheel feels in a grid view. 380 .mouse => |mouse| switch (mouse.action) { 381 .scroll => |dir| switch (dir) { 382 .up => index -|= cols, 383 .down => if (index + cols < count) { 384 index += cols; 385 }, 386 }, 387 else => {}, 388 }, 389 else => {}, 390 } 391 392 if (index != current_index) { 393 root_focus.setFocus(in.text_boxes.items[index].getFocus().id); 394 if (in.cellRect(index)) |rect| { 395 self.scroll.scrollToRect(rect); 396 } 397 } 398 } 399 400 pub fn clearGrid(self: *Scroll) void { 401 self.scroll.clearGrid(); 402 } 403 404 pub fn getGrid(self: Scroll) ?Grid { 405 return self.scroll.getGrid(); 406 } 407 408 pub fn getFocus(self: *Scroll) *Focus { 409 return self.scroll.getFocus(); 410 } 411 412 pub fn getSelectedIndex(self: Scroll) ?usize { 413 const in = self.scroll.child.flow_box; 414 const child_id = in.focus.child_id orelse return null; 415 return in.indexOfFocusId(child_id); 416 } 417 418 fn inner(self: *Scroll) *FlowBox { 419 return &self.scroll.child.flow_box; 420 } 421 }; 422 }; 423 424 // a left-to-right flow of variable-width focusable items that wraps at the 425 // available width, like words in a paragraph. unlike FlowBox there is no cell 426 // grid: each item is as wide as its text. selection movement is driven by the 427 // owning view via indexOfFocusId/verticalNeighbor. 428 pub const TagFlow = struct { 429 focus: *Focus, 430 grid: ?Grid, 431 text_boxes: std.ArrayList(wgt.TextBox), 432 // last build's item rects (content space), for vertical navigation and 433 // scroll-into-view. 434 rects: std.ArrayList(layout.IRect), 435 // backs each link item's `.custom` focus kind. 436 arena: std.heap.ArenaAllocator, 437 438 pub const Item = struct { text: []const u8, link: []const u8 = "" }; 439 440 pub fn init(allocator: std.mem.Allocator) !TagFlow { 441 return .{ 442 .focus = try Focus.create(allocator, .container), 443 .grid = null, 444 .text_boxes = .empty, 445 .rects = .empty, 446 .arena = std.heap.ArenaAllocator.init(allocator), 447 }; 448 } 449 450 pub fn deinit(self: *TagFlow, allocator: std.mem.Allocator) void { 451 self.focus.destroy(allocator); 452 if (self.grid) |*grid| { 453 grid.deinit(); 454 self.grid = null; 455 } 456 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 457 self.text_boxes.deinit(allocator); 458 self.rects.deinit(allocator); 459 self.arena.deinit(); 460 } 461 462 // the text box copies the item text; links are copied into the arena, so the 463 // caller's slices needn't outlive this call. 464 pub fn setItems(self: *TagFlow, allocator: std.mem.Allocator, items: []const Item) !void { 465 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 466 self.text_boxes.clearAndFree(allocator); 467 468 _ = self.arena.reset(.retain_capacity); 469 const aa = self.arena.allocator(); 470 471 self.focus.clear(); 472 self.focus.child_id = null; 473 474 for (items) |item| { 475 var text_box = try wgt.TextBox.init(allocator, item.text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 476 errdefer text_box.deinit(allocator); 477 text_box.getFocus().focusable = true; 478 479 if (item.link.len > 0) { 480 text_box.getFocus().kind = .{ .custom = try aa.dupe(u8, item.link) }; 481 } 482 483 try self.text_boxes.append(allocator, text_box); 484 } 485 486 if (self.text_boxes.items.len > 0) { 487 self.focus.child_id = self.text_boxes.items[0].getFocus().id; 488 } 489 } 490 491 pub fn build(self: *TagFlow, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 492 self.clearGrid(); 493 self.focus.clear(); 494 495 if (self.text_boxes.items.len == 0) return; 496 const max_width = constraint.max_size.width orelse 80; 497 if (max_width == 0) return; 498 499 // lay the items out left to right, wrapping to a new row when the next 500 // one wouldn't fit. 501 self.rects.clearRetainingCapacity(); 502 var x: usize = 0; 503 var y: usize = 0; 504 var row_height: usize = 0; 505 var total_width: usize = 0; 506 for (self.text_boxes.items) |*tb| { 507 try tb.build(allocator, .{ 508 .min_size = .{ .width = null, .height = null }, 509 .max_size = .{ .width = max_width, .height = null }, 510 }, root_focus); 511 const tb_grid_maybe = tb.getGrid(); 512 const w: usize = if (tb_grid_maybe) |g| g.size.width else 0; 513 const h: usize = if (tb_grid_maybe) |g| g.size.height else 0; 514 if (x > 0 and x + w > max_width) { 515 x = 0; 516 y += row_height; 517 row_height = 0; 518 } 519 try self.rects.append(allocator, .{ .x = @intCast(x), .y = @intCast(y), .size = .{ .width = w, .height = h } }); 520 if (h > row_height) row_height = h; 521 x += w; 522 if (x > total_width) total_width = x; 523 } 524 525 const total_height = y + row_height; 526 if (total_width == 0 or total_height == 0) return; 527 528 var grid = try Grid.init(allocator, .{ .width = total_width, .height = total_height }); 529 errdefer grid.deinit(); 530 531 for (self.text_boxes.items, self.rects.items) |*tb, rect| { 532 const tb_grid = tb.getGrid() orelse continue; 533 try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, @intCast(rect.x), @intCast(rect.y)); 534 try grid.drawGrid(tb_grid, @intCast(rect.x), @intCast(rect.y)); 535 } 536 537 self.grid = grid; 538 } 539 540 pub fn input(self: *TagFlow, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 541 _ = self; 542 _ = allocator; 543 _ = key; 544 _ = root_focus; 545 } 546 547 pub fn clearGrid(self: *TagFlow) void { 548 if (self.grid) |*grid| { 549 grid.deinit(); 550 self.grid = null; 551 } 552 for (self.text_boxes.items) |*tb| tb.clearGrid(); 553 } 554 555 pub fn getGrid(self: TagFlow) ?Grid { 556 return self.grid; 557 } 558 559 pub fn getFocus(self: *TagFlow) *Focus { 560 return self.focus; 561 } 562 563 pub fn indexOfFocusId(self: TagFlow, focus_id: usize) ?usize { 564 for (self.text_boxes.items, 0..) |tb, i| { 565 if (tb.focus.id == focus_id) return i; 566 } 567 return null; 568 } 569 570 // the first item of the row below (when `downward`) or above, or null at 571 // the flow's edge. items are laid out in order, so y is nondecreasing. 572 pub fn rowStep(self: TagFlow, index: usize, downward: bool) ?usize { 573 const rects = self.rects.items; 574 if (index >= rects.len) return null; 575 const cur_y = rects[index].y; 576 if (downward) { 577 for (rects[index + 1 ..], index + 1..) |r, i| { 578 if (r.y > cur_y) return i; 579 } 580 return null; 581 } 582 // walk the earlier rows; the last one visited is the row above. 583 var prev_first: ?usize = null; 584 var i: usize = 0; 585 while (i < rects.len and rects[i].y < cur_y) { 586 prev_first = i; 587 const y = rects[i].y; 588 while (i < rects.len and rects[i].y == y) i += 1; 589 } 590 return prev_first; 591 } 592 }; 593 594 // an invisible widget that fills the horizontal space granted by its parent. 595 // used inside Box(horiz) with a min_size so the box reserves space for the 596 // children that follow, pushing them to the right. 597 pub const Spacer = struct { 598 focus: *Focus, 599 grid: ?Grid, 600 601 pub fn init(allocator: std.mem.Allocator) !Spacer { 602 return .{ 603 .focus = try Focus.create(allocator, .container), 604 .grid = null, 605 }; 606 } 607 608 pub fn deinit(self: *Spacer, allocator: std.mem.Allocator) void { 609 self.focus.destroy(allocator); 610 if (self.grid) |*grid| { 611 grid.deinit(); 612 self.grid = null; 613 } 614 } 615 616 pub fn build(self: *Spacer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 617 _ = root_focus; 618 self.clearGrid(); 619 const width = constraint.max_size.width orelse return; 620 if (width == 0) return; 621 self.grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 622 } 623 624 pub fn input(self: *Spacer, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 625 _ = self; 626 _ = allocator; 627 _ = key; 628 _ = root_focus; 629 } 630 631 pub fn clearGrid(self: *Spacer) void { 632 if (self.grid) |*grid| { 633 grid.deinit(); 634 self.grid = null; 635 } 636 } 637 638 pub fn getGrid(self: Spacer) ?Grid { 639 return self.grid; 640 } 641 642 pub fn getFocus(self: *Spacer) *Focus { 643 return self.focus; 644 } 645 }; 646 647 // a one-row, non-focusable status bar showing the current page's url 648 pub const Footer = struct { 649 focus: *Focus, 650 grid: ?Grid, 651 session: *ui.Session, 652 arena: std.heap.ArenaAllocator, 653 url: []const u8 = "", 654 url_width: usize = 0, 655 656 pub fn init(allocator: std.mem.Allocator, session: *ui.Session) !Footer { 657 return .{ 658 .focus = try Focus.create(allocator, .container), 659 .grid = null, 660 .session = session, 661 .arena = std.heap.ArenaAllocator.init(allocator), 662 }; 663 } 664 665 pub fn deinit(self: *Footer, allocator: std.mem.Allocator) void { 666 self.focus.destroy(allocator); 667 if (self.grid) |*grid| grid.deinit(); 668 self.arena.deinit(); 669 } 670 671 pub fn build(self: *Footer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 672 _ = root_focus; 673 self.clearGrid(); 674 const width = constraint.max_size.width orelse return; 675 if (width == 0) return; 676 677 _ = self.arena.reset(.retain_capacity); 678 self.url = ""; 679 self.url_width = 0; 680 const aa = self.arena.allocator(); 681 const path = self.session.data.current_page.toUrl(&self.arena) catch return; 682 const text = ui.terminalWebUrl(aa, self.session, path) catch return; 683 self.url = text; 684 685 var grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 686 errdefer grid.deinit(); 687 var utf8 = (std.unicode.Utf8View.init(text) catch return).iterator(); 688 var i: usize = 0; 689 while (utf8.nextCodepoint()) |ch| { 690 const rune_width = xitui.width.cellWidth(ch); 691 if (i + rune_width > width) break; 692 try grid.setRune(i, 0, ch); 693 i += rune_width; 694 } 695 self.url_width = i; 696 self.grid = grid; 697 } 698 699 pub fn input(self: *Footer, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 700 _ = allocator; 701 switch (key) { 702 .mouse => |mouse| { 703 if (mouse.ctrl or !inp.leftClickOn(root_focus, self.getFocus().id, mouse)) return; 704 const rect = (root_focus.children.get(self.getFocus().id) orelse return).rect; 705 if (mouse.x < rect.x + self.url_width) self.session.host_request = .{ .show_copyable_text = self.url }; 706 }, 707 else => {}, 708 } 709 } 710 711 pub fn clearGrid(self: *Footer) void { 712 if (self.grid) |*grid| { 713 grid.deinit(); 714 self.grid = null; 715 } 716 } 717 718 pub fn getGrid(self: Footer) ?Grid { 719 return self.grid; 720 } 721 722 pub fn getFocus(self: *Footer) *Focus { 723 return self.focus; 724 } 725 }; 726 727 // a single-child wrapper that builds the child at its natural size and 728 // positions its grid in the middle of the area granted by the parent 729 // a bordered label, indented past the content it precedes 730 pub const SectionLabel = struct { 731 box: wgt.Box(Widget), 732 733 pub fn init(allocator: std.mem.Allocator, content: []const u8) !SectionLabel { 734 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 735 errdefer box.deinit(allocator); 736 737 { 738 var gap = try wgt.Text.init(allocator, " "); 739 errdefer gap.deinit(allocator); 740 try box.children.put(allocator, gap.getFocus().id, .{ .widget = .{ .text = gap }, .rect = null, .min_size = null }); 741 } 742 743 { 744 var tb = try wgt.TextBox.init(allocator, content, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 745 errdefer tb.deinit(allocator); 746 tb.getFocus().focusable = true; 747 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 748 } 749 750 box.getFocus().child_id = box.children.keys()[1]; 751 return .{ .box = box }; 752 } 753 754 pub fn deinit(self: *SectionLabel, allocator: std.mem.Allocator) void { 755 self.box.deinit(allocator); 756 } 757 758 pub fn build(self: *SectionLabel, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 759 try self.box.build(allocator, constraint, root_focus); 760 } 761 762 pub fn input(self: *SectionLabel, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 763 _ = self; 764 _ = allocator; 765 _ = key; 766 _ = root_focus; 767 } 768 769 pub fn clearGrid(self: *SectionLabel) void { 770 self.box.clearGrid(); 771 } 772 773 pub fn getGrid(self: SectionLabel) ?Grid { 774 return self.box.getGrid(); 775 } 776 777 pub fn getFocus(self: *SectionLabel) *Focus { 778 return self.box.getFocus(); 779 } 780 }; 781 782 // a submit button, indented past its form's content. the web form POSTs 783 // the button's form; terminal hosts detect presses through `buttonId`. 784 pub const SubmitButton = struct { 785 box: wgt.Box(Widget), 786 787 pub fn init(allocator: std.mem.Allocator) !SubmitButton { 788 return initLabeled(allocator, "submit"); 789 } 790 791 pub fn initLabeled(allocator: std.mem.Allocator, label: []const u8) !SubmitButton { 792 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 793 errdefer box.deinit(allocator); 794 795 { 796 var gap = try wgt.Text.init(allocator, " "); 797 errdefer gap.deinit(allocator); 798 try box.children.put(allocator, gap.getFocus().id, .{ .widget = .{ .text = gap }, .rect = null, .min_size = null }); 799 } 800 801 { 802 var button = try wgt.TextBox.init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 803 errdefer button.deinit(allocator); 804 button.getFocus().focusable = true; 805 // the renderer distinguishes plain clickables from buttons that 806 // should POST to a server route by this kind. 807 button.getFocus().kind = .{ .custom = "submit" }; 808 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = label.len + 2, .height = null } }); 809 } 810 811 box.getFocus().child_id = box.children.keys()[1]; 812 return .{ .box = box }; 813 } 814 815 pub fn deinit(self: *SubmitButton, allocator: std.mem.Allocator) void { 816 self.box.deinit(allocator); 817 } 818 819 pub fn build(self: *SubmitButton, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 820 try self.box.build(allocator, constraint, root_focus); 821 } 822 823 pub fn input(self: *SubmitButton, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 824 _ = self; 825 _ = allocator; 826 _ = key; 827 _ = root_focus; 828 } 829 830 pub fn clearGrid(self: *SubmitButton) void { 831 self.box.clearGrid(); 832 } 833 834 pub fn getGrid(self: SubmitButton) ?Grid { 835 return self.box.getGrid(); 836 } 837 838 pub fn getFocus(self: *SubmitButton) *Focus { 839 return self.box.getFocus(); 840 } 841 842 // the button's focus id, for click hit-testing 843 pub fn buttonId(self: *const SubmitButton) usize { 844 return self.box.children.keys()[1]; 845 } 846 }; 847 848 pub const Center = struct { 849 focus: *Focus, 850 grid: ?Grid, 851 child: *Widget, 852 853 pub fn init(allocator: std.mem.Allocator, child_widget: Widget) !Center { 854 const child = try allocator.create(Widget); 855 errdefer allocator.destroy(child); 856 child.* = child_widget; 857 return .{ 858 .focus = try Focus.create(allocator, .container), 859 .grid = null, 860 .child = child, 861 }; 862 } 863 864 pub fn deinit(self: *Center, allocator: std.mem.Allocator) void { 865 self.focus.destroy(allocator); 866 if (self.grid) |*grid| { 867 grid.deinit(); 868 self.grid = null; 869 } 870 self.child.deinit(allocator); 871 allocator.destroy(self.child); 872 } 873 874 pub fn build(self: *Center, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 875 self.clearGrid(); 876 self.getFocus().clear(); 877 878 // build the child without forcing it to fill min_size; it sizes 879 // itself to its content within the available max. 880 try self.child.build(allocator, .{ 881 .min_size = .{ .width = null, .height = null }, 882 .max_size = constraint.max_size, 883 }, root_focus); 884 885 const child_grid = self.child.getGrid() orelse return; 886 887 // prefer max when given; otherwise grow to max(min, child) so that 888 // when only a min is set (e.g. the wasm path passes viewport rows 889 // as min_height with no max), we can still vertically center while 890 // letting taller content extend past the min. 891 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; 892 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; 893 if (width == 0 or height == 0) return; 894 895 const offset_x: usize = (width -| child_grid.size.width) / 2; 896 const offset_y: usize = (height -| child_grid.size.height) / 2; 897 898 var grid = try Grid.init(allocator, .{ .width = width, .height = height }); 899 errdefer grid.deinit(); 900 try grid.drawGrid(child_grid, offset_x, offset_y); 901 try self.getFocus().addChild(allocator, self.child.getFocus(), child_grid.size, offset_x, offset_y); 902 self.getFocus().child_id = self.child.getFocus().id; 903 904 self.grid = grid; 905 } 906 907 pub fn input(self: *Center, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 908 try self.child.input(allocator, key, root_focus); 909 } 910 911 pub fn clearGrid(self: *Center) void { 912 if (self.grid) |*grid| { 913 grid.deinit(); 914 self.grid = null; 915 } 916 self.child.clearGrid(); 917 } 918 919 pub fn getGrid(self: Center) ?Grid { 920 return self.grid; 921 } 922 923 pub fn getFocus(self: *Center) *Focus { 924 return self.focus; 925 } 926 }; 927 928 // renders truecolor ANSI art 929 pub const AnsiArt = struct { 930 focus: *Focus, 931 grid: ?Grid, 932 content: []const u8, 933 934 pub fn init(allocator: std.mem.Allocator, content: []const u8) !AnsiArt { 935 return .{ 936 .focus = try Focus.create(allocator, .container), 937 .grid = null, 938 .content = content, 939 }; 940 } 941 942 pub fn deinit(self: *AnsiArt, allocator: std.mem.Allocator) void { 943 self.focus.destroy(allocator); 944 if (self.grid) |*grid| { 945 grid.deinit(); 946 self.grid = null; 947 } 948 } 949 950 pub fn build(self: *AnsiArt, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 951 _ = root_focus; 952 self.clearGrid(); 953 954 // parse into rows of cells, then copy into a rectangular grid 955 var rows: std.ArrayList(std.ArrayList(Grid.Cell)) = .empty; 956 defer { 957 for (rows.items) |*row| row.deinit(allocator); 958 rows.deinit(allocator); 959 } 960 var row: std.ArrayList(Grid.Cell) = .empty; 961 errdefer row.deinit(allocator); 962 963 var style: Grid.Style = .{}; 964 var width: usize = 0; 965 const content = self.content; 966 var i: usize = 0; 967 while (i < content.len) { 968 const byte = content[i]; 969 if (byte == '\n') { 970 width = @max(width, row.items.len); 971 try rows.append(allocator, row); 972 row = .empty; 973 i += 1; 974 } else if (byte == 0x1B and i + 1 < content.len and content[i + 1] == '[') { 975 // CSI: scan to the final byte (0x40..0x7E); apply it if it's 'm' 976 var j = i + 2; 977 while (j < content.len and !(content[j] >= 0x40 and content[j] <= 0x7E)) j += 1; 978 if (j >= content.len) { 979 i = content.len; // malformed trailing escape; stop 980 } else { 981 if (content[j] == 'm') applySgr(&style, content[i + 2 .. j]); 982 i = j + 1; 983 } 984 } else { 985 const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; 986 const end = @min(content.len, i + len); 987 const rune = content[i..end]; 988 const codepoint = std.unicode.utf8Decode(rune) catch '�'; 989 // a foreground color does not make a space visible. only an 990 // effective background makes it opaque; foreground state often 991 // remains active through the padding at the end of a row. 992 const effective_background = if (style.inverted) style.fg else style.bg; 993 const transparent = end - i == 1 and rune[0] == ' ' and effective_background == null; 994 try row.append(allocator, .{ 995 .rune = if (transparent) null else codepoint, 996 .style = style, 997 }); 998 if (!transparent and xitui.width.cellWidth(codepoint) == 2) { 999 try row.append(allocator, .{ .rune = null, .style = style, .continuation = true }); 1000 } 1001 i = end; 1002 } 1003 } 1004 // a trailing row with content but no closing newline 1005 if (row.items.len > 0) { 1006 width = @max(width, row.items.len); 1007 try rows.append(allocator, row); 1008 } else { 1009 row.deinit(allocator); 1010 } 1011 row = .empty; // ownership moved into rows (or freed); keep errdefer safe 1012 1013 const height = rows.items.len; 1014 if (width == 0 or height == 0) return; 1015 1016 const clamped_w = @min(width, constraint.max_size.width orelse width); 1017 const clamped_h = @min(height, constraint.max_size.height orelse height); 1018 1019 var grid = try Grid.init(allocator, .{ .width = clamped_w, .height = clamped_h }); 1020 errdefer grid.deinit(); 1021 for (rows.items[0..clamped_h], 0..) |r, y| { 1022 const n = @min(r.items.len, clamped_w); 1023 for (r.items[0..n], 0..) |cell, x| { 1024 if (cell.continuation) continue; 1025 (try grid.cell(x, y)).style = cell.style; 1026 try grid.setRune(x, y, cell.rune); 1027 } 1028 } 1029 self.grid = grid; 1030 } 1031 1032 pub fn clearGrid(self: *AnsiArt) void { 1033 if (self.grid) |*grid| { 1034 grid.deinit(); 1035 self.grid = null; 1036 } 1037 } 1038 1039 pub fn getGrid(self: AnsiArt) ?Grid { 1040 return self.grid; 1041 } 1042 1043 pub fn getFocus(self: *AnsiArt) *Focus { 1044 return self.focus; 1045 } 1046 1047 fn applySgr(style: *Grid.Style, params: []const u8) void { 1048 var nums: [16]u32 = undefined; 1049 var n: usize = 0; 1050 var it = std.mem.splitScalar(u8, params, ';'); 1051 while (it.next()) |tok| { 1052 if (n >= nums.len) break; 1053 // an empty parameter (e.g. bare "\x1b[m") means 0 1054 nums[n] = std.fmt.parseInt(u32, tok, 10) catch 0; 1055 n += 1; 1056 } 1057 if (n == 0) { 1058 style.* = .{}; 1059 return; 1060 } 1061 var i: usize = 0; 1062 while (i < n) : (i += 1) { 1063 switch (nums[i]) { 1064 0 => style.* = .{}, 1065 7 => style.inverted = true, 1066 27 => style.inverted = false, 1067 39 => style.fg = null, 1068 49 => style.bg = null, 1069 38, 48 => { 1070 // truecolor form: 38;2;r;g;b — anything else is ignored 1071 if (i + 4 < n and nums[i + 1] == 2) { 1072 const c = Grid.Color{ 1073 .r = @truncate(nums[i + 2]), 1074 .g = @truncate(nums[i + 3]), 1075 .b = @truncate(nums[i + 4]), 1076 }; 1077 if (nums[i] == 38) style.fg = c else style.bg = c; 1078 i += 4; 1079 } 1080 }, 1081 else => {}, 1082 } 1083 } 1084 } 1085 }; 1086 1087 // a full-screen wrapper that renders ANSI art behind whatever page it wraps 1088 pub const AnsiBackground = struct { 1089 grid: ?Grid, 1090 child: *Widget, 1091 art: AnsiArt, 1092 session: *ui.Session, 1093 1094 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, session: *ui.Session) !AnsiBackground { 1095 var cw = child_widget; 1096 const child = allocator.create(Widget) catch |e| { 1097 cw.deinit(allocator); 1098 return e; 1099 }; 1100 child.* = cw; 1101 errdefer { 1102 child.deinit(allocator); 1103 allocator.destroy(child); 1104 } 1105 return .{ .grid = null, .child = child, .art = try AnsiArt.init(allocator, session.data.ansi_art), .session = session }; 1106 } 1107 1108 pub fn deinit(self: *AnsiBackground, allocator: std.mem.Allocator) void { 1109 if (self.grid) |*grid| grid.deinit(); 1110 self.art.deinit(allocator); 1111 self.child.deinit(allocator); 1112 allocator.destroy(self.child); 1113 } 1114 1115 pub fn build(self: *AnsiBackground, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1116 self.clearGrid(); 1117 1118 // make the wrapped page fill the whole available area 1119 try self.child.build(allocator, .{ 1120 .min_size = .{ 1121 .width = constraint.max_size.width orelse constraint.min_size.width, 1122 .height = constraint.max_size.height orelse constraint.min_size.height, 1123 }, 1124 .max_size = constraint.max_size, 1125 }, root_focus); 1126 1127 if (!self.session.data.enable_ansi) return; 1128 const foreground = self.child.getGrid() orelse return; 1129 self.art.content = self.session.data.ansi_art; 1130 try self.buildArt(allocator, foreground.size, root_focus); 1131 if (!self.session.is_terminal) return; 1132 const art_grid = self.art.getGrid() orelse return; 1133 self.grid = try artBehind(allocator, foreground, art_grid); 1134 } 1135 1136 pub fn input(self: *AnsiBackground, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1137 try self.child.input(allocator, key, root_focus); 1138 } 1139 1140 pub fn clearGrid(self: *AnsiBackground) void { 1141 if (self.grid) |*grid| { 1142 grid.deinit(); 1143 self.grid = null; 1144 } 1145 self.child.clearGrid(); 1146 self.art.clearGrid(); 1147 } 1148 1149 pub fn getGrid(self: AnsiBackground) ?Grid { 1150 return self.grid orelse self.child.getGrid(); 1151 } 1152 1153 pub fn getFocus(self: *AnsiBackground) *Focus { 1154 return self.child.getFocus(); 1155 } 1156 1157 // a foreground cell counts as blank if it's empty or a bare space without styling 1158 fn cellIsBlank(cell: Grid.Cell) bool { 1159 if (cell.continuation) return false; 1160 if (cell.rune) |rune| { 1161 return rune == ' ' and 1162 cell.style.fg == null and cell.style.bg == null and !cell.style.inverted; 1163 } 1164 return true; 1165 } 1166 1167 // the art is dimmed to this fraction of its brightness so foreground text 1168 // stays legible over it 1169 const art_brightness = 35; // percent 1170 1171 fn dimColor(color: ?Grid.Color) ?Grid.Color { 1172 const c = color orelse return null; 1173 return .{ 1174 .r = @intCast(@as(u16, c.r) * art_brightness / 100), 1175 .g = @intCast(@as(u16, c.g) * art_brightness / 100), 1176 .b = @intCast(@as(u16, c.b) * art_brightness / 100), 1177 }; 1178 } 1179 1180 fn buildArt(self: *AnsiBackground, allocator: std.mem.Allocator, size: layout.Size, root_focus: *Focus) !void { 1181 try self.art.build(allocator, .{ 1182 .min_size = .{ .width = null, .height = null }, 1183 .max_size = .{ .width = size.width, .height = size.height }, 1184 }, root_focus); 1185 if (self.art.grid) |*grid| { 1186 for (grid.cells) |*cell| { 1187 cell.style.fg = dimColor(cell.style.fg); 1188 cell.style.bg = dimColor(cell.style.bg); 1189 } 1190 } 1191 } 1192 1193 // terminals that don't support truecolor misparse a "38;2;r;g;b"/"48;2;…" 1194 // SGR as a list of plain SGR codes, so any channel value in 1..9 turns on 1195 // a text attribute (5/6 = blink, 7 = inverse, 8 = conceal, …). 1196 // snap such channels clear of that range so the backdrop doesn't blink or 1197 // hide text there; the ≤9/255 shift is imperceptible on terminals that 1198 // actually render truecolor. 1199 fn sgrSafe(color: ?Grid.Color) ?Grid.Color { 1200 const c = color orelse return null; 1201 const snap = struct { 1202 fn f(v: u8) u8 { 1203 return if (v >= 1 and v <= 9) 10 else v; 1204 } 1205 }.f; 1206 return .{ .r = snap(c.r), .g = snap(c.g), .b = snap(c.b) }; 1207 } 1208 1209 fn applyArtBackground(dst: *Grid.Cell, src: Grid.Cell) void { 1210 const background_maybe = sgrSafe(src.style.bg orelse src.style.fg); 1211 dst.style.bg = background_maybe; 1212 // change unstyled text to contrast with the art behind it 1213 if (dst.style.fg == null) { 1214 if (background_maybe) |background| { 1215 // use near-black and near-white, which is easier on the eyes 1216 // while retaining high contrast 1217 const luminance = (@as(u32, background.r) * 299 + 1218 @as(u32, background.g) * 587 + 1219 @as(u32, background.b) * 114) / 1000; 1220 dst.style.fg = if (luminance >= 128) 1221 .{ .r = 16, .g = 16, .b = 16 } 1222 else 1223 .{ .r = 240, .g = 240, .b = 240 }; 1224 } 1225 } 1226 } 1227 1228 // composites ANSI art behind a foreground grid 1229 fn artBehind(allocator: std.mem.Allocator, foreground: Grid, art_grid: Grid) !Grid { 1230 var out = try Grid.initFromGrid(allocator, foreground, foreground.size, 0, 0); 1231 errdefer out.deinit(); 1232 1233 const anchor_x = foreground.size.width -| art_grid.size.width; 1234 for (0..art_grid.size.height) |y| { 1235 for (0..art_grid.size.width) |x| { 1236 const src = (try art_grid.cell(x, y)).*; 1237 if (src.rune == null) continue; 1238 const dst = try out.cell(anchor_x + x, y); 1239 const wide = x + 1 < art_grid.size.width and (try art_grid.cell(x + 1, y)).continuation; 1240 const next_dst = if (wide) try out.cell(anchor_x + x + 1, y) else null; 1241 // blank cells take the art; occupied cells take its background 1242 if (cellIsBlank(dst.*) and (next_dst == null or cellIsBlank(next_dst.?.*))) { 1243 dst.style = src.style; 1244 dst.style.fg = sgrSafe(dst.style.fg); 1245 dst.style.bg = sgrSafe(dst.style.bg); 1246 try out.setRune(anchor_x + x, y, src.rune); 1247 } else { 1248 applyArtBackground(dst, src); 1249 if (next_dst) |next| applyArtBackground(next, src); 1250 } 1251 } 1252 } 1253 return out; 1254 } 1255 }; 1256 1257 pub const CopyableText = struct { 1258 pub const Choice = struct { 1259 selector: []const u8 = "", 1260 text: []const u8, 1261 copyable_text: ?[]const u8 = null, 1262 label: []const u8 = "", 1263 bottom_label: []const u8 = "", 1264 }; 1265 1266 box: wgt.Box(Widget), 1267 session: *ui.Session, 1268 choices: []Choice, 1269 selected: usize = 0, 1270 1271 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, choices: []const Choice) !CopyableText { 1272 if (choices.len == 0) return error.MissingCopyableText; 1273 1274 const owned_choices = try allocator.dupe(Choice, choices); 1275 errdefer allocator.free(owned_choices); 1276 1277 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 1278 errdefer box.deinit(allocator); 1279 1280 if (choices.len > 1) for (choices) |choice| { 1281 var selector_box = try wgt.TextBox.init(allocator, choice.selector, .{ .border_style = .hidden, .wrap_kind = .none }); 1282 errdefer selector_box.deinit(allocator); 1283 selector_box.getFocus().focusable = true; 1284 try box.children.put(allocator, selector_box.getFocus().id, .{ .widget = .{ .text_box = selector_box }, .rect = null, .min_size = .{ .width = choice.selector.len + 2, .height = 3 } }); 1285 }; 1286 1287 var text_input = try wgt.TextInput.init(allocator, .{ 1288 .border_style = .single, 1289 .label = choices[0].label, 1290 .bottom_label = choices[0].bottom_label, 1291 .read_only = true, 1292 .render_content = session.is_terminal, 1293 .visible_width = null, 1294 }); 1295 errdefer text_input.deinit(allocator); 1296 text_input.getFocus().focusable = true; 1297 try text_input.setContent(allocator, choices[0].text); 1298 box.getFocus().child_id = text_input.getFocus().id; 1299 try box.children.put(allocator, text_input.getFocus().id, .{ .widget = .{ .text_input = text_input }, .rect = null, .min_size = null, .flex = .shrink }); 1300 1301 return .{ .box = box, .session = session, .choices = owned_choices }; 1302 } 1303 1304 pub fn initClone(allocator: std.mem.Allocator, session: *ui.Session, location: ui.RoutablePage.RepoLocation) !?CopyableText { 1305 const aa = session.page_arena.allocator(); 1306 var choices: [2]Choice = undefined; 1307 var count: usize = 0; 1308 switch (location) { 1309 .repo => |identity| { 1310 if (session.data.git_http_port) |port| { 1311 const url = try std.fmt.allocPrint(aa, "http://localhost:{d}/repo/{s}", .{ port, identity }); 1312 choices[count] = .{ 1313 .selector = "http", 1314 .text = url, 1315 .copyable_text = try std.fmt.allocPrint(aa, "git clone {s}", .{url}), 1316 .label = " clone ", 1317 }; 1318 count += 1; 1319 } 1320 if (session.data.git_ssh_port) |port| { 1321 const url = try std.fmt.allocPrint(aa, "ssh://localhost:{d}/repo/{s}", .{ port, identity }); 1322 choices[count] = .{ 1323 .selector = "ssh", 1324 .text = url, 1325 .copyable_text = try std.fmt.allocPrint(aa, "{s}git clone {s}", .{ session.data.git_ssh_prefix, url }), 1326 .label = " clone ", 1327 }; 1328 count += 1; 1329 } 1330 }, 1331 .fork => |f| if (session.data.git_ssh_port) |port| { 1332 const url = try std.fmt.allocPrint(aa, "ssh://localhost:{d}/fork/{s}/patch:{s}", .{ port, f.identity, f.id }); 1333 choices[count] = .{ 1334 .text = url, 1335 .copyable_text = try std.fmt.allocPrint(aa, "{s}git clone {s}", .{ session.data.git_ssh_prefix, url }), 1336 .label = " clone ", 1337 }; 1338 count += 1; 1339 }, 1340 } 1341 if (count == 0) return null; 1342 return @as(?CopyableText, try init(allocator, session, choices[0..count])); 1343 } 1344 1345 pub fn minWidth(self: *const CopyableText) usize { 1346 var width = maxWidth(self.choices) + 2; 1347 if (self.choices.len > 1) { 1348 for (self.choices) |choice| width += choice.selector.len + 2; 1349 } 1350 return width; 1351 } 1352 1353 pub fn deinit(self: *CopyableText, allocator: std.mem.Allocator) void { 1354 self.box.deinit(allocator); 1355 allocator.free(self.choices); 1356 } 1357 1358 pub fn build(self: *CopyableText, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1359 self.clearGrid(); 1360 1361 if (self.choices.len > 1) { 1362 if (root_focus.grandchild_id) |id| { 1363 for (0..self.choices.len) |i| { 1364 if (id != self.selector(i).getFocus().id) continue; 1365 if (i != self.selected) try self.select(allocator, i); 1366 root_focus.setFocus(self.textInput().getFocus().id); 1367 break; 1368 } 1369 } 1370 1371 const focused = root_focus.grandchild_id == self.textInput().getFocus().id; 1372 for (0..self.choices.len) |i| { 1373 self.selector(i).options.border_style = if (i == self.selected) 1374 (if (focused) .double else .single) 1375 else 1376 .hidden; 1377 } 1378 } 1379 self.textInput().options.border_style = .single; 1380 self.textInput().cursor = 0; 1381 self.textInput().scroll_offset = 0; 1382 try self.box.build(allocator, constraint, root_focus); 1383 1384 if (!self.session.is_terminal) { 1385 const aa = self.session.arena.allocator(); 1386 const id = self.textInput().getFocus().id; 1387 try self.session.text_inputs.put(aa, id, self.textInput()); 1388 } 1389 } 1390 1391 pub fn input(self: *CopyableText, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1392 switch (key) { 1393 .arrow_left => if (self.selected > 0) try self.select(allocator, self.selected - 1), 1394 .arrow_right => if (self.selected + 1 < self.choices.len) try self.select(allocator, self.selected + 1), 1395 .enter => if (self.session.is_terminal) self.show(), 1396 .mouse => |mouse| { 1397 if (self.choices.len > 1) for (0..self.choices.len) |i| { 1398 if (!inp.leftClickOn(root_focus, self.selector(i).getFocus().id, mouse)) continue; 1399 try self.select(allocator, i); 1400 root_focus.setFocus(self.textInput().getFocus().id); 1401 return; 1402 }; 1403 if (self.session.is_terminal and !mouse.ctrl and inp.leftClickOn(root_focus, self.textInput().getFocus().id, mouse)) self.show(); 1404 }, 1405 else => {}, 1406 } 1407 } 1408 1409 fn select(self: *CopyableText, allocator: std.mem.Allocator, selected: usize) !void { 1410 self.selected = selected; 1411 const choice = self.choices[selected]; 1412 const text_input = self.textInput(); 1413 text_input.options.label = choice.label; 1414 text_input.options.bottom_label = choice.bottom_label; 1415 try text_input.setContent(allocator, choice.text); 1416 } 1417 1418 fn show(self: *CopyableText) void { 1419 const choice = self.choices[self.selected]; 1420 self.session.host_request = .{ .show_copyable_text = choice.copyable_text orelse choice.text }; 1421 } 1422 1423 fn textInput(self: *CopyableText) *wgt.TextInput { 1424 return &self.box.children.values()[if (self.choices.len > 1) self.choices.len else 0].widget.text_input; 1425 } 1426 1427 fn selector(self: *CopyableText, index: usize) *wgt.TextBox { 1428 return &self.box.children.values()[index].widget.text_box; 1429 } 1430 1431 fn maxWidth(choices: []const Choice) usize { 1432 var width: usize = 0; 1433 for (choices) |choice| width = @max(width, choice.text.len, choice.label.len, choice.bottom_label.len); 1434 return width; 1435 } 1436 1437 pub fn clearGrid(self: *CopyableText) void { 1438 self.box.clearGrid(); 1439 } 1440 1441 pub fn getGrid(self: CopyableText) ?Grid { 1442 return self.box.getGrid(); 1443 } 1444 1445 pub fn getFocus(self: *CopyableText) *Focus { 1446 return self.box.getFocus(); 1447 } 1448 }; 1449