╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │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) }, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────╮ │428 + .user => |*p| .{ .user = try .init(allocator, p, session) },│ │429 }; │ │430 │ │431 const demon_art = @embedFile("embed/demon.ans"); │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │434 errdefer root.deinit(allocator); │ │435 │ │436 try root.build(allocator, .{ │ │227 - .min_size = .{ .width = null, .height = null },│ │437 + .min_size = .{ .width = null, .height = 40 }, │ │438 .max_size = .{ .width = 80, .height = null }, │ │439 }, root.getFocus()); │ │440 if (root.getFocus().child_id) |child_id| { │ ╰────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────╮ │452 scroll: wgt.Scroll(Widget), │ │453 stack: wgt.Stack(Widget), │ │454 flow_box: FlowBox, │ │455 + flow_box_scroll: FlowBox.Scroll,│ │456 spacer: Spacer, │ │457 center: Center, │ │458 ansi_art: AnsiArt, │ ╰─────────────────────────────────────────╯ ╭────────────────────────────────────────────╮ │459 background: AnsiBackground, │ │460 home: Home.View, │ │461 + user: User.View, │ │462 title: Title.View, │ │463 home_header: Home.Header.View, │ │464 + user_header: User.Header.View, │ │465 home_users: Home.Users.View, │ │466 home_repos: Home.Repos.View, │ │467 auth_tab: Home.Header.AuthTab.View,│ ╰────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │255 - settings: Home.Settings.View, │ │256 - auth: Home.Auth.View, │ │468 + home_settings: Home.Settings.View, │ │469 + home_auth: Home.Auth.View, │ │470 │ │471 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void {│ │472 switch (self.*) { │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯