╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const rp = xit.repo; 4 const hash = xit.hash; 5 const xitui = xit.xitui; 6 const term = xitui.terminal; 7 const wgt = xitui.widget; 8 const layout = xitui.layout; 9 const inp = xitui.input; 10 const Grid = xitui.grid.Grid; 11 const Focus = xitui.focus.Focus; 12 const evt = @import("./event.zig"); 13 14 pub const canonical_repo_opts: rp.RepoOpts(.xit) = .{}; 15 pub const DB = rp.Repo(.xit, canonical_repo_opts).DB; 16 17 pub const Home = @import("./ui/Home.zig"); 18 pub const Title = @import("./ui/Title.zig"); 19 20 pub const Page = union(enum) { 21 home: Home, 22 }; 23 24 // a top-level "page" the user can navigate to 25 pub const RoutablePage = enum { 26 home_users, 27 home_repos, 28 home_ansi, 29 home_auth, 30 31 pub const default: RoutablePage = .home_users; 32 33 pub fn url(self: RoutablePage) []const u8 { 34 return switch (self) { 35 .home_users => "/users", 36 .home_repos => "/repos", 37 .home_ansi => "/ansi", 38 .home_auth => "/auth", 39 }; 40 } 41 42 pub fn fromUrl(path: []const u8) ?RoutablePage { 43 if (std.mem.eql(u8, path, "/")) return default; 44 if (std.mem.eql(u8, path, "/users")) return .home_users; 45 if (std.mem.eql(u8, path, "/repos")) return .home_repos; 46 if (std.mem.eql(u8, path, "/ansi")) return .home_ansi; 47 if (std.mem.eql(u8, path, "/auth")) return .home_auth; 48 return null; 49 } 50 }; 51 52 // per-connection mutable state. each SSH session / web session / local TUI 53 // run gets its own 54 pub const Session = struct { 55 data: Data = .{}, // serializable data sent down to web client 56 arena: *std.heap.ArenaAllocator, 57 haxy_moment: ?DB.HashMap(.read_only) = null, // db cursor (null on the wasm side) 58 59 pub const Data = struct { 60 user_id: ?[]const u8 = null, 61 // a transient outcome to surface from the last /login POST attempt 62 login_failure: ?Home.Auth.Login.Failure = null, 63 current_page: RoutablePage = .default, 64 // whether to render the ANSI art backdrop 65 enable_ansi: bool = false, 66 }; 67 68 pub fn init( 69 comptime repo_opts: rp.RepoOpts(.xit), 70 arena: *std.heap.ArenaAllocator, 71 repo: *rp.Repo(.xit, repo_opts), 72 data: Data, 73 ) !Session { 74 const RepoDB = rp.Repo(.xit, repo_opts).DB; 75 76 const history = try RepoDB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 77 78 const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; 79 const moment = try RepoDB.HashMap(.read_only).init(moment_cursor); 80 81 const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy-last-object-id")) orelse return error.NotFound; 82 var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; 83 _ = try last_object_id_cursor.readBytes(&last_object_id); 84 85 const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; 86 const haxy = try RepoDB.ArrayList(.read_only).init(haxy_cursor); 87 88 const haxy_moments_cursor = try haxy.getCursor(-1) orelse return error.NotFound; 89 const haxy_moments = try RepoDB.HashMap(.read_only).init(haxy_moments_cursor); 90 91 const haxy_moment_cursor = try haxy_moments.getCursor(hash.bytesToInt(repo_opts.hash, &last_object_id)) orelse return error.NotFound; 92 const haxy_moment = try RepoDB.HashMap(.read_only).init(haxy_moment_cursor); 93 94 return .{ 95 .data = data, 96 .arena = arena, 97 .haxy_moment = haxy_moment, 98 }; 99 } 100 }; 101 102 // what the server hands to the client (and what main_wasm parses on _start). 103 // keeps Page free of any per-request session state. 104 pub const Snapshot = struct { 105 page: Page, 106 session: Session.Data = .{}, 107 }; 108 109 pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const Page, session: *Session) !void { 110 var root = try initRoot(allocator, page, session); 111 defer root.deinit(allocator); 112 113 var terminal = try term.Terminal.init(io, allocator); 114 defer terminal.deinit(io); 115 116 var last_size = layout.Size{ .width = 0, .height = 0 }; 117 var last_grid = try Grid.init(allocator, last_size); 118 defer last_grid.deinit(); 119 120 while (!terminal.shouldQuit()) { 121 const grid_changed = try terminal.render(&root, &last_grid, &last_size); 122 123 // process any inputs. 124 // 125 // if the grid didn't change, then first do a blocking 126 // read, so the thread will sleep until further input. 127 // after that, all remaining reads are non-blocking so 128 // we can process the rest of the queued inputs. 129 // 130 // if the grid *did* change, then only do non-blocking 131 // reads. we do not want to sleep the thread because 132 // there may be an animation that requires more looping. 133 var blocking = !grid_changed; 134 while (try terminal.readKey(io, blocking)) |key| { 135 try inputKey(allocator, &root, key, &terminal); 136 blocking = false; 137 } 138 139 try root.build(allocator, .{ 140 .min_size = .{ .width = null, .height = null }, 141 .max_size = .{ .width = last_size.width, .height = last_size.height }, 142 }, root.getFocus()); 143 } 144 } 145 146 pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: inp.Key, terminal: anytype) !void { 147 switch (key) { 148 .escape => terminal.requestQuit(), 149 .mouse => |mouse| { 150 if (mouse.action == .press and mouse.action.press == .left) { 151 const root_focus = root.getFocus(); 152 var iter = root_focus.children.iterator(); 153 while (iter.next()) |entry| { 154 const child = entry.value_ptr.*; 155 if (!child.focus.focusable) continue; 156 const r = child.rect; 157 if (mouse.x >= r.x and mouse.y >= r.y and 158 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 159 { 160 try root_focus.setFocus(entry.key_ptr.*); 161 break; 162 } 163 } 164 // forward the press into the widget tree so buttons (and any 165 // future click-aware widgets) can react. widgets that don't 166 // care about presses ignore it. 167 try root.input(allocator, key, root.getFocus()); 168 } else { 169 try root.input(allocator, key, root.getFocus()); 170 } 171 }, 172 else => try root.input(allocator, key, root.getFocus()), 173 } 174 } 175 176 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { 177 const page_widget: Widget = switch (page.*) { 178 .home => |*p| .{ .home = try .init(allocator, p, session) }, 179 }; 180 181 const demon_art = @embedFile("embed/demon.ans"); 182 183 var root = Widget{ .background = try AnsiBackground.init(allocator, page_widget, demon_art, session) }; 184 errdefer root.deinit(allocator); 185 186 try root.build(allocator, .{ 187 .min_size = .{ .width = null, .height = null }, 188 .max_size = .{ .width = 80, .height = null }, 189 }, root.getFocus()); 190 if (root.getFocus().child_id) |child_id| { 191 try root.getFocus().setFocus(child_id); 192 } 193 194 return root; 195 } 196 197 pub const Widget = union(enum) { 198 text: wgt.Text(Widget), 199 box: wgt.Box(Widget), 200 text_box: wgt.TextBox(Widget), 201 text_input: wgt.TextInput(Widget), 202 scroll: wgt.Scroll(Widget), 203 stack: wgt.Stack(Widget), 204 flow_box: FlowBox, 205 spacer: Spacer, 206 center: Center, 207 ansi_art: AnsiArt, 208 background: AnsiBackground, 209 home: Home.View, 210 title: Title.View, 211 home_header: Home.Header.View, 212 home_users: Home.Users.View, 213 home_repos: Home.Repos.View, 214 home_auth_tab: Home.Header.AuthTab.View, 215 home_ansi: Home.Ansi.View, 216 home_auth: Home.Auth.View, 217 218 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void { 219 switch (self.*) { 220 inline else => |*case| case.deinit(allocator), 221 } 222 } 223 224 pub fn build(self: *Widget, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 225 switch (self.*) { 226 inline else => |*case| try case.build(allocator, constraint, root_focus), 227 } 228 } 229 230 pub fn input(self: *Widget, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) anyerror!void { 231 switch (self.*) { 232 inline else => |*case| try case.input(allocator, key, root_focus), 233 } 234 } 235 236 pub fn clearGrid(self: *Widget) void { 237 switch (self.*) { 238 inline else => |*case| case.clearGrid(), 239 } 240 } 241 242 pub fn getGrid(self: Widget) ?Grid { 243 switch (self) { 244 inline else => |*case| return case.getGrid(), 245 } 246 } 247 248 pub fn getFocus(self: *Widget) *Focus { 249 switch (self.*) { 250 inline else => |*case| return case.getFocus(), 251 } 252 } 253 }; 254 255 pub const FlowBox = struct { 256 focus: Focus, 257 grid: ?Grid, 258 text_boxes: std.ArrayList(wgt.TextBox(Widget)), 259 lines: std.ArrayList([]const u8), 260 // column count from the last build — FlowBox.Scroll.input uses it so arrow 261 // up/down can step by a row's worth of items. 262 last_cols: usize, 263 options: Options, 264 265 const border_rows: usize = 2; 266 267 pub const Options = struct { 268 cell_width: usize = 40, 269 cell_height: usize = 3, 270 }; 271 272 pub fn init(options: Options) FlowBox { 273 return .{ 274 .focus = Focus.init(.container), 275 .grid = null, 276 .text_boxes = .empty, 277 .lines = .empty, 278 .last_cols = 1, 279 .options = options, 280 }; 281 } 282 283 pub fn deinit(self: *FlowBox, allocator: std.mem.Allocator) void { 284 self.focus.deinit(allocator); 285 if (self.grid) |*grid| { 286 grid.deinit(); 287 self.grid = null; 288 } 289 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 290 self.text_boxes.deinit(allocator); 291 for (self.lines.items) |line| allocator.free(line); 292 self.lines.deinit(allocator); 293 } 294 295 pub fn setItems(self: *FlowBox, allocator: std.mem.Allocator, items: []const []const u8) !void { 296 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 297 self.text_boxes.clearAndFree(allocator); 298 299 for (self.lines.items) |line| allocator.free(line); 300 self.lines.clearAndFree(allocator); 301 302 self.focus.clear(); 303 self.focus.child_id = null; 304 305 for (items) |item| { 306 const line = try allocator.dupe(u8, item); 307 { 308 errdefer allocator.free(line); 309 try self.lines.append(allocator, line); 310 } 311 312 var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 313 errdefer text_box.deinit(allocator); 314 text_box.getFocus().focusable = true; 315 try self.text_boxes.append(allocator, text_box); 316 } 317 318 if (self.text_boxes.items.len > 0) { 319 self.focus.child_id = self.text_boxes.items[0].getFocus().id; 320 } 321 } 322 323 pub fn build(self: *FlowBox, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 324 self.clearGrid(); 325 self.focus.clear(); 326 327 const cell_width = self.options.cell_width; 328 const max_width = constraint.max_size.width orelse cell_width; 329 const cols = if (cell_width == 0) 1 else @max(1, max_width / cell_width); 330 self.last_cols = cols; 331 332 const count = self.text_boxes.items.len; 333 if (count == 0) return; 334 const slot_height = self.options.cell_height + border_rows; 335 if (slot_height == 0) return; 336 337 // build at the slot size so every text box fits a single grid cell 338 for (self.text_boxes.items) |*tb| { 339 tb.options.border_style = if (self.focus.child_id == tb.getFocus().id) .single else .hidden; 340 try tb.build(allocator, .{ 341 .min_size = .{ .width = cell_width, .height = null }, 342 .max_size = .{ .width = cell_width, .height = slot_height }, 343 }, root_focus); 344 } 345 346 const rows = (count + cols - 1) / cols; 347 const content_height = rows * slot_height; 348 const total_width = cols * cell_width; 349 if (total_width == 0 or content_height == 0) return; 350 351 var grid = try Grid.init(allocator, .{ .width = total_width, .height = content_height }); 352 errdefer grid.deinit(); 353 354 for (self.text_boxes.items, 0..) |*tb, i| { 355 const tb_grid = tb.getGrid() orelse continue; 356 const col = i % cols; 357 const row = i / cols; 358 const cell_x = col * cell_width; 359 const cell_y = row * slot_height; 360 try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, cell_x, cell_y); 361 try grid.drawGrid(tb_grid, cell_x, cell_y); 362 } 363 364 self.grid = grid; 365 } 366 367 pub fn input(self: *FlowBox, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 368 _ = self; 369 _ = allocator; 370 _ = key; 371 _ = root_focus; 372 } 373 374 pub fn clearGrid(self: *FlowBox) void { 375 if (self.grid) |*grid| { 376 grid.deinit(); 377 self.grid = null; 378 } 379 for (self.text_boxes.items) |*tb| tb.clearGrid(); 380 } 381 382 pub fn getGrid(self: FlowBox) ?Grid { 383 return self.grid; 384 } 385 386 pub fn getFocus(self: *FlowBox) *Focus { 387 return &self.focus; 388 } 389 390 pub fn cellRect(self: FlowBox, index: usize) ?layout.IRect { 391 if (self.last_cols == 0 or index >= self.text_boxes.items.len) return null; 392 const slot_height = self.options.cell_height + border_rows; 393 const col = index % self.last_cols; 394 const row = index / self.last_cols; 395 return .{ 396 .x = @intCast(col * self.options.cell_width), 397 .y = @intCast(row * slot_height), 398 .size = .{ .width = self.options.cell_width, .height = slot_height }, 399 }; 400 } 401 402 pub fn indexOfFocusId(self: FlowBox, focus_id: usize) ?usize { 403 for (self.text_boxes.items, 0..) |tb, i| { 404 if (tb.box.focus.id == focus_id) return i; 405 } 406 return null; 407 } 408 409 pub const Scroll = struct { 410 scroll: wgt.Scroll(Widget), 411 412 pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options) !Scroll { 413 var layout_inner = FlowBox.init(options); 414 errdefer layout_inner.deinit(allocator); 415 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .vert); 416 errdefer scroll.deinit(allocator); 417 return .{ .scroll = scroll }; 418 } 419 420 pub fn deinit(self: *Scroll, allocator: std.mem.Allocator) void { 421 self.scroll.deinit(allocator); 422 } 423 424 pub fn setItems(self: *Scroll, allocator: std.mem.Allocator, items: []const []const u8) !void { 425 self.scroll.x = 0; 426 self.scroll.y = 0; 427 try self.inner().setItems(allocator, items); 428 } 429 430 pub fn build(self: *Scroll, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 431 try self.scroll.build(allocator, constraint, root_focus); 432 } 433 434 pub fn input(self: *Scroll, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 435 _ = allocator; 436 const in = self.inner(); 437 const child_id = in.focus.child_id orelse return; 438 const current_index = in.indexOfFocusId(child_id) orelse return; 439 const count = in.text_boxes.items.len; 440 if (count == 0) return; 441 const cols = in.last_cols; 442 const slot_height = in.options.cell_height + FlowBox.border_rows; 443 444 var index = current_index; 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