╭ 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 Home = @import("./ui/Home.zig"); 13 14 pub const Page = union(enum) { 15 home: Home, 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.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 .home => |*p| .{ .home = try Home.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 stack: wgt.Stack(Widget), 109 selectable_list: SelectableList, 110 home: Home.View, 111 home_header: Home.Header.View, 112 home_users: Home.Users.View, 113 home_repos: Home.Repos.View, 114 115 pub fn deinit(self: *Widget) void { 116 switch (self.*) { 117 inline else => |*case| case.deinit(), 118 } 119 } 120 121 pub fn build(self: *Widget, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 122 switch (self.*) { 123 inline else => |*case| try case.build(constraint, root_focus), 124 } 125 } 126 127 pub fn input(self: *Widget, key: inp.Key, root_focus: *Focus) anyerror!void { 128 switch (self.*) { 129 inline else => |*case| try case.input(key, root_focus), 130 } 131 } 132 133 pub fn clearGrid(self: *Widget) void { 134 switch (self.*) { 135 inline else => |*case| case.clearGrid(), 136 } 137 } 138 139 pub fn getGrid(self: Widget) ?Grid { 140 switch (self) { 141 inline else => |*case| return case.getGrid(), 142 } 143 } 144 145 pub fn getFocus(self: *Widget) *Focus { 146 switch (self.*) { 147 inline else => |*case| return case.getFocus(), 148 } 149 } 150 }; 151 152 // a scrollable list of selectable single-line items. content is replaced 153 // via setItems, which dupes the strings into the widget's own allocation. 154 pub const SelectableList = struct { 155 allocator: std.mem.Allocator, 156 scroll: wgt.Scroll(Widget), 157 lines: std.ArrayList([]const u8), 158 159 pub fn init(allocator: std.mem.Allocator) !SelectableList { 160 var self = blk: { 161 var inner_box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 162 errdefer inner_box.deinit(); 163 164 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .box = inner_box }, .vert); 165 errdefer scroll.deinit(); 166 167 break :blk SelectableList{ 168 .allocator = allocator, 169 .scroll = scroll, 170 .lines = .empty, 171 }; 172 }; 173 errdefer self.deinit(); 174 return self; 175 } 176 177 pub fn deinit(self: *SelectableList) void { 178 self.scroll.deinit(); 179 for (self.lines.items) |line| self.allocator.free(line); 180 self.lines.deinit(self.allocator); 181 } 182 183 pub fn setItems(self: *SelectableList, items: []const []const u8) !void { 184 const inner_box = &self.scroll.child.box; 185 186 for (inner_box.children.values()) |*child| { 187 child.widget.deinit(); 188 } 189 inner_box.children.clearAndFree(self.allocator); 190 191 for (self.lines.items) |line| self.allocator.free(line); 192 self.lines.clearAndFree(self.allocator); 193 194 self.scroll.x = 0; 195 self.scroll.y = 0; 196 // keep the focus tree in sync with the widget tree so setFocus can 197 // descend even when the pane isn't laid out this build (e.g. narrow 198 // terminal where only the focused pane fits). build's clear+addChild 199 // would do this otherwise, but it gets skipped for unbuilt panes. 200 inner_box.getFocus().clear(); 201 inner_box.getFocus().child_id = null; 202 203 for (items) |item| { 204 const line = try self.allocator.dupe(u8, item); 205 { 206 errdefer self.allocator.free(line); 207 try self.lines.append(self.allocator, line); 208 } 209 210 var text_box = try wgt.TextBox(Widget).init(self.allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 211 errdefer text_box.deinit(); 212 text_box.getFocus().focusable = true; 213 try inner_box.children.put(self.allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); 214 try inner_box.getFocus().addChild(text_box.getFocus(), .{ .width = 0, .height = 0 }, 0, 0); 215 } 216 217 if (inner_box.children.count() > 0) { 218 inner_box.getFocus().child_id = inner_box.children.keys()[0]; 219 } 220 } 221 222 pub fn build(self: *SelectableList, constraint: layout.Constraint, root_focus: *Focus) !void { 223 self.clearGrid(); 224 const children = &self.scroll.child.box.children; 225 for (children.keys(), children.values()) |id, *item| { 226 item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) 227 (if (root_focus.grandchild_id == id) .double else .single) 228 else 229 .hidden; 230 } 231 try self.scroll.build(constraint, root_focus); 232 } 233 234 pub fn input(self: *SelectableList, key: inp.Key, root_focus: *Focus) !void { 235 if (self.getFocus().child_id) |child_id| { 236 const children = &self.scroll.child.box.children; 237 if (children.getIndex(child_id)) |current_index| { 238 var index = current_index; 239 240 switch (key) { 241 .arrow_up => { 242 index -|= 1; 243 }, 244 .arrow_down => { 245 if (index + 1 < children.count()) { 246 index += 1; 247 } 248 }, 249 .home => { 250 index = 0; 251 }, 252 .end => { 253 if (children.count() > 0) { 254 index = children.count() - 1; 255 } 256 }, 257 .page_up => { 258 if (self.getGrid()) |grid| { 259 const half_count = (grid.size.height / 3) / 2; 260 index -|= half_count; 261 } 262 }, 263 .page_down => { 264 if (self.getGrid()) |grid| { 265 if (children.count() > 0) { 266 const half_count = (grid.size.height / 3) / 2; 267 index = @min(index + half_count, children.count() - 1); 268 } 269 } 270 }, 271 .mouse => |mouse| switch (mouse.action) { 272 .scroll => |dir| switch (dir) { 273 .up => index -|= 1, 274 .down => if (index + 1 < children.count()) { 275 index += 1; 276 }, 277 }, 278 else => {}, 279 }, 280 else => {}, 281 } 282 283 if (index != current_index) { 284 try root_focus.setFocus(children.keys()[index]); 285 self.updateScroll(index); 286 } 287 } 288 } 289 } 290 291 pub fn clearGrid(self: *SelectableList) void { 292 self.scroll.clearGrid(); 293 } 294 295 pub fn getGrid(self: SelectableList) ?Grid { 296 return self.scroll.getGrid(); 297 } 298 299 pub fn getFocus(self: *SelectableList) *Focus { 300 return self.scroll.getFocus(); 301 } 302 303 pub fn getSelectedIndex(self: SelectableList) ?usize { 304 if (self.scroll.child.box.focus.child_id) |child_id| { 305 const children = &self.scroll.child.box.children; 306 return children.getIndex(child_id); 307 } else { 308 return null; 309 } 310 } 311 312 fn updateScroll(self: *SelectableList, index: usize) void { 313 const inner_box = &self.scroll.child.box; 314 if (inner_box.children.values()[index].rect) |rect| { 315 self.scroll.scrollToRect(rect); 316 } 317 } 318 }; 319