╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const xitui = xit.xitui; 4 const term = xitui.terminal; 5 const wgt = xitui.widget; 6 const layout = xitui.layout; 7 const inp = xitui.input; 8 const Grid = xitui.grid.Grid; 9 const Focus = xitui.focus.Focus; 10 const evt = @import("./event.zig"); 11 12 pub const UsersAndRepos = @import("./ui/UsersAndRepos.zig"); 13 14 pub const Page = union(enum) { 15 users_and_repos: UsersAndRepos, 16 }; 17 18 pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const Page) !void { 19 var root = try initRoot(allocator, page); 20 defer root.deinit(); 21 22 var terminal = try term.Terminal.init(io, allocator); 23 defer terminal.deinit(io); 24 25 var last_size = layout.Size{ .width = 0, .height = 0 }; 26 var last_grid = try Grid.init(allocator, last_size); 27 defer last_grid.deinit(); 28 29 while (!terminal.shouldQuit()) { 30 const grid_changed = try terminal.render(&root, &last_grid, &last_size); 31 32 // process any inputs. 33 // 34 // if the grid didn't change, then first do a blocking 35 // read, so the thread will sleep until further input. 36 // after that, all remaining reads are non-blocking so 37 // we can process the rest of the queued inputs. 38 // 39 // if the grid *did* change, then only do non-blocking 40 // reads. we do not want to sleep the thread because 41 // there may be an animation that requires more looping. 42 var blocking = !grid_changed; 43 while (try terminal.readKey(io, blocking)) |key| { 44 try inputKey(&root, key, &terminal); 45 blocking = false; 46 } 47 48 try root.build(.{ 49 .min_size = .{ .width = null, .height = null }, 50 .max_size = .{ .width = last_size.width, .height = last_size.height }, 51 }, root.getFocus()); 52 } 53 } 54 55 // route a single decoded key into the widget tree, handling 'q'-to-quit and 56 // mouse-click focus traversal along the way. shared between the tty event 57 // loop (ui.run) and the SSH session event loop in serve_ssh_tui.zig. 58 // `terminal` is whatever terminal type the caller is driving — it must 59 // expose requestQuit(). 60 pub fn inputKey(root: *Widget, key: inp.Key, terminal: anytype) !void { 61 switch (key) { 62 .codepoint => |cp| if (cp == 'q') terminal.requestQuit() else try root.input(key, root.getFocus()), 63 .mouse => |mouse| { 64 if (mouse.action == .press and mouse.action.press == .left) { 65 const root_focus = root.getFocus(); 66 var iter = root_focus.children.iterator(); 67 while (iter.next()) |entry| { 68 const child = entry.value_ptr.*; 69 if (!child.focus.focusable) continue; 70 const r = child.rect; 71 if (mouse.x >= r.x and mouse.y >= r.y and 72 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 73 { 74 try root_focus.setFocus(entry.key_ptr.*); 75 break; 76 } 77 } 78 } else { 79 try root.input(key, root.getFocus()); 80 } 81 }, 82 else => try root.input(key, root.getFocus()), 83 } 84 } 85 86 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page) !Widget { 87 var root: Widget = switch (page.*) { 88 .users_and_repos => |*p| .{ .users_and_repos = try UsersAndRepos.View.init(allocator, p) }, 89 }; 90 errdefer root.deinit(); 91 92 try root.build(.{ 93 .min_size = .{ .width = null, .height = null }, 94 .max_size = .{ .width = 80, .height = null }, 95 }, root.getFocus()); 96 if (root.getFocus().child_id) |child_id| { 97 try root.getFocus().setFocus(child_id); 98 } 99 100 return root; 101 } 102 103 pub const Widget = union(enum) { 104 text: wgt.Text(Widget), 105 box: wgt.Box(Widget), 106 text_box: wgt.TextBox(Widget), 107 scroll: wgt.Scroll(Widget), 108 selectable_list: SelectableList, 109 users_and_repos: UsersAndRepos.View, 110 111 pub fn deinit(self: *Widget) void { 112 switch (self.*) { 113 inline else => |*case| case.deinit(), 114 } 115 } 116 117 pub fn build(self: *Widget, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 118 switch (self.*) { 119 inline else => |*case| try case.build(constraint, root_focus), 120 } 121 } 122 123 pub fn input(self: *Widget, key: inp.Key, root_focus: *Focus) anyerror!void { 124 switch (self.*) { 125 inline else => |*case| try case.input(key, root_focus), 126 } 127 } 128 129 pub fn clearGrid(self: *Widget) void { 130 switch (self.*) { 131 inline else => |*case| case.clearGrid(), 132 } 133 } 134 135 pub fn getGrid(self: Widget) ?Grid { 136 switch (self) { 137 inline else => |*case| return case.getGrid(), 138 } 139 } 140 141 pub fn getFocus(self: *Widget) *Focus { 142 switch (self.*) { 143 inline else => |*case| return case.getFocus(), 144 } 145 } 146 }; 147 148 // a scrollable list of selectable single-line items. content is replaced 149 // via setItems, which dupes the strings into the widget's own allocation. 150 pub const SelectableList = struct { 151 allocator: std.mem.Allocator, 152 scroll: wgt.Scroll(Widget), 153 lines: std.ArrayList([]const u8), 154 155 pub fn init(allocator: std.mem.Allocator) !SelectableList { 156 var self = blk: { 157 var inner_box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 158 errdefer inner_box.deinit(); 159 160 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .box = inner_box }, .vert); 161 errdefer scroll.deinit(); 162 163 break :blk SelectableList{ 164 .allocator = allocator, 165 .scroll = scroll, 166 .lines = .empty, 167 }; 168 }; 169 errdefer self.deinit(); 170 return self; 171 } 172 173 pub fn deinit(self: *SelectableList) void { 174 self.scroll.deinit(); 175 for (self.lines.items) |line| self.allocator.free(line); 176 self.lines.deinit(self.allocator); 177 } 178 179 pub fn setItems(self: *SelectableList, items: []const []const u8) !void { 180 const inner_box = &self.scroll.child.box; 181 182 for (inner_box.children.values()) |*child| { 183 child.widget.deinit(); 184 } 185 inner_box.children.clearAndFree(self.allocator); 186 187 for (self.lines.items) |line| self.allocator.free(line); 188 self.lines.clearAndFree(self.allocator); 189 190 self.scroll.x = 0; 191 self.scroll.y = 0; 192 // keep the focus tree in sync with the widget tree so setFocus can 193 // descend even when the pane isn't laid out this build (e.g. narrow 194 // terminal where only the focused pane fits). build's clear+addChild 195 // would do this otherwise, but it gets skipped for unbuilt panes. 196 inner_box.getFocus().clear(); 197 inner_box.getFocus().child_id = null; 198 199 for (items) |item| { 200 const line = try self.allocator.dupe(u8, item); 201 { 202 errdefer self.allocator.free(line); 203 try self.lines.append(self.allocator, line); 204 } 205 206 var text_box = try wgt.TextBox(Widget).init(self.allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 207 errdefer text_box.deinit(); 208 text_box.getFocus().focusable = true; 209 try inner_box.children.put(self.allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); 210 try inner_box.getFocus().addChild(text_box.getFocus(), .{ .width = 0, .height = 0 }, 0, 0); 211 } 212 213 if (inner_box.children.count() > 0) { 214 inner_box.getFocus().child_id = inner_box.children.keys()[0]; 215 } 216 } 217 218 pub fn build(self: *SelectableList, constraint: layout.Constraint, root_focus: *Focus) !void { 219 self.clearGrid(); 220 const children = &self.scroll.child.box.children; 221 for (children.keys(), children.values()) |id, *item| { 222 item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) 223 (if (root_focus.grandchild_id == id) .double else .single) 224 else 225 .hidden; 226 } 227 try self.scroll.build(constraint, root_focus); 228 } 229 230 pub fn input(self: *SelectableList, key: inp.Key, root_focus: *Focus) !void { 231 if (self.getFocus().child_id) |child_id| { 232 const children = &self.scroll.child.box.children; 233 if (children.getIndex(child_id)) |current_index| { 234 var index = current_index; 235 236 switch (key) { 237 .arrow_up => { 238 index -|= 1; 239 }, 240 .arrow_down => { 241 if (index + 1 < children.count()) { 242 index += 1; 243 } 244 }, 245 .home => { 246 index = 0; 247 }, 248 .end => { 249 if (children.count() > 0) { 250 index = children.count() - 1; 251 } 252 }, 253 .page_up => { 254 if (self.getGrid()) |grid| { 255 const half_count = (grid.size.height / 3) / 2; 256 index -|= half_count; 257 } 258 }, 259 .page_down => { 260 if (self.getGrid()) |grid| { 261 if (children.count() > 0) { 262 const half_count = (grid.size.height / 3) / 2; 263 index = @min(index + half_count, children.count() - 1); 264 } 265 } 266 }, 267 .mouse => |mouse| switch (mouse.action) { 268 .scroll => |dir| switch (dir) { 269 .up => index -|= 1, 270 .down => if (index + 1 < children.count()) { 271 index += 1; 272 }, 273 }, 274 else => {}, 275 }, 276 else => {}, 277 } 278 279 if (index != current_index) { 280 try root_focus.setFocus(children.keys()[index]); 281 self.updateScroll(index); 282 } 283 } 284 } 285 } 286 287 pub fn clearGrid(self: *SelectableList) void { 288 self.scroll.clearGrid(); 289 } 290 291 pub fn getGrid(self: SelectableList) ?Grid { 292 return self.scroll.getGrid(); 293 } 294 295 pub fn getFocus(self: *SelectableList) *Focus { 296 return self.scroll.getFocus(); 297 } 298 299 pub fn getSelectedIndex(self: SelectableList) ?usize { 300 if (self.scroll.child.box.focus.child_id) |child_id| { 301 const children = &self.scroll.child.box.children; 302 return children.getIndex(child_id); 303 } else { 304 return null; 305 } 306 } 307 308 fn updateScroll(self: *SelectableList, index: usize) void { 309 const inner_box = &self.scroll.child.box; 310 if (inner_box.children.values()[index].rect) |rect| { 311 self.scroll.scrollToRect(rect); 312 } 313 } 314 }; 315