╭ 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_auth, 29 30 pub const default: RoutablePage = .home_users; 31 32 pub fn url(self: RoutablePage) []const u8 { 33 return switch (self) { 34 .home_users => "/users", 35 .home_repos => "/repos", 36 .home_auth => "/auth", 37 }; 38 } 39 40 pub fn fromUrl(path: []const u8) ?RoutablePage { 41 if (std.mem.eql(u8, path, "/")) return default; 42 if (std.mem.eql(u8, path, "/users")) return .home_users; 43 if (std.mem.eql(u8, path, "/repos")) return .home_repos; 44 if (std.mem.eql(u8, path, "/auth")) return .home_auth; 45 return null; 46 } 47 }; 48 49 // per-connection mutable state. each SSH session / web session / local TUI 50 // run gets its own 51 pub const Session = struct { 52 data: Data = .{}, // serializable data sent down to web client 53 arena: *std.heap.ArenaAllocator, 54 haxy_moment: ?DB.HashMap(.read_only) = null, // db cursor (null on the wasm side) 55 56 pub const Data = struct { 57 user_id: ?[]const u8 = null, 58 // a transient outcome to surface from the last /login POST attempt 59 login_failure: ?Home.Auth.Login.Failure = null, 60 current_page: RoutablePage = .default, 61 }; 62 63 pub fn init( 64 comptime repo_opts: rp.RepoOpts(.xit), 65 arena: *std.heap.ArenaAllocator, 66 repo: *rp.Repo(.xit, repo_opts), 67 data: Data, 68 ) !Session { 69 const RepoDB = rp.Repo(.xit, repo_opts).DB; 70 71 const history = try RepoDB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 72 73 const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; 74 const moment = try RepoDB.HashMap(.read_only).init(moment_cursor); 75 76 const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy-last-object-id")) orelse return error.NotFound; 77 var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; 78 _ = try last_object_id_cursor.readBytes(&last_object_id); 79 80 const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; 81 const haxy = try RepoDB.ArrayList(.read_only).init(haxy_cursor); 82 83 const haxy_moments_cursor = try haxy.getCursor(-1) orelse return error.NotFound; 84 const haxy_moments = try RepoDB.HashMap(.read_only).init(haxy_moments_cursor); 85 86 const haxy_moment_cursor = try haxy_moments.getCursor(hash.bytesToInt(repo_opts.hash, &last_object_id)) orelse return error.NotFound; 87 const haxy_moment = try RepoDB.HashMap(.read_only).init(haxy_moment_cursor); 88 89 return .{ 90 .data = data, 91 .arena = arena, 92 .haxy_moment = haxy_moment, 93 }; 94 } 95 }; 96 97 // what the server hands to the client (and what main_wasm parses on _start). 98 // keeps Page free of any per-request session state. 99 pub const Snapshot = struct { 100 page: Page, 101 session: Session.Data = .{}, 102 }; 103 104 pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const Page, session: *Session) !void { 105 var root = try initRoot(allocator, page, session); 106 defer root.deinit(allocator); 107 108 var terminal = try term.Terminal.init(io, allocator); 109 defer terminal.deinit(io); 110 111 var last_size = layout.Size{ .width = 0, .height = 0 }; 112 var last_grid = try Grid.init(allocator, last_size); 113 defer last_grid.deinit(); 114 115 while (!terminal.shouldQuit()) { 116 const grid_changed = try terminal.render(&root, &last_grid, &last_size); 117 118 // process any inputs. 119 // 120 // if the grid didn't change, then first do a blocking 121 // read, so the thread will sleep until further input. 122 // after that, all remaining reads are non-blocking so 123 // we can process the rest of the queued inputs. 124 // 125 // if the grid *did* change, then only do non-blocking 126 // reads. we do not want to sleep the thread because 127 // there may be an animation that requires more looping. 128 var blocking = !grid_changed; 129 while (try terminal.readKey(io, blocking)) |key| { 130 try inputKey(allocator, &root, key, &terminal); 131 blocking = false; 132 } 133 134 try root.build(allocator, .{ 135 .min_size = .{ .width = null, .height = null }, 136 .max_size = .{ .width = last_size.width, .height = last_size.height }, 137 }, root.getFocus()); 138 } 139 } 140 141 pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: inp.Key, terminal: anytype) !void { 142 switch (key) { 143 .escape => terminal.requestQuit(), 144 .mouse => |mouse| { 145 if (mouse.action == .press and mouse.action.press == .left) { 146 const root_focus = root.getFocus(); 147 var iter = root_focus.children.iterator(); 148 while (iter.next()) |entry| { 149 const child = entry.value_ptr.*; 150 if (!child.focus.focusable) continue; 151 const r = child.rect; 152 if (mouse.x >= r.x and mouse.y >= r.y and 153 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 154 { 155 try root_focus.setFocus(entry.key_ptr.*); 156 break; 157 } 158 } 159 // forward the press into the widget tree so buttons (and any 160 // future click-aware widgets) can react. widgets that don't 161 // care about presses ignore it. 162 try root.input(allocator, key, root.getFocus()); 163 } else { 164 try root.input(allocator, key, root.getFocus()); 165 } 166 }, 167 else => try root.input(allocator, key, root.getFocus()), 168 } 169 } 170 171 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { 172 const page_widget: Widget = switch (page.*) { 173 .home => |*p| .{ .home = try .init(allocator, p, session) }, 174 }; 175 176 const demon_art = @embedFile("embed/demon.ans"); 177 178 var root = Widget{ .background = try AnsiBackground.init(allocator, page_widget, demon_art) }; 179 errdefer root.deinit(allocator); 180 181 try root.build(allocator, .{ 182 .min_size = .{ .width = null, .height = null }, 183 .max_size = .{ .width = 80, .height = null }, 184 }, root.getFocus()); 185 if (root.getFocus().child_id) |child_id| { 186 try root.getFocus().setFocus(child_id); 187 } 188 189 return root; 190 } 191 192 pub const Widget = union(enum) { 193 text: wgt.Text(Widget), 194 box: wgt.Box(Widget), 195 text_box: wgt.TextBox(Widget), 196 text_input: wgt.TextInput(Widget), 197 scroll: wgt.Scroll(Widget), 198 stack: wgt.Stack(Widget), 199 flow_box: FlowBox, 200 spacer: Spacer, 201 center: Center, 202 ansi_art: AnsiArt, 203 background: AnsiBackground, 204 home: Home.View, 205 title: Title.View, 206 home_header: Home.Header.View, 207 home_users: Home.Users.View, 208 home_repos: Home.Repos.View, 209 home_auth_tab: Home.Header.AuthTab.View, 210 home_auth: Home.Auth.View, 211 212 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void { 213 switch (self.*) { 214 inline else => |*case| case.deinit(allocator), 215 } 216 } 217 218 pub fn build(self: *Widget, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 219 switch (self.*) { 220 inline else => |*case| try case.build(allocator, constraint, root_focus), 221 } 222 } 223 224 pub fn input(self: *Widget, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) anyerror!void { 225 switch (self.*) { 226 inline else => |*case| try case.input(allocator, key, root_focus), 227 } 228 } 229 230 pub fn clearGrid(self: *Widget) void { 231 switch (self.*) { 232 inline else => |*case| case.clearGrid(), 233 } 234 } 235 236 pub fn getGrid(self: Widget) ?Grid { 237 switch (self) { 238 inline else => |*case| return case.getGrid(), 239 } 240 } 241 242 pub fn getFocus(self: *Widget) *Focus { 243 switch (self.*) { 244 inline else => |*case| return case.getFocus(), 245 } 246 } 247 }; 248 249 pub const FlowBox = struct { 250 focus: Focus, 251 grid: ?Grid, 252 text_boxes: std.ArrayList(wgt.TextBox(Widget)), 253 lines: std.ArrayList([]const u8), 254 // column count from the last build — FlowBox.Scroll.input uses it so arrow 255 // up/down can step by a row's worth of items. 256 last_cols: usize, 257 options: Options, 258 259 const border_rows: usize = 2; 260 261 pub const Options = struct { 262 cell_width: usize = 40, 263 cell_height: usize = 3, 264 }; 265 266 pub fn init(options: Options) FlowBox { 267 return .{ 268 .focus = Focus.init(.container), 269 .grid = null, 270 .text_boxes = .empty, 271 .lines = .empty, 272 .last_cols = 1, 273 .options = options, 274 }; 275 } 276 277 pub fn deinit(self: *FlowBox, allocator: std.mem.Allocator) void { 278 self.focus.deinit(allocator); 279 if (self.grid) |*grid| { 280 grid.deinit(); 281 self.grid = null; 282 } 283 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 284 self.text_boxes.deinit(allocator); 285 for (self.lines.items) |line| allocator.free(line); 286 self.lines.deinit(allocator); 287 } 288 289 pub fn setItems(self: *FlowBox, allocator: std.mem.Allocator, items: []const []const u8) !void { 290 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 291 self.text_boxes.clearAndFree(allocator); 292 293 for (self.lines.items) |line| allocator.free(line); 294 self.lines.clearAndFree(allocator); 295 296 self.focus.clear(); 297 self.focus.child_id = null; 298 299 for (items) |item| { 300 const line = try allocator.dupe(u8, item); 301 { 302 errdefer allocator.free(line); 303 try self.lines.append(allocator, line); 304 } 305 306 var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 307 errdefer text_box.deinit(allocator); 308 text_box.getFocus().focusable = true; 309 try self.text_boxes.append(allocator, text_box); 310 } 311 312 if (self.text_boxes.items.len > 0) { 313 self.focus.child_id = self.text_boxes.items[0].getFocus().id; 314 } 315 } 316 317 pub fn build(self: *FlowBox, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 318 self.clearGrid(); 319 self.focus.clear(); 320 321 const cell_width = self.options.cell_width; 322 const max_width = constraint.max_size.width orelse cell_width; 323 const cols = if (cell_width == 0) 1 else @max(1, max_width / cell_width); 324 self.last_cols = cols; 325 326 const count = self.text_boxes.items.len; 327 if (count == 0) return; 328 const slot_height = self.options.cell_height + border_rows; 329 if (slot_height == 0) return; 330 331 // build at the slot size so every text box fits a single grid cell 332 for (self.text_boxes.items) |*tb| { 333 tb.options.border_style = if (self.focus.child_id == tb.getFocus().id) .single else .hidden; 334 try tb.build(allocator, .{ 335 .min_size = .{ .width = cell_width, .height = null }, 336 .max_size = .{ .width = cell_width, .height = slot_height }, 337 }, root_focus); 338 } 339 340 const rows = (count + cols - 1) / cols; 341 const content_height = rows * slot_height; 342 const total_width = cols * cell_width; 343 if (total_width == 0 or content_height == 0) return; 344 345 var grid = try Grid.init(allocator, .{ .width = total_width, .height = content_height }); 346 errdefer grid.deinit(); 347 348 for (self.text_boxes.items, 0..) |*tb, i| { 349 const tb_grid = tb.getGrid() orelse continue; 350 const col = i % cols; 351 const row = i / cols; 352 const cell_x = col * cell_width; 353 const cell_y = row * slot_height; 354 try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, cell_x, cell_y); 355 try grid.drawGrid(tb_grid, cell_x, cell_y); 356 } 357 358 self.grid = grid; 359 } 360 361 pub fn input(self: *FlowBox, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 362 _ = self; 363 _ = allocator; 364 _ = key; 365 _ = root_focus; 366 } 367 368 pub fn clearGrid(self: *FlowBox) void { 369 if (self.grid) |*grid| { 370 grid.deinit(); 371 self.grid = null; 372 } 373 for (self.text_boxes.items) |*tb| tb.clearGrid(); 374 } 375 376 pub fn getGrid(self: FlowBox) ?Grid { 377 return self.grid; 378 } 379 380 pub fn getFocus(self: *FlowBox) *Focus { 381 return &self.focus; 382 } 383 384 pub fn cellRect(self: FlowBox, index: usize) ?layout.IRect { 385 if (self.last_cols == 0 or index >= self.text_boxes.items.len) return null; 386 const slot_height = self.options.cell_height + border_rows; 387 const col = index % self.last_cols; 388 const row = index / self.last_cols; 389 return .{ 390 .x = @intCast(col * self.options.cell_width), 391 .y = @intCast(row * slot_height), 392 .size = .{ .width = self.options.cell_width, .height = slot_height }, 393 }; 394 } 395 396 pub fn indexOfFocusId(self: FlowBox, focus_id: usize) ?usize { 397 for (self.text_boxes.items, 0..) |tb, i| { 398 if (tb.box.focus.id == focus_id) return i; 399 } 400 return null; 401 } 402 403 pub const Scroll = struct { 404 scroll: wgt.Scroll(Widget), 405 406 pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options) !Scroll { 407 var layout_inner = FlowBox.init(options); 408 errdefer layout_inner.deinit(allocator); 409 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .vert); 410 errdefer scroll.deinit(allocator); 411 return .{ .scroll = scroll }; 412 } 413 414 pub fn deinit(self: *Scroll, allocator: std.mem.Allocator) void { 415 self.scroll.deinit(allocator); 416 } 417 418 pub fn setItems(self: *Scroll, allocator: std.mem.Allocator, items: []const []const u8) !void { 419 self.scroll.x = 0; 420 self.scroll.y = 0; 421 try self.inner().setItems(allocator, items); 422 } 423 424 pub fn build(self: *Scroll, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 425 try self.scroll.build(allocator, constraint, root_focus); 426 } 427 428 pub fn input(self: *Scroll, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 429 _ = allocator; 430 const in = self.inner(); 431 const child_id = in.focus.child_id orelse return; 432 const current_index = in.indexOfFocusId(child_id) orelse return; 433 const count = in.text_boxes.items.len; 434 if (count == 0) return; 435 const cols = in.last_cols; 436 const slot_height = in.options.cell_height + FlowBox.border_rows; 437 438 var index = current_index; 439 switch (key) { 440 .arrow_up => index -|= cols, 441 .arrow_down => if (index + cols < count) { 442 index += cols; 443 }, 444 .arrow_left => index -|= 1, 445 .arrow_right => if (index + 1 < count) { 446 index += 1; 447 }, 448 .home => index = 0, 449 .end => index = count - 1, 450 .page_up => { 451 if (self.scroll.grid) |grid| if (slot_height > 0) { 452 const rows_per_page = grid.size.height / slot_height; 453 index -|= rows_per_page * cols; 454 }; 455 }, 456 .page_down => { 457 if (self.scroll.grid) |grid| if (slot_height > 0) { 458 const rows_per_page = grid.size.height / slot_height; 459 index = @min(index + rows_per_page * cols, count - 1); 460 }; 461 }, 462 // scroll wheel moves the focused cell by a full row so the 463 // viewport (via scrollToRect) follows in row-sized steps, 464 // matching how a scroll wheel feels in a grid view. 465 .mouse => |mouse| switch (mouse.action) { 466 .scroll => |dir| switch (dir) { 467 .up => index -|= cols, 468 .down => if (index + cols < count) { 469 index += cols; 470 }, 471 }, 472 else => {}, 473 }, 474 else => {}, 475 } 476 477 if (index != current_index) { 478 try root_focus.setFocus(in.text_boxes.items[index].getFocus().id); 479 if (in.cellRect(index)) |rect| { 480 self.scroll.scrollToRect(rect); 481 } 482 } 483 } 484 485 pub fn clearGrid(self: *Scroll) void { 486 self.scroll.clearGrid(); 487 } 488 489 pub fn getGrid(self: Scroll) ?Grid { 490 return self.scroll.getGrid(); 491 } 492 493 pub fn getFocus(self: *Scroll) *Focus { 494 return self.scroll.getFocus(); 495 } 496 497 pub fn getSelectedIndex(self: Scroll) ?usize { 498 const in = self.scroll.child.flow_box; 499 const child_id = in.focus.child_id orelse return null; 500 return in.indexOfFocusId(child_id); 501 } 502 503 fn inner(self: *Scroll) *FlowBox { 504 return &self.scroll.child.flow_box; 505 } 506 }; 507 }; 508 509 // an invisible widget that fills the horizontal space granted by its parent. 510 // used inside Box(horiz) with a min_size so the box reserves space for the 511 // children that follow, pushing them to the right. 512 pub const Spacer = struct { 513 focus: Focus, 514 grid: ?Grid, 515 516 pub fn init() Spacer { 517 return .{ 518 .focus = Focus.init(.container), 519 .grid = null, 520 }; 521 } 522 523 pub fn deinit(self: *Spacer, allocator: std.mem.Allocator) void { 524 self.focus.deinit(allocator); 525 if (self.grid) |*grid| { 526 grid.deinit(); 527 self.grid = null; 528 } 529 } 530 531 pub fn build(self: *Spacer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 532 _ = root_focus; 533 self.clearGrid(); 534 const width = constraint.max_size.width orelse return; 535 if (width == 0) return; 536 self.grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 537 } 538 539 pub fn input(self: *Spacer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 540 _ = self; 541 _ = allocator; 542 _ = key; 543 _ = root_focus; 544 } 545 546 pub fn clearGrid(self: *Spacer) void { 547 if (self.grid) |*grid| { 548 grid.deinit(); 549 self.grid = null; 550 } 551 } 552 553 pub fn getGrid(self: Spacer) ?Grid { 554 return self.grid; 555 } 556 557 pub fn getFocus(self: *Spacer) *Focus { 558 return &self.focus; 559 } 560 }; 561 562 // a single-child wrapper that builds the child at its natural size and 563 // positions its grid in the middle of the area granted by the parent 564 pub const Center = struct { 565 focus: Focus, 566 grid: ?Grid, 567 child: *Widget, 568 direction: Direction, 569 570 pub const Direction = enum { both, horiz, vert }; 571 572 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, direction: Direction) !Center { 573 const child = try allocator.create(Widget); 574 errdefer allocator.destroy(child); 575 child.* = child_widget; 576 return .{ 577 .focus = Focus.init(.container), 578 .grid = null, 579 .child = child, 580 .direction = direction, 581 }; 582 } 583 584 pub fn deinit(self: *Center, allocator: std.mem.Allocator) void { 585 self.focus.deinit(allocator); 586 if (self.grid) |*grid| { 587 grid.deinit(); 588 self.grid = null; 589 } 590 self.child.deinit(allocator); 591 allocator.destroy(self.child); 592 } 593 594 pub fn build(self: *Center, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 595 self.clearGrid(); 596 self.getFocus().clear(); 597 598 // build the child without forcing it to fill min_size; it sizes 599 // itself to its content within the available max. 600 try self.child.build(allocator, .{ 601 .min_size = .{ .width = null, .height = null }, 602 .max_size = constraint.max_size, 603 }, root_focus); 604 605 const child_grid = self.child.getGrid() orelse return; 606 607 // prefer max when given; otherwise grow to max(min, child) so that 608 // when only a min is set (e.g. the wasm path passes viewport rows 609 // as min_height with no max), we can still vertically center while 610 // letting taller content extend past the min. 611 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; 612 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; 613 if (width == 0 or height == 0) return; 614 615 const offset_x: usize = if (self.direction == .horiz or self.direction == .both) 616 (width -| child_grid.size.width) / 2 617 else 618 0; 619 const offset_y: usize = if (self.direction == .vert or self.direction == .both) 620 (height -| child_grid.size.height) / 2 621 else 622 0; 623 624 var grid = try Grid.init(allocator, .{ .width = width, .height = height }); 625 errdefer grid.deinit(); 626 try grid.drawGrid(child_grid, offset_x, offset_y); 627 try self.getFocus().addChild(allocator, self.child.getFocus(), child_grid.size, offset_x, offset_y); 628 self.getFocus().child_id = self.child.getFocus().id; 629 630 self.grid = grid; 631 } 632 633 pub fn input(self: *Center, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 634 try self.child.input(allocator, key, root_focus); 635 } 636 637 pub fn clearGrid(self: *Center) void { 638 if (self.grid) |*grid| { 639 grid.deinit(); 640 self.grid = null; 641 } 642 self.child.clearGrid(); 643 } 644 645 pub fn getGrid(self: Center) ?Grid { 646 return self.grid; 647 } 648 649 pub fn getFocus(self: *Center) *Focus { 650 return &self.focus; 651 } 652 }; 653 654 // renders truecolor ANSI art 655 pub const AnsiArt = struct { 656 focus: Focus, 657 grid: ?Grid, 658 content: []const u8, 659 660 pub fn init(content: []const u8) AnsiArt { 661 return .{ 662 .focus = Focus.init(.container), 663 .grid = null, 664 .content = content, 665 }; 666 } 667 668 pub fn deinit(self: *AnsiArt, allocator: std.mem.Allocator) void { 669 self.focus.deinit(allocator); 670 if (self.grid) |*grid| { 671 grid.deinit(); 672 self.grid = null; 673 } 674 } 675 676 pub fn build(self: *AnsiArt, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 677 _ = root_focus; 678 self.clearGrid(); 679 680 // parse into rows of cells, then copy into a rectangular grid 681 var rows: std.ArrayList(std.ArrayList(Grid.Cell)) = .empty; 682 defer { 683 for (rows.items) |*row| row.deinit(allocator); 684 rows.deinit(allocator); 685 } 686 var row: std.ArrayList(Grid.Cell) = .empty; 687 errdefer row.deinit(allocator); 688 689 var style: Grid.Style = .{}; 690 var width: usize = 0; 691 const content = self.content; 692 var i: usize = 0; 693 while (i < content.len) { 694 const byte = content[i]; 695 if (byte == '\n') { 696 width = @max(width, row.items.len); 697 try rows.append(allocator, row); 698 row = .empty; 699 i += 1; 700 } else if (byte == 0x1B and i + 1 < content.len and content[i + 1] == '[') { 701 // CSI: scan to the final byte (0x40..0x7E); apply it if it's 'm' 702 var j = i + 2; 703 while (j < content.len and !(content[j] >= 0x40 and content[j] <= 0x7E)) j += 1; 704 if (j >= content.len) { 705 i = content.len; // malformed trailing escape; stop 706 } else { 707 if (content[j] == 'm') applySgr(&style, content[i + 2 .. j]); 708 i = j + 1; 709 } 710 } else { 711 const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; 712 const end = @min(content.len, i + len); 713 const rune = content[i..end]; 714 const transparent = end - i == 1 and rune[0] == ' ' and 715 style.fg == null and style.bg == null and !style.inverted; 716 try row.append(allocator, .{ 717 .rune = if (transparent) null else rune, 718 .style = style, 719 }); 720 i = end; 721 } 722 } 723 // a trailing row with content but no closing newline 724 if (row.items.len > 0) { 725 width = @max(width, row.items.len); 726 try rows.append(allocator, row); 727 } else { 728 row.deinit(allocator); 729 } 730 row = .empty; // ownership moved into rows (or freed); keep errdefer safe 731 732 const height = rows.items.len; 733 if (width == 0 or height == 0) return; 734 735 const clamped_w = @min(width, constraint.max_size.width orelse width); 736 const clamped_h = @min(height, constraint.max_size.height orelse height); 737 738 var grid = try Grid.init(allocator, .{ .width = clamped_w, .height = clamped_h }); 739 errdefer grid.deinit(); 740 for (rows.items[0..clamped_h], 0..) |r, y| { 741 const n = @min(r.items.len, clamped_w); 742 for (r.items[0..n], 0..) |cell, x| { 743 grid.cells.items[try grid.cells.at(.{ y, x })] = cell; 744 } 745 } 746 self.grid = grid; 747 } 748 749 pub fn input(self: *AnsiArt, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 750 _ = self; 751 _ = allocator; 752 _ = key; 753 _ = root_focus; 754 } 755 756 pub fn clearGrid(self: *AnsiArt) void { 757 if (self.grid) |*grid| { 758 grid.deinit(); 759 self.grid = null; 760 } 761 } 762 763 pub fn getGrid(self: AnsiArt) ?Grid { 764 return self.grid; 765 } 766 767 pub fn getFocus(self: *AnsiArt) *Focus { 768 return &self.focus; 769 } 770 771 fn applySgr(style: *Grid.Style, params: []const u8) void { 772 var nums: [16]u32 = undefined; 773 var n: usize = 0; 774 var it = std.mem.splitScalar(u8, params, ';'); 775 while (it.next()) |tok| { 776 if (n >= nums.len) break; 777 // an empty parameter (e.g. bare "\x1b[m") means 0 778 nums[n] = std.fmt.parseInt(u32, tok, 10) catch 0; 779 n += 1; 780 } 781 if (n == 0) { 782 style.* = .{}; 783 return; 784 } 785 var i: usize = 0; 786 while (i < n) : (i += 1) { 787 switch (nums[i]) { 788 0 => style.* = .{}, 789 7 => style.inverted = true, 790 27 => style.inverted = false, 791 39 => style.fg = null, 792 49 => style.bg = null, 793 38, 48 => { 794 // truecolor form: 38;2;r;g;b — anything else is ignored 795 if (i + 4 < n and nums[i + 1] == 2) { 796 const c = Grid.Color{ 797 .r = @truncate(nums[i + 2]), 798 .g = @truncate(nums[i + 3]), 799 .b = @truncate(nums[i + 4]), 800 }; 801 if (nums[i] == 38) style.fg = c else style.bg = c; 802 i += 4; 803 } 804 }, 805 else => {}, 806 } 807 } 808 } 809 }; 810 811 // a full-screen wrapper that renders ANSI art behind whatever page it wraps 812 pub const AnsiBackground = struct { 813 grid: ?Grid, 814 child: *Widget, 815 art: AnsiArt, 816 817 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, art_content: []const u8) !AnsiBackground { 818 var cw = child_widget; 819 const child = allocator.create(Widget) catch |e| { 820 cw.deinit(allocator); 821 return e; 822 }; 823 child.* = cw; 824 return .{ .grid = null, .child = child, .art = AnsiArt.init(art_content) }; 825 } 826 827 pub fn deinit(self: *AnsiBackground, allocator: std.mem.Allocator) void { 828 if (self.grid) |*grid| grid.deinit(); 829 self.art.deinit(allocator); 830 self.child.deinit(allocator); 831 allocator.destroy(self.child); 832 } 833 834 pub fn build(self: *AnsiBackground, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 835 self.clearGrid(); 836 837 // make the wrapped page fill the whole available area 838 try self.child.build(allocator, .{ 839 .min_size = .{ 840 .width = constraint.max_size.width orelse constraint.min_size.width, 841 .height = constraint.max_size.height orelse constraint.min_size.height, 842 }, 843 .max_size = constraint.max_size, 844 }, root_focus); 845 846 if (self.child.getGrid()) |fg| { 847 self.grid = try artBehind(allocator, fg, &self.art, .top_right, root_focus); 848 } 849 } 850 851 pub fn input(self: *AnsiBackground, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 852 try self.child.input(allocator, key, root_focus); 853 } 854 855 pub fn clearGrid(self: *AnsiBackground) void { 856 if (self.grid) |*grid| { 857 grid.deinit(); 858 self.grid = null; 859 } 860 self.child.clearGrid(); 861 self.art.clearGrid(); 862 } 863 864 pub fn getGrid(self: AnsiBackground) ?Grid { 865 return self.grid orelse self.child.getGrid(); 866 } 867 868 pub fn getFocus(self: *AnsiBackground) *Focus { 869 return self.child.getFocus(); 870 } 871 872 // which top corner the art hugs. 873 const ArtAnchor = enum { top_left, top_right }; 874 875 // a foreground cell counts as blank if it's empty or a bare space without styling 876 fn cellIsBlank(cell: Grid.Cell) bool { 877 if (cell.rune) |rune| { 878 return std.mem.eql(u8, rune, " ") and 879 cell.style.fg == null and cell.style.bg == null and !cell.style.inverted; 880 } 881 return true; 882 } 883 884 // a single representative color for an art cell 885 fn artCellColor(style: Grid.Style) ?Grid.Color { 886 const f = style.fg orelse return style.bg; 887 const b = style.bg orelse return f; 888 return .{ 889 .r = @intCast((@as(u16, f.r) + b.r) / 2), 890 .g = @intCast((@as(u16, f.g) + b.g) / 2), 891 .b = @intCast((@as(u16, f.b) + b.b) / 2), 892 }; 893 } 894 895 // behind a visible glyph the art color is dimmed to this fraction of its 896 // brightness so light terminal text stays legible on top of it 897 const glyph_bg_brightness = 45; // percent 898 899 // a glyph sitting over the art also gets its foreground pinned to this color 900 // (unless it set its own), so the text stays legible 901 const glyph_fg_over_art = Grid.Color{ .r = 235, .g = 235, .b = 235 }; 902 903 fn dimColor(color: ?Grid.Color) ?Grid.Color { 904 const c = color orelse return null; 905 return .{ 906 .r = @intCast(@as(u16, c.r) * glyph_bg_brightness / 100), 907 .g = @intCast(@as(u16, c.g) * glyph_bg_brightness / 100), 908 .b = @intCast(@as(u16, c.b) * glyph_bg_brightness / 100), 909 }; 910 } 911 912 // terminals that don't support truecolor misparse a "38;2;r;g;b"/"48;2;…" 913 // SGR as a list of plain SGR codes, so any channel value in 1..9 turns on 914 // a text attribute (5/6 = blink, 7 = inverse, 8 = conceal, …). 915 // snap such channels clear of that range so the backdrop doesn't blink or 916 // hide text there; the ≤9/255 shift is imperceptible on terminals that 917 // actually render truecolor. 918 fn sgrSafe(color: ?Grid.Color) ?Grid.Color { 919 const c = color orelse return null; 920 const snap = struct { 921 fn f(v: u8) u8 { 922 return if (v >= 1 and v <= 9) 10 else v; 923 } 924 }.f; 925 return .{ .r = snap(c.r), .g = snap(c.g), .b = snap(c.b) }; 926 } 927 928 // composites ANSI art behind a foreground grid 929 fn artBehind(allocator: std.mem.Allocator, foreground: Grid, art: *AnsiArt, anchor: ArtAnchor, root_focus: *Focus) !Grid { 930 art.clearGrid(); 931 try art.build(allocator, .{ 932 .min_size = .{ .width = null, .height = null }, 933 .max_size = .{ .width = foreground.size.width, .height = foreground.size.height }, 934 }, root_focus); 935 936 var out = try Grid.initFromGrid(allocator, foreground, foreground.size, 0, 0); 937 errdefer out.deinit(); 938 939 if (art.getGrid()) |art_grid| { 940 const anchor_x = switch (anchor) { 941 .top_left => 0, 942 .top_right => foreground.size.width -| art_grid.size.width, 943 }; 944 for (0..art_grid.size.height) |y| { 945 for (0..art_grid.size.width) |x| { 946 const src = art_grid.cells.items[try art_grid.cells.at(.{ y, x })]; 947 if (src.rune == null) continue; 948 const idx = out.cells.at(.{ y, anchor_x + x }) catch continue; 949 const dst = &out.cells.items[idx]; 950 if (cellIsBlank(dst.*)) { 951 dst.* = src; 952 dst.style.fg = sgrSafe(dst.style.fg); 953 dst.style.bg = sgrSafe(dst.style.bg); 954 } else if (dst.style.bg == null) { 955 dst.style.bg = sgrSafe(dimColor(artCellColor(src.style))); 956 if (dst.style.fg == null) dst.style.fg = glyph_fg_over_art; 957 } 958 } 959 } 960 } 961 return out; 962 } 963 }; 964