╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │157 arena: *std.heap.ArenaAllocator, │ │158 haxy_moment: ?evt.AdminDB.HashMap(.read_only) = null, // db cursor (null on the wasm side) │ │159 pending: std.ArrayList(Action) = .empty, // actions queued by widgets this frame │ │160 + nav_back: bool = false, // set by input (escape) to request the native TUI pop a page; see Nav│ │161 │ │162 const Self = @This(); │ │163 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │240 } │ │241 } │ │242 } │ │243 + │ │244 + fn navigate(self: *Session, route: RoutablePage) !void { │ │245 + const aa = self.arena.allocator(); │ │246 + self.data.current_page = switch (route) { │ │247 + .user => |name| .{ .user = try aa.dupe(u8, name) }, │ │248 + .user_settings => |name| .{ .user_settings = try aa.dupe(u8, name) }, │ │249 + .user_auth => |name| .{ .user_auth = try aa.dupe(u8, name) }, │ │250 + else => route, │ │251 + }; │ │252 + } │ │253 }; │ │254 │ │255 pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const Page, session: *Session) !void {│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────╮ │147 - var root = try initRoot(allocator, page, session); │ │148 - defer root.deinit(allocator); │ │256 + var nav = try Nav.init(allocator, page, session); │ │257 + defer nav.deinit(allocator); │ │258 │ │259 var terminal = try term.Terminal.init(io, allocator);│ │260 defer terminal.deinit(io); │ ╰──────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │264 defer last_grid.deinit(); │ │265 │ │266 while (!terminal.shouldQuit()) { │ │158 - const grid_changed = try terminal.render(&root, &last_grid, &last_size); │ │267 + const grid_changed = try terminal.render(&nav.root, &last_grid, &last_size);│ │268 │ │269 // process any inputs. │ │270 // │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────╮ │278 // there may be an animation that requires more looping.│ │279 var blocking = !grid_changed; │ │280 while (try terminal.readKey(io, blocking)) |key| { │ │172 - try inputKey(allocator, &root, key, &terminal); │ │281 + try inputKey(allocator, &nav.root, key, session); │ │282 blocking = false; │ │283 } │ │284 │ ╰─────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │285 // local run has no repo to persist to, so just apply in-memory │ │286 session.applyPending(); │ │287 │ │179 - try root.build(allocator, .{ │ │288 + // reconcile navigation: forward to a new page, or back on escape (and │ │289 + // quit when there's nothing left to go back to). │ │290 + if (!try nav.sync(allocator, session)) terminal.requestQuit(); │ │291 + │ │292 + try nav.root.build(allocator, .{ │ │293 .min_size = .{ .width = null, .height = null }, │ │294 .max_size = .{ .width = last_size.width, .height = last_size.height },│ │182 - }, root.getFocus()); │ │295 + }, nav.root.getFocus()); │ │296 } │ │297 } │ │298 │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │186 -pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: inp.Key, terminal: anytype) !void {│ │299 +pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: inp.Key, session: *Session) !void {│ │300 switch (key) { │ │188 - .escape => terminal.requestQuit(), │ │301 + // request a navigation pop; the host's Nav.sync goes back a page, or │ │302 + // quits when there's no history left. │ │303 + .escape => session.nav_back = true, │ │304 + .enter => { │ │305 + const root_focus = root.getFocus(); │ │306 + if (root_focus.grandchild_id) |gid| { │ │307 + // follow a cross-page link │ │308 + if (crossPageLink(root_focus, gid, session.data.current_page)) |route| { │ │309 + return session.navigate(route); │ │310 + } │ │311 + } │ │312 + try root.input(allocator, key, root_focus); │ │313 + }, │ │314 .mouse => |mouse| { │ │315 if (mouse.action == .press and mouse.action.press == .left) { │ │316 const root_focus = root.getFocus(); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────╮ │317 + var clicked: ?usize = null; │ │318 var iter = root_focus.children.iterator();│ │319 while (iter.next()) |entry| { │ │320 const child = entry.value_ptr.*; │ ╰───────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │323 if (mouse.x >= r.x and mouse.y >= r.y and │ │324 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) │ │325 { │ │200 - try root_focus.setFocus(entry.key_ptr.*); │ │326 + clicked = entry.key_ptr.*; │ │327 break; │ │328 } │ │329 + } │ │330 + if (clicked) |focus_id| { │ │331 + // follow a cross-page link │ │332 + if (crossPageLink(root_focus, focus_id, session.data.current_page)) |route| {│ │333 + return session.navigate(route); │ │334 + } │ │335 + try root_focus.setFocus(focus_id); │ │336 } │ │337 // forward the press into the widget tree so buttons (and any │ │338 // future click-aware widgets) can react. widgets that don't │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │345 else => try root.input(allocator, key, root.getFocus()), │ │346 } │ │347 } │ │348 + │ │349 +// if the focus target at focus_id is an `a:` link to a different top-level page │ │350 +// than `current`, return its route; otherwise null. lets a host turn a click / │ │351 +// enter on such a link into a navigation rather than a focus change. │ │352 +pub fn crossPageLink(root_focus: *Focus, focus_id: usize, current: RoutablePage) ?RoutablePage {│ │353 + const child = root_focus.children.get(focus_id) orelse return null; │ │354 + const custom = switch (child.focus.kind) { │ │355 + .custom => |c| c, │ │356 + else => return null, │ │357 + }; │ │358 + const a_prefix = "a:"; │ │359 + if (!std.mem.startsWith(u8, custom, a_prefix)) return null; │ │360 + const route = RoutablePage.fromUrl(custom[a_prefix.len..]) orelse return null; │ │361 + if (route.parent() == current.parent()) return null; │ │362 + return route; │ │363 +} │ │364 + │ │365 +// native-TUI navigation │ │366 +pub const Nav = struct { │ │367 + root: Widget, │ │368 + route: RoutablePage, │ │369 + history: std.ArrayList(Entry), │ │370 + │ │371 + const Entry = struct { root: Widget, route: RoutablePage }; │ │372 + │ │373 + // cap on retained back-history so chain can't grow memory without bound │ │374 + const max_history: usize = 16; │ │375 + │ │376 + pub fn init(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Nav { │ │377 + return .{ │ │378 + .root = try initRoot(allocator, page, session), │ │379 + .route = session.data.current_page, │ │380 + .history = .empty, │ │381 + }; │ │382 + } │ │383 + │ │384 + pub fn deinit(self: *Nav, allocator: std.mem.Allocator) void { │ │385 + self.root.deinit(allocator); │ │386 + for (self.history.items) |*entry| entry.root.deinit(allocator); │ │387 + self.history.deinit(allocator); │ │388 + } │ │389 │ │390 + // reconcile the displayed root with the session's navigation state. returns │ │391 + // false when escape was pressed with no history left, so the caller can quit. │ │392 + // a forward nav (current_page moved to a different parent page) pushes the │ │393 + // current root and builds the new page; a back request pops and restores the │ │394 + // previous root. the new Page is allocated in the session arena so the widget │ │395 + // tree's data pointers stay valid for the life of the session. │ │396 + pub fn sync(self: *Nav, allocator: std.mem.Allocator, session: *Session) !bool { │ │397 + if (session.nav_back) { │ │398 + session.nav_back = false; │ │399 + const entry = self.history.pop() orelse return false; │ │400 + self.root.deinit(allocator); │ │401 + self.root = entry.root; │ │402 + self.route = entry.route; │ │403 + session.data.current_page = entry.route; │ │404 + return true; │ │405 + } │ │406 + │ │407 + if (session.data.current_page.parent() != self.route.parent()) { │ │408 + const moment = session.haxy_moment orelse return true; │ │409 + const page = try session.arena.allocator().create(Page); │ │410 + page.* = try Page.init(session.arena, moment, session.data.current_page); │ │411 + const new_root = try initRoot(allocator, page, session); │ │412 + try self.history.append(allocator, .{ .root = self.root, .route = self.route }); │ │413 + // drop the oldest entry (freeing its widget tree) once we're over cap │ │414 + if (self.history.items.len > max_history) { │ │415 + var oldest = self.history.orderedRemove(0); │ │416 + oldest.root.deinit(allocator); │ │417 + } │ │418 + self.root = new_root; │ │419 + self.route = session.data.current_page; │ │420 + } │ │421 + return true; │ │422 + } │ │423 +}; │ │424 + │ │425 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { │ │426 const page_widget: Widget = switch (page.*) { │ │427 .home => |*p| .{ .home = try .init(allocator, p, session) }, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯