╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │11 const evt = @import("./event.zig"); │ │12 │ │13 pub const Home = @import("./ui/Home.zig"); │ │14 +pub const User = @import("./ui/User.zig"); │ │15 pub const Title = @import("./ui/Title.zig"); │ │16 │ │16 -pub const Page = union(enum) { │ │17 +pub const PageKind = enum { │ │18 + home, │ │19 + user, │ │20 +}; │ │21 + │ │22 +pub const Page = union(PageKind) { │ │23 home: Home, │ │24 + user: User, │ │25 + │ │26 + pub fn init(arena: *std.heap.ArenaAllocator, haxy_moment: evt.AdminDB.HashMap(.read_only), route: RoutablePage) !Page {│ │27 + return switch (route.parent()) { │ │28 + .home => .{ .home = try Home.init(arena, haxy_moment) }, │ │29 + .user => switch (route) { │ │30 + .user, .user_settings, .user_auth => |name| .{ .user = try User.init(arena, haxy_moment, name) }, │ │31 + else => return error.UnexpectedRoute, │ │32 + }, │ │33 + }; │ │34 + } │ │35 }; │ │36 │ │37 // what the server hands to the client (and what main_wasm parses on _start). │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │41 session: Session.Data = .{}, │ │42 }; │ │43 │ │27 -// a top-level "page" the user can navigate to │ │28 -pub const RoutablePage = enum { │ │44 +// a top-level "page" the user can navigate to. the user_* variants are the │ │45 +// tabs of a user page; they each carry the user's name and all map to the │ │46 +// .user parent page, so switching between them stays on that page (and just│ │47 +// updates the url) rather than navigating away. │ │48 +pub const RoutablePage = union(enum) { │ │49 home_users, │ │50 home_repos, │ │31 - settings, │ │32 - auth, │ │51 + home_settings, │ │52 + home_auth, │ │53 + user: []const u8, │ │54 + user_settings: []const u8, │ │55 + user_auth: []const u8, │ │56 │ │57 pub const default: RoutablePage = .home_users; │ │58 │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │36 - pub fn url(self: RoutablePage) []const u8 { │ │59 + const user_segment = "/user/"; │ │60 + │ │61 + pub fn url(comptime self: RoutablePage) []const u8 {│ │62 return switch (self) { │ │63 .home_users => "/users", │ │64 .home_repos => "/repos", │ ╰────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │40 - .settings => "/settings", │ │41 - .auth => "/auth", │ │65 + .home_settings => "/settings", │ │66 + .home_auth => "/auth", │ │67 + // user routes carry a runtime name; build them with urlAlloc. │ │68 + .user, .user_settings, .user_auth => @compileError("user routes are dynamic; use urlAlloc"), │ │69 + }; │ │70 + } │ │71 + │ │72 + pub fn urlAlloc(self: RoutablePage, arena: *std.heap.ArenaAllocator) ![]const u8 { │ │73 + return switch (self) { │ │74 + .user => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}", .{name}), │ │75 + .user_settings => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/settings", .{name}),│ │76 + .user_auth => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/auth", .{name}), │ │77 + inline else => |_, tag| url(tag), │ │78 + }; │ │79 + } │ │80 + │ │81 + pub fn parent(self: RoutablePage) PageKind { │ │82 + return switch (self) { │ │83 + .home_users, .home_repos, .home_settings, .home_auth => .home, │ │84 + .user, .user_settings, .user_auth => .user, │ │85 }; │ │86 } │ │87 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 89 if (std.mem.eql(u8, path, "/")) return default; │ │ 90 if (std.mem.eql(u8, path, "/users")) return .home_users; │ │ 91 if (std.mem.eql(u8, path, "/repos")) return .home_repos; │ │ 49 - if (std.mem.eql(u8, path, "/settings")) return .settings; │ │ 50 - if (std.mem.eql(u8, path, "/auth")) return .auth; │ │ 92 + if (std.mem.eql(u8, path, "/settings")) return .home_settings; │ │ 93 + if (std.mem.eql(u8, path, "/auth")) return .home_auth; │ │ 94 + if (std.mem.startsWith(u8, path, user_segment)) { │ │ 95 + const rest = path[user_segment.len..]; │ │ 96 + const slash = std.mem.indexOfScalar(u8, rest, '/'); │ │ 97 + const name = if (slash) |s| rest[0..s] else rest; │ │ 98 + if (name.len == 0) return null; │ │ 99 + if (slash) |s| { │ │100 + const sub = rest[s + 1 ..]; │ │101 + if (std.mem.eql(u8, sub, "settings")) return .{ .user_settings = name }; │ │102 + if (std.mem.eql(u8, sub, "auth")) return .{ .user_auth = name }; │ │103 + return null; // unknown user sub-path │ │104 + } │ │105 + return .{ .user = name }; │ │106 + } │ │107 return null; │ │108 + } │ │109 + │ │110 + pub fn eql(a: RoutablePage, b: RoutablePage) bool { │ │111 + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; │ │112 + return switch (a) { │ │113 + .user => |a_name| std.mem.eql(u8, a_name, b.user), │ │114 + .user_settings => |a_name| std.mem.eql(u8, a_name, b.user_settings), │ │115 + .user_auth => |a_name| std.mem.eql(u8, a_name, b.user_auth), │ │116 + else => true, │ │117 + }; │ │118 + } │ │119 + │ │120 + // serialize as { "kind": <tag>, "name"?: <user name> }. the default │ │121 + // union(enum) codec doesn't round-trip here: Stringify emits a void │ │122 + // variant as a bare tag string, but the parser expects an object — so the │ │123 + // server->wasm Snapshot JSON would fail to parse. │ │124 + pub fn jsonStringify(self: RoutablePage, jw: anytype) !void { │ │125 + try jw.beginObject(); │ │126 + try jw.objectField("kind"); │ │127 + try jw.write(@tagName(self)); │ │128 + switch (self) { │ │129 + .user, .user_settings, .user_auth => |name| { │ │130 + try jw.objectField("name"); │ │131 + try jw.write(name); │ │132 + }, │ │133 + else => {}, │ │134 + } │ │135 + try jw.endObject(); │ │136 + } │ │137 + │ │138 + pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !RoutablePage {│ │139 + const Helper = struct { kind: std.meta.Tag(RoutablePage), name: ?[]const u8 = null }; │ │140 + const helper = try std.json.innerParse(Helper, allocator, source, options); │ │141 + return switch (helper.kind) { │ │142 + .home_users => .home_users, │ │143 + .home_repos => .home_repos, │ │144 + .home_settings => .home_settings, │ │145 + .home_auth => .home_auth, │ │146 + .user => .{ .user = helper.name orelse return error.MissingField }, │ │147 + .user_settings => .{ .user_settings = helper.name orelse return error.MissingField }, │ │148 + .user_auth => .{ .user_auth = helper.name orelse return error.MissingField }, │ │149 + }; │ │150 } │ │151 }; │ │152 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 │ ╰─────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯