╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
3110 const parsed = RoutablePage.Array(evt.User.name_max_len).from(name) orelse return error.RouteTooLong; 3111 const url = try (RoutablePage{ .user_repos = .{ .name = parsed } }).toUrl(page_arena); 3112 return page_arena.allocator().print("a:{s}", .{url}); 3113 } 3114 3115 // a focusable " author " box showing `author`, linking to their user page 3116 // when it is a known user 3117 pub fn authorBox(allocator: std.mem.Allocator, page_arena: *std.heap.ArenaAllocator, author: Author) !wgt.TextBox { 3118 const text = switch (author) { 3119 .unknown => "", 3120 .email, .user_name => |t| t, 3121 }; 3122 var tb = try wgt.TextBox.init(allocator, text, .{ .border = .single, .round_corners = true, .wrap_kind = .none, .top_label = .{ .text = " author " } }); 3123 errdefer tb.deinit(allocator); 3124 tb.getFocus().mode = .all; 3125 switch (author) { 3126 .user_name => |name| tb.getFocus().kind = .{ .custom = try userLink(page_arena, name) }, 3127 else => {}, 3128 } 3129 return tb; 3130 } 3131 3132 // native-TUI navigation 3133 pub const Nav = struct { 3134 root: Widget, 3135 // backs the current page (its Page data and the page-scoped strings its 3136 // widgets build). owned here, swapped on each navigation. session.page_arena 3137 // tracks whichever of these is current so widgets allocate into it. 3138 arena: *std.heap.ArenaAllocator, 3139 // the login the current root was built for 3140 user_id: ?[evt.event_id_size]u8, 3141 history: std.ArrayList(Entry), 3142 3143 // each retained page keeps its own arena, freed when the page leaves the 3144 // stack (popped on back, evicted at cap, or on deinit). this is what keeps a 3145 // long-lived session from accumulating every page it ever visited. 3146 const Entry = struct { root: Widget, route: RoutablePage, arena: *std.heap.ArenaAllocator, user_id: ?[evt.event_id_size]u8 }; 3147 3148 // cap on retained back-history so the chain can't grow memory without bound 3149 const max_history: usize = 16; 3150 3151 pub fn init(allocator: std.mem.Allocator, session: *Session) !Nav { 3152 session.back = .unavailable; 3153 const arena = try allocator.create(std.heap.ArenaAllocator); 3154 arena.* = std.heap.ArenaAllocator.init(allocator); 3155 errdefer { 3156 arena.deinit(); 3157 allocator.destroy(arena); 3158 } 3159 3160 return .{ 3161 .root = try initCurrentRoot(allocator, arena, session), 3162 .arena = arena, 3163 .user_id = session.userId(), 3164 .history = .empty, 3165 }; 3166 } 3167 3168 pub fn deinit(self: *Nav, allocator: std.mem.Allocator) void { 3169 self.root.deinit(allocator); 3170 freeArena(allocator, self.arena); 3171 for (self.history.items) |*entry| { 3172 entry.root.deinit(allocator); 3173 freeArena(allocator, entry.arena); 3174 } 3175 self.history.deinit(allocator); 3176 } 3177 3178 // rebuild the current page in place from a current moment 3179 fn rebuild(self: *Nav, allocator: std.mem.Allocator, session: *Session) !void { 3180 if (session.haxy_moment == null and session.local == null) return; 3181 const arena = try allocator.create(std.heap.ArenaAllocator); 3182 arena.* = std.heap.ArenaAllocator.init(allocator); 3183 errdefer freeArena(allocator, arena); 3184 3185 const new_root = try initCurrentRoot(allocator, arena, session); 3186 3187 self.root.deinit(allocator); 3188 freeArena(allocator, self.arena); 3189 self.root = new_root; 3190 self.arena = arena; 3191 self.user_id = session.userId(); 3192 } 3193 3194 // build the current page's root in `arena`, or the not-found page's when 3195 // the route names nothing 3196 fn initCurrentRoot(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator, session: *Session) !Widget { 3197 session.page_arena = arena; 3198 const page = try arena.allocator().create(Page); 3199 page.* = Page.init(arena, session, session.data.current_page) catch |err| switch (err) { 3200 error.NotFound => blk: { 3201 session.data.current_page = .not_found; 3202 break :blk .not_found; 3203 }, 3204 else => |e| return e, 3205 }; 3206 return initRoot(allocator, page, session); 3207 } 3208 3209 fn freeArena(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator) void { 3210 arena.deinit(); 3211 allocator.destroy(arena); 3212 } 3213 3214 // reconcile the displayed root with the session's navigation state. a 3215 // forward nav (current_page moved to a different parent page) pushes the 3216 // current root and builds the new page in a fresh arena; a back request 3217 // frees the current page and restores the previous one. when escape is 3218 // pressed with no history left we switch to the quit tab instead of quitting. 3219 // returns whether the root was replaced. 3220 pub fn sync(self: *Nav, allocator: std.mem.Allocator, session: *Session) !bool { 3221 // refresh: rebuild the current page in place from a current moment 3222 if (session.refresh_requested) { 3223 session.refresh_requested = false; 3224 try self.rebuild(allocator, session); 3225 return true; 3226 } 3227 3228 if (session.back == .requested) { 3229 if (self.history.pop()) |entry| { 3230 session.back = if (self.history.items.len == 0) .unavailable else .available; 3231 self.root.deinit(allocator); 3232 freeArena(allocator, self.arena); 3233 self.root = entry.root; 3234 self.arena = entry.arena; 3235 self.user_id = entry.user_id; 3236 session.page_arena = entry.arena; 3237 session.data.current_page = entry.route; 3238 // a page built under another login is rebuilt rather than restored 3239 if (!std.meta.eql(entry.user_id, session.userId())) { 3240 try self.rebuild(allocator, session); 3241 return true; 3242 } 3243 chooseAnsiArtForNavigation(session); 3244 return true; 3245 } 3246 session.back = .unavailable; 3247 // nothing to go back to; switch to the quit confirmation 3248 const root_focus = self.root.getFocus(); 3249 var iter = root_focus.children.iterator(); 3250 while (iter.next()) |entry| { 3251 switch (entry.value_ptr.focus.kind) { 3252 .custom => |custom| if (std.mem.eql(u8, custom, Quit.tab_kind)) { 3253 root_focus.setFocus(entry.key_ptr.*); 3254 // a Stack only builds its selected child, so the quit 3255 // button isn't in the focus tree until a build runs with 3256 // the quit tab selected. build once, then send a 3257 // synthetic arrow_down to drop focus from the tab onto 3258 // the button so it's ready to confirm. 3259 try self.root.build(allocator, .{ 3260 .min_size = .{ .width = null, .height = 40 }, 3261 .max_size = .{ .width = 80, .height = null }, 3262 }, root_focus); 3263 try self.root.input(allocator, .arrow_down, root_focus); 3264 break; 3265 }, 3266 else => {}, 3267 } 3268 } 3269 return false; 3270 } 3271 3272 // forward navigation: navigate() set next_page to the page to move to (a 3273 // cross-page link or tab change crossing pages) 3274 if (session.next_page) |route| { 3275 session.next_page = null; 3276 if (session.haxy_moment == null and session.local == null) return false; 3277 const previous_route = session.data.current_page; 3278 // the page we navigated to becomes the current page 3279 session.data.current_page = route; 3280 session.back = .available; 3281 3282 const arena = try allocator.create(std.heap.ArenaAllocator); 3283 arena.* = std.heap.ArenaAllocator.init(allocator); 3284 errdefer freeArena(allocator, arena); 3285 3286 const new_root = try initCurrentRoot(allocator, arena, session); 3287 3288 try self.history.append(allocator, .{ .root = self.root, .route = previous_route, .arena = self.arena, .user_id = self.user_id }); 3289 // drop the oldest entry (freeing its widget tree and arena) once over cap 3290 if (self.history.items.len > max_history) { 3291 var oldest = self.history.orderedRemove(0); 3292 oldest.root.deinit(allocator); 3293 freeArena(allocator, oldest.arena); 3294 } 3295 self.root = new_root; 3296 self.arena = arena; 3297 self.user_id = session.userId(); 3298 return true; 3299 } 3300 return false; 3301 } 3302 }; 3303 3304 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { 3305 const page_widget: Widget = switch (page.*) { 3306 .home => |*p| .{ .home = try .init(allocator, p, session) }, 3307 .user => |*p| .{ .user = try .init(allocator, p, session) }, 3308 .repo => |*p| .{ .repo = try .init(allocator, p, session) }, 3309 .fork => |*p| .{ .fork = try .init(allocator, p, session) }, 3310 .not_found => .{ .not_found = try .init(allocator, session) }, 3311 }; 3312 3313 chooseAnsiArtForNavigation(session); 3314 3315 // on the TUI/SSH, the page sits above a one-row footer showing its web 3316 // url, when there's a web UI to point at 3317 var root = if (session.is_terminal) blk: { 3318 var box = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .vert }); 3319 errdefer box.deinit(allocator); 3320 const bg_id = bg_blk: { 3321 var background = try widget.AnsiBackground.init(allocator, page_widget, session); 3322 errdefer background.deinit(allocator); 3323 const id = background.getFocus().id; 3324 try box.children.put(allocator, id, .{ .widget = .{ .background = background }, .rect = null, .min_size = null }); 3325 break :bg_blk id; 3326 }; 3327 if (session.web_port != null) { 3328 var footer = try widget.Footer.init(allocator, session); 3329 errdefer footer.deinit(allocator); 3330 try box.children.put(allocator, footer.getFocus().id, .{ .widget = .{ .footer = footer }, .rect = null, .min_size = .{ .width = null, .height = 1 } }); 3331 } 3332 box.getFocus().child_id = bg_id; 3333 break :blk Widget{ .box = box }; 3334 } else Widget{ .background = try widget.AnsiBackground.init(allocator, page_widget, session) }; 3335 errdefer root.deinit(allocator); 3336 3337 // input-owning views build their TextInputs in init, so reset their 3338 // registrations here 3339 session.text_inputs.clearRetainingCapacity(); 3340 3341 try root.build(allocator, .{ 3342 .min_size = .{ .width = null, .height = 40 }, 3343 .max_size = .{ .width = 100, .height = null }, 3344 }, root.getFocus()); 3345 3346 return root; 3347 } 3348 3349 // native and server-side sessions choose from the embedded collection. WASM 3350 // keeps the art serialized by the server instead. 3351 fn chooseAnsiArtForNavigation(session: *Session) void { 3352 if (comptime ansi_art.art.len == 0) return; 3353 if (session.data.current_page == .not_found) { 3354 session.data.ansi_art = ansi_art.not_found; 3355 return; 3356 } 3357 const io = session.io orelse return; 3358 var random: usize = undefined; 3359 io.random(std.mem.asBytes(&random)); 3360 session.data.ansi_art = ansi_art.art[random % ansi_art.art.len]; 3361 } 3362