╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const rp = xit.repo; 4 const xitui = xit.xitui; 5 const term = xitui.terminal; 6 const wgt = xitui.widget; 7 const layout = xitui.layout; 8 const inp = xitui.input; 9 const Grid = xitui.grid.Grid; 10 const Focus = xitui.focus.Focus; 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 Repo = @import("./ui/Repo.zig"); 16 pub const Title = @import("./ui/Title.zig"); 17 pub const SubTitle = @import("./ui/SubTitle.zig"); 18 pub const Quit = @import("./ui/Quit.zig"); 19 20 pub const PageKind = enum { 21 home, 22 user, 23 repo, 24 }; 25 26 pub const Page = union(PageKind) { 27 home: Home, 28 user: User, 29 repo: Repo, 30 31 pub fn init(arena: *std.heap.ArenaAllocator, session: *Session, route: RoutablePage) !Page { 32 const haxy_moment = session.haxy_moment orelse return error.NoMoment; 33 return switch (route.parent()) { 34 .home => .{ .home = try Home.init(arena, haxy_moment, switch (route) { 35 .home_users => |after| after, 36 else => 0, 37 }, switch (route) { 38 .home_repos => |after| after, 39 else => 0, 40 }) }, 41 .user => switch (route) { 42 .user, .user_settings, .user_auth => |name| .{ .user = try User.init(arena, haxy_moment, name) }, 43 else => return error.UnexpectedRoute, 44 }, 45 .repo => switch (route) { 46 .repo, .repo_commits, .repo_refs, .repo_settings, .repo_auth => .{ .repo = try Repo.init(arena, session, route) }, 47 else => return error.UnexpectedRoute, 48 }, 49 }; 50 } 51 }; 52 53 // what the server hands to the client (and what main_wasm parses on _start). 54 // keeps Page free of any per-request session state. 55 pub const Snapshot = struct { 56 page: Page, 57 session: Session.Data = .{}, 58 }; 59 60 // a top-level "page" the user can navigate to. the user_* variants are the 61 // tabs of a user page; they each carry the user's name and all map to the 62 // .user parent page, so switching between them stays on that page (and just 63 // updates the url) rather than navigating away. 64 pub const RoutablePage = union(enum) { 65 home_users: usize, // 0 = first page 66 home_repos: usize, // 0 = first page 67 home_settings, 68 home_auth, 69 user: Array(evt.User.name_max_len), 70 user_settings: Array(evt.User.name_max_len), 71 user_auth: Array(evt.User.name_max_len), 72 repo: Array(repo_route_max_len), 73 repo_commits: struct { name: Array(repo_route_max_len), after: usize = 0 }, 74 repo_refs: struct { name: Array(repo_route_max_len), kind: RefKind = .branch, after: usize = 0 }, 75 repo_settings: Array(repo_route_max_len), 76 repo_auth: Array(repo_route_max_len), 77 78 pub const default: RoutablePage = .{ .home_users = 0 }; 79 80 pub const RefKind = enum { branch, tag }; 81 82 pub const RefKindOrOid = enum { 83 branch, 84 tag, 85 oid, 86 87 fn fromSeg(s: []const u8) ?RefKindOrOid { 88 return std.meta.stringToEnum(RefKindOrOid, s); 89 } 90 }; 91 92 const user_segment = "/user/"; 93 const repo_segment = "/repo/"; 94 const files_seg = "files"; 95 const commits_seg = "commits"; 96 97 // a repo route is "username/reponame", optionally followed by the files tab's 98 // "/files/<refkind>/<refvalue>/<dir>" (refkind/refvalue name the ref the tree 99 // is read at). the bare "username/reponame" (and "username/reponame/files") 100 // mean the files root of the repo's default branch. 101 pub const repo_route_max_len = 1024; 102 103 // the parts of a `.repo` files route. all slices point into the route's 104 // stored string. ref_kind is null (and ref_value/dir empty) for the bare 105 // default-branch root; dir is "" at a ref's root. 106 pub const RepoFiles = struct { 107 identity: []const u8, // "owner/name" 108 owner: []const u8, 109 name: []const u8, 110 ref_kind: ?RefKindOrOid, 111 ref_value: []const u8, 112 dir: []const u8, 113 114 // parse a `.repo` route's stored string ("owner/name" or 115 // "owner/name/files/<refkind>/<refvalue>[/<dir>]"). 116 pub fn parse(s: []const u8) ?RepoFiles { 117 const s1 = std.mem.indexOfScalar(u8, s, '/') orelse return null; 118 const owner = s[0..s1]; 119 const after = s[s1 + 1 ..]; 120 const s2 = std.mem.indexOfScalar(u8, after, '/'); 121 const name = if (s2) |i| after[0..i] else after; 122 if (owner.len == 0 or name.len == 0) return null; 123 var result: RepoFiles = .{ 124 .identity = s[0 .. s1 + 1 + name.len], 125 .owner = owner, 126 .name = name, 127 .ref_kind = null, 128 .ref_value = "", 129 .dir = "", 130 }; 131 // the tail after "owner/name/". absent (or a bare "files") = default root. 132 const tail = if (s2) |i| after[i + 1 ..] else return result; 133 if (std.mem.eql(u8, tail, files_seg)) return result; 134 if (!std.mem.startsWith(u8, tail, files_seg ++ "/")) return result; 135 // "<refkind>/<refvalue>[/<dir>]" 136 const ref_part = tail[files_seg.len + 1 ..]; 137 const k1 = std.mem.indexOfScalar(u8, ref_part, '/') orelse return result; 138 const kind = RefKindOrOid.fromSeg(ref_part[0..k1]) orelse return result; 139 const after_kind = ref_part[k1 + 1 ..]; 140 const k2 = std.mem.indexOfScalar(u8, after_kind, '/'); 141 const value = if (k2) |i| after_kind[0..i] else after_kind; 142 if (value.len == 0) return result; 143 result.ref_kind = kind; 144 result.ref_value = value; 145 result.dir = if (k2) |i| after_kind[i + 1 ..] else ""; 146 return result; 147 } 148 }; 149 150 // build a `.repo` files route for "owner/name" (identity) at `dir` ("" = the 151 // ref's root) pinned to `ref_kind`/`ref_value`. a null ref_kind yields the 152 // bare default-branch root. null if the result doesn't fit the inline name. 153 pub fn repoFilesRoute(identity: []const u8, ref_kind: ?RefKindOrOid, ref_value: []const u8, dir: []const u8) ?RoutablePage { 154 const kind = ref_kind orelse return .{ .repo = Array(repo_route_max_len).from(identity) orelse return null }; 155 var buf: [repo_route_max_len]u8 = undefined; 156 const s = if (dir.len == 0) 157 std.fmt.bufPrint(&buf, "{s}/" ++ files_seg ++ "/{s}/{s}", .{ identity, @tagName(kind), ref_value }) catch return null 158 else 159 std.fmt.bufPrint(&buf, "{s}/" ++ files_seg ++ "/{s}/{s}/{s}", .{ identity, @tagName(kind), ref_value, dir }) catch return null; 160 return .{ .repo = Array(repo_route_max_len).from(s) orelse return null }; 161 } 162 163 // the start oid of a `.repo_commits` route's stored string ("" = page 1, 164 // which the commits tab walks from HEAD). 165 pub fn repoCommitsStart(s: []const u8) []const u8 { 166 const marker = "/" ++ commits_seg ++ "/"; 167 const i = std.mem.indexOf(u8, s, marker) orelse return ""; 168 return s[i + marker.len ..]; 169 } 170 171 // build a `.repo_commits` route for "owner/name" (identity) at `start_oid` 172 // ("" = the first page). always carries the `commits` marker so page 1 173 // ("owner/name/commits") doesn't collide with the files root. 174 pub fn repoCommitsRoute(identity: []const u8, start_oid: []const u8, after: usize) ?RoutablePage { 175 var buf: [repo_route_max_len]u8 = undefined; 176 const s = if (start_oid.len == 0) 177 std.fmt.bufPrint(&buf, "{s}/" ++ commits_seg, .{identity}) catch return null 178 else 179 std.fmt.bufPrint(&buf, "{s}/" ++ commits_seg ++ "/{s}", .{ identity, start_oid }) catch return null; 180 return .{ .repo_commits = .{ .name = Array(repo_route_max_len).from(s) orelse return null, .after = after } }; 181 } 182 183 // build a `.repo_refs` route for "owner/name" (identity) paginating `kind`'s 184 // column to `after` (0 = the first window, the other column always at 0). 185 pub fn repoRefsRoute(identity: []const u8, kind: RefKind, after: usize) ?RoutablePage { 186 return .{ .repo_refs = .{ .name = Array(repo_route_max_len).from(identity) orelse return null, .kind = kind, .after = after } }; 187 } 188 189 // an inline, owned array of data. keeping it in the route (rather than a 190 // borrowed slice) makes RoutablePage a plain value: it can be copied, stored 191 // in history, and serialized without any arena tracking. 192 pub fn Array(comptime max_len: usize) type { 193 return struct { 194 bytes: [max_len]u8 = undefined, 195 len: u16 = 0, 196 197 pub fn from(s: []const u8) ?Array(max_len) { 198 if (s.len > max_len) return null; 199 var name = Array(max_len){ .len = @intCast(s.len) }; 200 @memcpy(name.bytes[0..s.len], s); 201 return name; 202 } 203 204 pub fn slice(self: *const Array(max_len)) []const u8 { 205 return self.bytes[0..self.len]; 206 } 207 }; 208 } 209 210 pub fn url(comptime self: RoutablePage) []const u8 { 211 return switch (self) { 212 .home_users => "/users", 213 .home_repos => "/repos", 214 .home_settings => "/settings", 215 .home_auth => "/auth", 216 .user, .user_settings, .user_auth => @compileError("user routes are dynamic; use urlAlloc"), 217 .repo, .repo_commits, .repo_refs, .repo_settings, .repo_auth => @compileError("repo routes are dynamic; use urlAlloc"), 218 }; 219 } 220 221 pub fn urlAlloc(self: RoutablePage, arena: *std.heap.ArenaAllocator) ![]const u8 { 222 return switch (self) { 223 .home_users => |after| if (after == 0) @as([]const u8, "/users") else try std.fmt.allocPrint(arena.allocator(), "/users?after={d}", .{after}), 224 .home_repos => |after| if (after == 0) @as([]const u8, "/repos") else try std.fmt.allocPrint(arena.allocator(), "/repos?after={d}", .{after}), 225 .user => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}", .{name.slice()}), 226 .user_settings => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/settings", .{name.slice()}), 227 .user_auth => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/auth", .{name.slice()}), 228 .repo => |name| try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}", .{name.slice()}), 229 .repo_commits => |c| if (c.after == 0) 230 try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}", .{c.name.slice()}) 231 else 232 try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}?after={d}", .{ c.name.slice(), c.after }), 233 .repo_refs => |r| if (r.after == 0) 234 try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/refs", .{r.name.slice()}) 235 else 236 try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/refs?kind={s}&after={d}", .{ r.name.slice(), @tagName(r.kind), r.after }), 237 .repo_settings => |name| try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/settings", .{name.slice()}), 238 .repo_auth => |name| try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/auth", .{name.slice()}), 239 inline else => |_, tag| url(tag), 240 }; 241 } 242 243 pub fn parent(self: RoutablePage) PageKind { 244 return switch (self) { 245 .home_users, .home_repos, .home_settings, .home_auth => .home, 246 .user, .user_settings, .user_auth => .user, 247 .repo, .repo_commits, .repo_refs, .repo_settings, .repo_auth => .repo, 248 }; 249 } 250 251 // parse a path plus its raw query string 252 pub fn fromUrl(path: []const u8, query: ?[]const u8) ?RoutablePage { 253 const after = uintParam(query, "after") orelse 0; 254 if (std.mem.eql(u8, path, "/")) return default; 255 if (std.mem.eql(u8, path, "/users")) return .{ .home_users = after }; 256 if (std.mem.eql(u8, path, "/repos")) return .{ .home_repos = after }; 257 if (std.mem.eql(u8, path, "/settings")) return .home_settings; 258 if (std.mem.eql(u8, path, "/auth")) return .home_auth; 259 // /user/<name>[/settings|/auth] 260 if (std.mem.startsWith(u8, path, user_segment)) { 261 const rest = path[user_segment.len..]; 262 const slash = std.mem.indexOfScalar(u8, rest, '/'); 263 const name = if (slash) |s| rest[0..s] else rest; 264 if (name.len == 0) return null; 265 const parsed = Array(evt.User.name_max_len).from(name) orelse return null; // name too long 266 if (slash) |s| { 267 const sub = rest[s + 1 ..]; 268 if (std.mem.eql(u8, sub, "settings")) return .{ .user_settings = parsed }; 269 if (std.mem.eql(u8, sub, "auth")) return .{ .user_auth = parsed }; 270 return null; // unknown sub-path 271 } 272 return .{ .user = parsed }; 273 } 274 // /repo/<username>/<reponame> (files root), then optionally /settings, 275 // /auth, or /files/<dir> for a directory in the files tab. the `repo` 276 // variant stores "username/reponame" (root) or 277 // "username/reponame/files/<dir>"; settings/auth store just the pair. 278 if (std.mem.startsWith(u8, path, repo_segment)) { 279 const rest = path[repo_segment.len..]; 280 const user_slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null; 281 const after_user = rest[user_slash + 1 ..]; 282 const repo_slash = std.mem.indexOfScalar(u8, after_user, '/'); 283 const repo_name = if (repo_slash) |s| after_user[0..s] else after_user; 284 // reject an empty username or reponame 285 if (user_slash == 0 or repo_name.len == 0) return null; 286 const pair = rest[0 .. user_slash + 1 + repo_name.len]; // "username/reponame" 287 if (repo_slash) |s| { 288 const sub = after_user[s + 1 ..]; 289 if (std.mem.eql(u8, sub, "refs")) return .{ .repo_refs = .{ 290 .name = Array(repo_route_max_len).from(pair) orelse return null, 291 .kind = refsKind(query), 292 .after = after, 293 } }; 294 if (std.mem.eql(u8, sub, "settings")) return .{ .repo_settings = Array(repo_route_max_len).from(pair) orelse return null }; 295 if (std.mem.eql(u8, sub, "auth")) return .{ .repo_auth = Array(repo_route_max_len).from(pair) orelse return null }; 296 if (std.mem.eql(u8, sub, files_seg)) return repoFilesRoute(pair, null, "", ""); // trailing /files == default-branch root 297 if (std.mem.startsWith(u8, sub, files_seg ++ "/")) { 298 // "files/<refkind>/<refvalue>[/<dir>]" 299 const ref_part = sub[files_seg.len + 1 ..]; 300 const k1 = std.mem.indexOfScalar(u8, ref_part, '/') orelse return null; 301 const kind = RefKindOrOid.fromSeg(ref_part[0..k1]) orelse return null; 302 const after_kind = ref_part[k1 + 1 ..]; 303 const k2 = std.mem.indexOfScalar(u8, after_kind, '/'); 304 const value = if (k2) |i| after_kind[0..i] else after_kind; 305 if (value.len == 0) return null; 306 const dir = if (k2) |i| after_kind[i + 1 ..] else ""; 307 return repoFilesRoute(pair, kind, value, dir); 308 } 309 if (std.mem.eql(u8, sub, commits_seg)) return repoCommitsRoute(pair, "", after); // trailing /commits == page 1 310 if (std.mem.startsWith(u8, sub, commits_seg ++ "/")) return repoCommitsRoute(pair, sub[commits_seg.len + 1 ..], after); 311 return null; // unknown sub-path 312 } 313 return repoFilesRoute(pair, null, "", ""); 314 } 315 return null; 316 } 317 318 // the refs column a "kind=" query param names; absent or unrecognized reads 319 // as the branches column. 320 fn refsKind(query: ?[]const u8) RefKind { 321 const qs = query orelse return .branch; 322 var it = std.mem.splitScalar(u8, qs, '&'); 323 while (it.next()) |pair| { 324 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 325 if (std.mem.eql(u8, pair[0..eq], "kind") and std.mem.eql(u8, pair[eq + 1 ..], "tag")) return .tag; 326 } 327 return .branch; 328 } 329 330 // read a "key=<uint>" value from a raw "a=1&b=2" query string. 331 fn uintParam(query: ?[]const u8, key: []const u8) ?usize { 332 const qs = query orelse return null; 333 var it = std.mem.splitScalar(u8, qs, '&'); 334 while (it.next()) |pair| { 335 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 336 if (std.mem.eql(u8, pair[0..eq], key)) 337 return std.fmt.parseInt(usize, pair[eq + 1 ..], 10) catch null; 338 } 339 return null; 340 } 341 342 pub fn eql(a: RoutablePage, b: RoutablePage) bool { 343 if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; 344 return switch (a) { 345 .home_users => |a_after| a_after == b.home_users, 346 .home_repos => |a_after| a_after == b.home_repos, 347 .user => |a_name| std.mem.eql(u8, a_name.slice(), b.user.slice()), 348 .user_settings => |a_name| std.mem.eql(u8, a_name.slice(), b.user_settings.slice()), 349 .user_auth => |a_name| std.mem.eql(u8, a_name.slice(), b.user_auth.slice()), 350 .repo => |a_name| std.mem.eql(u8, a_name.slice(), b.repo.slice()), 351 .repo_commits => |a_c| std.mem.eql(u8, a_c.name.slice(), b.repo_commits.name.slice()) and a_c.after == b.repo_commits.after, 352 .repo_refs => |a_r| std.mem.eql(u8, a_r.name.slice(), b.repo_refs.name.slice()) and a_r.kind == b.repo_refs.kind and a_r.after == b.repo_refs.after, 353 .repo_settings => |a_name| std.mem.eql(u8, a_name.slice(), b.repo_settings.slice()), 354 .repo_auth => |a_name| std.mem.eql(u8, a_name.slice(), b.repo_auth.slice()), 355 else => true, 356 }; 357 } 358 359 // the content a repo route renders: which repo, plus the files directory and 360 // commits page. tab switches keep this fixed (the page already holds every 361 // tab's data); a files-directory or commits-page change alters it. 362 const RepoContent = struct { identity: []const u8, files_ref_kind: ?RefKindOrOid, files_ref_value: []const u8, files_dir: []const u8, commits_start: []const u8, commits_after: usize, refs_kind: RefKind, refs_after: usize }; 363 364 // `r` is borrowed by pointer: the returned slices point into its inline 365 // route string, so it must outlive the RepoContent (passing by value would 366 // leave them dangling into a freed copy). 367 fn repoContent(r: *const RoutablePage) ?RepoContent { 368 const name = switch (r.*) { 369 .repo, .repo_settings, .repo_auth => |*n| n.slice(), 370 .repo_commits => |*c| c.name.slice(), 371 .repo_refs => |*r2| r2.name.slice(), 372 else => return null, 373 }; 374 const rf = RepoFiles.parse(name) orelse return null; 375 // a 0 offset means no refs pagination, so canonicalize the column to 376 // branch then — otherwise the base /refs route would look like a content 377 // change relative to the same page mirrored with kind=tag. 378 const refs_after = switch (r.*) { 379 .repo_refs => |r2| r2.after, 380 else => 0, 381 }; 382 const is_files = std.meta.activeTag(r.*) == .repo; 383 return .{ 384 .identity = rf.identity, 385 .files_ref_kind = if (is_files) rf.ref_kind else null, 386 .files_ref_value = if (is_files) rf.ref_value else "", 387 .files_dir = if (is_files) rf.dir else "", 388 .commits_start = if (std.meta.activeTag(r.*) == .repo_commits) repoCommitsStart(name) else "", 389 .commits_after = switch (r.*) { 390 .repo_commits => |c| c.after, 391 else => 0, 392 }, 393 .refs_kind = switch (r.*) { 394 .repo_refs => |r2| if (refs_after == 0) .branch else r2.kind, 395 else => .branch, 396 }, 397 .refs_after = refs_after, 398 }; 399 } 400 401 // true when `a` and `b` are both repo routes that render different content 402 pub fn repoPageChanged(a: RoutablePage, b: RoutablePage) bool { 403 const ca = repoContent(&a) orelse return false; 404 const cb = repoContent(&b) orelse return false; 405 return !std.mem.eql(u8, ca.identity, cb.identity) or 406 ca.files_ref_kind != cb.files_ref_kind or 407 !std.mem.eql(u8, ca.files_ref_value, cb.files_ref_value) or 408 !std.mem.eql(u8, ca.files_dir, cb.files_dir) or 409 !std.mem.eql(u8, ca.commits_start, cb.commits_start) or 410 ca.commits_after != cb.commits_after or 411 ca.refs_kind != cb.refs_kind or 412 ca.refs_after != cb.refs_after; 413 } 414 415 // true when `a` and `b` are the same home list tab paginated to a different 416 // window. switching between the users/repos tabs is in-page (the home page 417 // holds both lists), so only a changed `after` on the same tab navigates. 418 pub fn homePageChanged(a: RoutablePage, b: RoutablePage) bool { 419 return switch (a) { 420 .home_users => |aa| switch (b) { 421 .home_users => |bb| aa != bb, 422 else => false, 423 }, 424 .home_repos => |aa| switch (b) { 425 .home_repos => |bb| aa != bb, 426 else => false, 427 }, 428 else => false, 429 }; 430 } 431 432 // serialize as { "kind": <tag>, "name"?: <name>, "after"?: <n> }. the default 433 // union(enum) codec doesn't round-trip here: Stringify emits a void 434 // variant as a bare tag string, but the parser expects an object — so the 435 // server->wasm Snapshot JSON would fail to parse. 436 pub fn jsonStringify(self: RoutablePage, jw: anytype) !void { 437 try jw.beginObject(); 438 try jw.objectField("kind"); 439 try jw.write(@tagName(self)); 440 switch (self) { 441 .user, .user_settings, .user_auth => |name| { 442 try jw.objectField("name"); 443 try jw.write(name.slice()); 444 }, 445 .repo, .repo_settings, .repo_auth => |name| { 446 try jw.objectField("name"); 447 try jw.write(name.slice()); 448 }, 449 .repo_commits => |c| { 450 try jw.objectField("name"); 451 try jw.write(c.name.slice()); 452 try jw.objectField("after"); 453 try jw.write(c.after); 454 }, 455 .repo_refs => |r| { 456 try jw.objectField("name"); 457 try jw.write(r.name.slice()); 458 // "kind" already holds the union tag, so the paginated column 459 // travels under a distinct field. 460 try jw.objectField("ref_kind"); 461 try jw.write(@tagName(r.kind)); 462 try jw.objectField("after"); 463 try jw.write(r.after); 464 }, 465 .home_users, .home_repos => |after| { 466 try jw.objectField("after"); 467 try jw.write(after); 468 }, 469 else => {}, 470 } 471 try jw.endObject(); 472 } 473 474 pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !RoutablePage { 475 const Helper = struct { kind: std.meta.Tag(RoutablePage), name: ?[]const u8 = null, after: usize = 0, ref_kind: ?[]const u8 = null }; 476 const helper = try std.json.innerParse(Helper, allocator, source, options); 477 const parseName = struct { 478 // errors must stay within std.json's ParseError set; ValueTooLong 479 // is its member for an over-long field. 480 fn f(comptime max_len: usize, maybe: ?[]const u8) !Array(max_len) { 481 return Array(max_len).from(maybe orelse return error.MissingField) orelse error.ValueTooLong; 482 } 483 }.f; 484 return switch (helper.kind) { 485 .home_users => .{ .home_users = helper.after }, 486 .home_repos => .{ .home_repos = helper.after }, 487 .home_settings => .home_settings, 488 .home_auth => .home_auth, 489 .user => .{ .user = try parseName(evt.User.name_max_len, helper.name) }, 490 .user_settings => .{ .user_settings = try parseName(evt.User.name_max_len, helper.name) }, 491 .user_auth => .{ .user_auth = try parseName(evt.User.name_max_len, helper.name) }, 492 .repo => .{ .repo = try parseName(repo_route_max_len, helper.name) }, 493 .repo_commits => .{ .repo_commits = .{ .name = try parseName(repo_route_max_len, helper.name), .after = helper.after } }, 494 .repo_refs => .{ .repo_refs = .{ 495 .name = try parseName(repo_route_max_len, helper.name), 496 .kind = if (helper.ref_kind) |k| (if (std.mem.eql(u8, k, "tag")) .tag else .branch) else .branch, 497 .after = helper.after, 498 } }, 499 .repo_settings => .{ .repo_settings = try parseName(repo_route_max_len, helper.name) }, 500 .repo_auth => .{ .repo_auth = try parseName(repo_route_max_len, helper.name) }, 501 }; 502 } 503 }; 504 505 // per-connection mutable state. each SSH session / web session / local TUI 506 // run gets its own. 507 pub const Session = struct { 508 data: Data = .{}, 509 // session-lifetime allocations: login/user_id, persisted prefs, queued 510 // actions. lives as long as the connection. 511 arena: *std.heap.ArenaAllocator, 512 // current page's allocations: page data and the page-scoped strings widgets 513 // build (form actions, links). owned by Nav, swapped on each navigation, so 514 // it doesn't accumulate over a long-lived session. on the web/wasm paths, 515 // where a render is one-shot, this points at the same arena as `arena`. 516 page_arena: *std.heap.ArenaAllocator, 517 haxy_moment: ?evt.AdminDB.HashMap(.read_only) = null, // db cursor (null on the wasm side) 518 // filesystem io and the path to <server>/repos, for opening on-disk repos 519 // during page construction. both null on wasm, which has no filesystem and 520 // rebuilds pages from the serialized snapshot rather than from disk. 521 io: ?std.Io = null, 522 repos_dir: ?[]const u8 = null, 523 pending: std.ArrayList(Action) = .empty, // actions queued by widgets this frame 524 // focus id -> the live TextInput, refreshed each frame by the views that own 525 // inputs. web/wasm form handling looks widgets up here by focus id. 526 text_inputs: std.AutoHashMapUnmanaged(usize, *wgt.TextInput(Widget)) = .empty, 527 nav_back: bool = false, // set by input (escape) to request the native TUI pop a page; see Nav 528 // a requested forward navigation. set this (via navigate) to move to a new 529 // page; Nav.sync builds it and then copies it into current_page, clearing 530 // this back to null. setting current_page directly only updates the url and 531 // does not navigate. 532 next_page: ?RoutablePage = null, 533 is_terminal: bool = false, // true on remote SSH and local TUI 534 // port the web UI is served on, for the TUI/SSH footer's "http://localhost:<port>..." 535 // url. null on the web itself (no footer there). 536 web_port: ?u16 = null, 537 quit_requested: bool = false, 538 539 const Self = @This(); 540 541 // serializable data sent down to web client 542 pub const Data = struct { 543 user_id: ?[]const u8 = null, 544 // a transient outcome to surface from the last /login POST attempt 545 login_failure: ?Home.Auth.Login.Failure = null, 546 current_page: RoutablePage = .default, 547 // whether to render the ANSI art backdrop 548 enable_ansi: bool = true, 549 }; 550 551 // a user-initiated state change. widgets enqueue these on the session during 552 // input instead of mutating state or touching the DB themselves; the host 553 // drains them each frame (see applyAndWritePending / applyPending). this keeps DB 554 // side effects out of the widget tree and gives every render path one place to 555 // turn a UI action into a state change + event. 556 pub const Action = union(enum) { 557 toggle_ansi, 558 }; 559 560 pub fn init( 561 arena: *std.heap.ArenaAllocator, 562 repo: *rp.Repo(.xit, evt.admin_repo_opts), 563 data: Data, 564 ) !Self { 565 var session = Self{ 566 .data = data, 567 .arena = arena, 568 // until a host swaps in a page-scoped arena (Nav does this), page 569 // allocations land in the session arena. on the web path that's the 570 // intended behavior, since the whole arena is per-request. 571 .page_arena = arena, 572 .haxy_moment = try evt.currentMoment(evt.admin_repo_opts, repo), 573 }; 574 try session.loadUserPrefs(); 575 return session; 576 } 577 578 // load the logged-in user's persisted preferences from the db 579 pub fn loadUserPrefs(self: *Self) !void { 580 const user_id = self.data.user_id orelse return; 581 const moment = self.haxy_moment orelse return; 582 if (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, self.arena, user_id)) |user| { 583 self.data.enable_ansi = user.enable_ansi; 584 } 585 } 586 587 // queue an action for the host to drain this frame. 588 pub fn push(self: *Self, action: Action) !void { 589 try self.pending.append(self.arena.allocator(), action); 590 } 591 592 // apply a single action's in-memory effect only (no persistence). 593 fn apply(self: *Self, action: Action) void { 594 switch (action) { 595 .toggle_ansi => self.data.enable_ansi = !self.data.enable_ansi, 596 } 597 } 598 599 // drain the session's queued actions by applying them to the session. 600 // used on the wasm path, which has no repo to persist to. 601 pub fn applyPending(self: *Self) void { 602 for (self.pending.items) |action| self.apply(action); 603 self.pending.clearRetainingCapacity(); 604 } 605 606 // drain the session's queued actions by applying them to the session and 607 // writing them to the db 608 pub fn applyAndWritePending( 609 self: *Self, 610 io: std.Io, 611 allocator: std.mem.Allocator, 612 repo: *rp.Repo(.xit, evt.admin_repo_opts), 613 ) !void { 614 defer self.pending.clearRetainingCapacity(); 615 var wrote = false; 616 for (self.pending.items) |action| { 617 self.apply(action); 618 switch (action) { 619 .toggle_ansi => if (self.data.user_id) |user_id| { 620 try evt.User.toggleAnsi(evt.admin_repo_opts, io, allocator, repo, user_id); 621 wrote = true; 622 }, 623 } 624 } 625 // reload the moment so a later read off it sees what we just applied 626 if (wrote) self.haxy_moment = try evt.currentMoment(evt.admin_repo_opts, repo); 627 } 628 629 // request a forward navigation to `route`; the host consumes next_page 630 // (Nav.sync on the terminal, the wasm tick on the web). 631 pub fn navigate(self: *Session, route: RoutablePage) !void { 632 self.next_page = route; 633 } 634 }; 635 636 pub fn run(io: std.Io, allocator: std.mem.Allocator, session: *Session) !void { 637 var nav = try Nav.init(allocator, session); 638 defer nav.deinit(allocator); 639 640 var terminal = try term.Terminal.init(io, allocator); 641 defer terminal.deinit(io); 642 643 // set term as active so it will be properly cooked 644 // when a panic/segfault happens 645 term.setActive(&terminal); 646 defer term.setActive(null); 647 648 var last_size = layout.Size{ .width = 0, .height = 0 }; 649 var last_grid = try Grid.init(allocator, last_size); 650 defer last_grid.deinit(); 651 652 while (!terminal.shouldQuit()) { 653 const grid_changed = try terminal.render(&nav.root, &last_grid, &last_size); 654 655 // process any inputs. 656 // 657 // if the grid didn't change, then first do a blocking 658 // read, so the thread will sleep until further input. 659 // after that, all remaining reads are non-blocking so 660 // we can process the rest of the queued inputs. 661 // 662 // if the grid *did* change, then only do non-blocking 663 // reads. we do not want to sleep the thread because 664 // there may be an animation that requires more looping. 665 var blocking = !grid_changed; 666 while (try terminal.readKey(io, blocking)) |key| { 667 blocking = false; 668 try inputKey(allocator, &nav.root, key, session); 669 } 670 671 // local run has no repo to persist to, so just apply in-memory 672 session.applyPending(); 673 674 // reconcile navigation: forward to a new page, or back on escape. 675 try nav.sync(allocator, session); 676 677 // the quit button (on the quit tab) asks the host to tear down. 678 if (session.quit_requested) terminal.requestQuit(); 679 680 try nav.root.build(allocator, .{ 681 .min_size = .{ .width = null, .height = null }, 682 .max_size = .{ .width = last_size.width, .height = last_size.height }, 683 }, nav.root.getFocus()); 684 } 685 } 686 687 pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: inp.Key, session: *Session) !void { 688 switch (key) { 689 // request a navigation pop; the host's Nav.sync goes back a page, or 690 // quits when there's no history left. 691 .escape => session.nav_back = true, 692 .enter => { 693 const root_focus = root.getFocus(); 694 if (root_focus.grandchild_id) |gid| { 695 // follow a cross-page link 696 if (crossPageLink(root_focus, gid, session.data.current_page)) |route| { 697 return session.navigate(route); 698 } 699 } 700 try root.input(allocator, key, root_focus); 701 }, 702 .mouse => |mouse| { 703 if (mouse.action == .press and mouse.action.press == .left) { 704 const root_focus = root.getFocus(); 705 var clicked: ?usize = null; 706 var iter = root_focus.children.iterator(); 707 while (iter.next()) |entry| { 708 const child = entry.value_ptr.*; 709 if (!child.focus.focusable) continue; 710 const r = child.rect; 711 if (mouse.x >= r.x and mouse.y >= r.y and 712 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 713 { 714 clicked = entry.key_ptr.*; 715 break; 716 } 717 } 718 if (clicked) |focus_id| { 719 // follow a cross-page link 720 if (crossPageLink(root_focus, focus_id, session.data.current_page)) |route| { 721 return session.navigate(route); 722 } 723 try root_focus.setFocus(focus_id); 724 } 725 // forward the press into the widget tree so buttons (and any 726 // future click-aware widgets) can react. widgets that don't 727 // care about presses ignore it. 728 try root.input(allocator, key, root.getFocus()); 729 } else { 730 try root.input(allocator, key, root.getFocus()); 731 } 732 }, 733 else => try root.input(allocator, key, root.getFocus()), 734 } 735 } 736 737 // if the focus target at focus_id is an `a:` link that should navigate (a 738 // different parent page, or a different files directory within the repo page), 739 // return its route; otherwise null. lets a host turn a click / enter on such a 740 // link into a navigation rather than a focus change. 741 pub fn crossPageLink(root_focus: *Focus, focus_id: usize, current: RoutablePage) ?RoutablePage { 742 const child = root_focus.children.get(focus_id) orelse return null; 743 const custom = switch (child.focus.kind) { 744 .custom => |c| c, 745 else => return null, 746 }; 747 const a_prefix = "a:"; 748 if (!std.mem.startsWith(u8, custom, a_prefix)) return null; 749 const url = custom[a_prefix.len..]; 750 const q = std.mem.indexOfScalar(u8, url, '?'); 751 const route = RoutablePage.fromUrl(if (q) |i| url[0..i] else url, if (q) |i| url[i + 1 ..] else null) orelse return null; 752 // a link to a different parent page always navigates; within a page, a 753 // files-directory / commits-page / list-window change navigates while tab 754 // links stay in-page (header-handled). 755 if (route.parent() != current.parent() or RoutablePage.repoPageChanged(route, current) or RoutablePage.homePageChanged(route, current)) return route; 756 return null; 757 } 758 759 // native-TUI navigation 760 pub const Nav = struct { 761 root: Widget, 762 route: RoutablePage, 763 // backs the current page (its Page data and the page-scoped strings its 764 // widgets build). owned here, swapped on each navigation. session.page_arena 765 // tracks whichever of these is current so widgets allocate into it. 766 arena: *std.heap.ArenaAllocator, 767 history: std.ArrayList(Entry), 768 769 // each retained page keeps its own arena, freed when the page leaves the 770 // stack (popped on back, evicted at cap, or on deinit). this is what keeps a 771 // long-lived session from accumulating every page it ever visited. 772 const Entry = struct { root: Widget, route: RoutablePage, arena: *std.heap.ArenaAllocator }; 773 774 // cap on retained back-history so the chain can't grow memory without bound 775 const max_history: usize = 16; 776 777 pub fn init(allocator: std.mem.Allocator, session: *Session) !Nav { 778 const arena = try allocator.create(std.heap.ArenaAllocator); 779 arena.* = std.heap.ArenaAllocator.init(allocator); 780 errdefer { 781 arena.deinit(); 782 allocator.destroy(arena); 783 } 784 785 session.page_arena = arena; 786 const route = session.data.current_page; 787 788 const page = try arena.allocator().create(Page); 789 page.* = try Page.init(arena, session, route); 790 return .{ 791 .root = try initRoot(allocator, page, session), 792 .route = route, 793 .arena = arena, 794 .history = .empty, 795 }; 796 } 797 798 pub fn deinit(self: *Nav, allocator: std.mem.Allocator) void { 799 self.root.deinit(allocator); 800 freeArena(allocator, self.arena); 801 for (self.history.items) |*entry| { 802 entry.root.deinit(allocator); 803 freeArena(allocator, entry.arena); 804 } 805 self.history.deinit(allocator); 806 } 807 808 fn freeArena(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator) void { 809 arena.deinit(); 810 allocator.destroy(arena); 811 } 812 813 // reconcile the displayed root with the session's navigation state. a 814 // forward nav (current_page moved to a different parent page) pushes the 815 // current root and builds the new page in a fresh arena; a back request 816 // frees the current page and restores the previous one. when escape is 817 // pressed with no history left we switch to the quit tab instead of quitting. 818 pub fn sync(self: *Nav, allocator: std.mem.Allocator, session: *Session) !void { 819 if (session.nav_back) { 820 session.nav_back = false; 821 if (self.history.pop()) |entry| { 822 self.root.deinit(allocator); 823 freeArena(allocator, self.arena); 824 self.root = entry.root; 825 self.route = entry.route; 826 self.arena = entry.arena; 827 session.page_arena = entry.arena; 828 session.data.current_page = entry.route; 829 return; 830 } 831 // nothing to go back to; switch to the quit confirmation 832 const root_focus = self.root.getFocus(); 833 var iter = root_focus.children.iterator(); 834 while (iter.next()) |entry| { 835 switch (entry.value_ptr.focus.kind) { 836 .custom => |custom| if (std.mem.eql(u8, custom, Quit.tab_kind)) { 837 try root_focus.setFocus(entry.key_ptr.*); 838 // a Stack only builds its selected child, so the quit 839 // button isn't in the focus tree until a build runs with 840 // the quit tab selected. build once, then send a 841 // synthetic arrow_down to drop focus from the tab onto 842 // the button so it's ready to confirm. 843 try self.root.build(allocator, .{ 844 .min_size = .{ .width = null, .height = 40 }, 845 .max_size = .{ .width = 80, .height = null }, 846 }, root_focus); 847 try self.root.input(allocator, .arrow_down, root_focus); 848 break; 849 }, 850 else => {}, 851 } 852 } 853 return; 854 } 855 856 // forward navigation: navigate() set next_page to the page to move to (a 857 // cross-page link or tab change crossing pages) 858 if (session.next_page) |route| { 859 session.next_page = null; 860 if (session.haxy_moment == null) return; 861 // the page we navigated to becomes the current page 862 session.data.current_page = route; 863 864 const arena = try allocator.create(std.heap.ArenaAllocator); 865 arena.* = std.heap.ArenaAllocator.init(allocator); 866 errdefer freeArena(allocator, arena); 867 868 session.page_arena = arena; 869 870 const page = try arena.allocator().create(Page); 871 page.* = try Page.init(arena, session, route); 872 const new_root = try initRoot(allocator, page, session); 873 874 try self.history.append(allocator, .{ .root = self.root, .route = self.route, .arena = self.arena }); 875 // drop the oldest entry (freeing its widget tree and arena) once over cap 876 if (self.history.items.len > max_history) { 877 var oldest = self.history.orderedRemove(0); 878 oldest.root.deinit(allocator); 879 freeArena(allocator, oldest.arena); 880 } 881 self.root = new_root; 882 self.route = route; 883 self.arena = arena; 884 } 885 } 886 }; 887 888 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { 889 const page_widget: Widget = switch (page.*) { 890 .home => |*p| .{ .home = try .init(allocator, p, session) }, 891 .user => |*p| .{ .user = try .init(allocator, p, session) }, 892 .repo => |*p| .{ .repo = try .init(allocator, p, session) }, 893 }; 894 895 const demon_art = @embedFile("embed/demon.ans"); 896 897 // on the TUI/SSH, the page sits above a one-row footer showing the url 898 var root = if (session.is_terminal) blk: { 899 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 900 errdefer box.deinit(allocator); 901 const bg_id = bg_blk: { 902 var background = try AnsiBackground.init(allocator, page_widget, demon_art, session); 903 errdefer background.deinit(allocator); 904 const id = background.getFocus().id; 905 try box.children.put(allocator, id, .{ .widget = .{ .background = background }, .rect = null, .min_size = null }); 906 break :bg_blk id; 907 }; 908 { 909 var footer = try Footer.init(allocator, session); 910 errdefer footer.deinit(allocator); 911 try box.children.put(allocator, footer.getFocus().id, .{ .widget = .{ .footer = footer }, .rect = null, .min_size = .{ .width = null, .height = 1 } }); 912 } 913 box.getFocus().child_id = bg_id; 914 break :blk Widget{ .box = box }; 915 } else Widget{ .background = try AnsiBackground.init(allocator, page_widget, demon_art, session) }; 916 errdefer root.deinit(allocator); 917 918 // input-owning views build their TextInputs in init — so reset the 919 // focus-id -> *TextInput map here 920 session.text_inputs.clearRetainingCapacity(); 921 922 try root.build(allocator, .{ 923 .min_size = .{ .width = null, .height = 40 }, 924 .max_size = .{ .width = 80, .height = null }, 925 }, root.getFocus()); 926 if (root.getFocus().child_id) |child_id| { 927 try root.getFocus().setFocus(child_id); 928 } 929 930 return root; 931 } 932 933 pub const Widget = union(enum) { 934 text: wgt.Text(Widget), 935 box: wgt.Box(Widget), 936 text_box: wgt.TextBox(Widget), 937 text_input: wgt.TextInput(Widget), 938 scroll: wgt.Scroll(Widget), 939 stack: wgt.Stack(Widget), 940 flow_box: FlowBox, 941 flow_box_scroll: FlowBox.Scroll, 942 spacer: Spacer, 943 center: Center, 944 ansi_art: AnsiArt, 945 background: AnsiBackground, 946 home: Home.View, 947 user: User.View, 948 repo: Repo.View, 949 quit: Quit.View, 950 title: Title.View, 951 sub_title: SubTitle.View, 952 home_header: Home.Header.View, 953 user_header: User.Header.View, 954 repo_header: Repo.Header.View, 955 repo_files: Repo.Files.View, 956 repo_commits: Repo.Commits.View, 957 repo_refs: Repo.Refs.View, 958 home_users: Home.Users.View, 959 home_repos: Home.Repos.View, 960 auth_tab: Home.Header.AuthTab.View, 961 home_settings: Home.Settings.View, 962 home_auth: Home.Auth.View, 963 footer: Footer, 964 965 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void { 966 switch (self.*) { 967 inline else => |*case| case.deinit(allocator), 968 } 969 } 970 971 pub fn build(self: *Widget, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 972 switch (self.*) { 973 inline else => |*case| try case.build(allocator, constraint, root_focus), 974 } 975 } 976 977 pub fn input(self: *Widget, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) anyerror!void { 978 switch (self.*) { 979 inline else => |*case| try case.input(allocator, key, root_focus), 980 } 981 } 982 983 pub fn clearGrid(self: *Widget) void { 984 switch (self.*) { 985 inline else => |*case| case.clearGrid(), 986 } 987 } 988 989 pub fn getGrid(self: Widget) ?Grid { 990 switch (self) { 991 inline else => |*case| return case.getGrid(), 992 } 993 } 994 995 pub fn getFocus(self: *Widget) *Focus { 996 switch (self.*) { 997 inline else => |*case| return case.getFocus(), 998 } 999 } 1000 }; 1001 1002 pub const FlowBox = struct { 1003 focus: *Focus, 1004 grid: ?Grid, 1005 text_boxes: std.ArrayList(wgt.TextBox(Widget)), 1006 // backs the per-item strings the text boxes borrow: each box's `content` 1007 // and, for link items, its `.custom` focus kind (e.g. "a:/user/foo"). reset 1008 // wholesale on each setItems so there's no per-string ownership to track. 1009 arena: std.heap.ArenaAllocator, 1010 // column count from the last build — FlowBox.Scroll.input uses it so arrow 1011 // up/down can step by a row's worth of items. 1012 last_cols: usize, 1013 options: Options, 1014 1015 const border_rows: usize = 2; 1016 1017 pub const Options = struct { 1018 cell_width: usize = 40, 1019 cell_height: usize = 3, 1020 }; 1021 1022 pub fn init(allocator: std.mem.Allocator, options: Options) !FlowBox { 1023 return .{ 1024 .focus = try Focus.create(allocator, .container), 1025 .grid = null, 1026 .text_boxes = .empty, 1027 .arena = std.heap.ArenaAllocator.init(allocator), 1028 .last_cols = 1, 1029 .options = options, 1030 }; 1031 } 1032 1033 pub fn deinit(self: *FlowBox, allocator: std.mem.Allocator) void { 1034 self.focus.destroy(allocator); 1035 if (self.grid) |*grid| { 1036 grid.deinit(); 1037 self.grid = null; 1038 } 1039 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 1040 self.text_boxes.deinit(allocator); 1041 self.arena.deinit(); 1042 } 1043 1044 // one flow item: its display text plus an optional link. a non-empty `link` 1045 // sets the item's focus kind such as `.{ .custom = "a:/user/foo" }`, which 1046 // the web renderer turns into an anchor. 1047 pub const Item = struct { text: []const u8, link: []const u8 = "" }; 1048 1049 // both the item text and the link are copied into the arena, so the caller's 1050 // slices needn't outlive this call. 1051 pub fn setItems(self: *FlowBox, allocator: std.mem.Allocator, items: []const Item) !void { 1052 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 1053 self.text_boxes.clearAndFree(allocator); 1054 1055 // the old text boxes (and their borrowed content/links) are gone now, so 1056 // the arena that backed those strings can be reclaimed. 1057 _ = self.arena.reset(.retain_capacity); 1058 const aa = self.arena.allocator(); 1059 1060 self.focus.clear(); 1061 self.focus.child_id = null; 1062 1063 for (items) |item| { 1064 const line = try aa.dupe(u8, item.text); 1065 1066 var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 1067 errdefer text_box.deinit(allocator); 1068 text_box.getFocus().focusable = true; 1069 1070 if (item.link.len > 0) { 1071 text_box.getFocus().kind = .{ .custom = try aa.dupe(u8, item.link) }; 1072 } 1073 1074 try self.text_boxes.append(allocator, text_box); 1075 } 1076 1077 if (self.text_boxes.items.len > 0) { 1078 self.focus.child_id = self.text_boxes.items[0].getFocus().id; 1079 } 1080 } 1081 1082 pub fn build(self: *FlowBox, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1083 self.clearGrid(); 1084 self.focus.clear(); 1085 1086 const cell_width = self.options.cell_width; 1087 const max_width = constraint.max_size.width orelse cell_width; 1088 const cols = if (cell_width == 0) 1 else @max(1, max_width / cell_width); 1089 self.last_cols = cols; 1090 1091 const count = self.text_boxes.items.len; 1092 if (count == 0) return; 1093 const slot_height = self.options.cell_height + border_rows; 1094 if (slot_height == 0) return; 1095 1096 // build at the slot size so every text box fits a single grid cell 1097 for (self.text_boxes.items) |*tb| { 1098 tb.options.border_style = if (self.focus.child_id == tb.getFocus().id) .single else .hidden; 1099 try tb.build(allocator, .{ 1100 .min_size = .{ .width = cell_width, .height = null }, 1101 .max_size = .{ .width = cell_width, .height = slot_height }, 1102 }, root_focus); 1103 } 1104 1105 const rows = (count + cols - 1) / cols; 1106 const content_height = rows * slot_height; 1107 const total_width = cols * cell_width; 1108 if (total_width == 0 or content_height == 0) return; 1109 1110 var grid = try Grid.init(allocator, .{ .width = total_width, .height = content_height }); 1111 errdefer grid.deinit(); 1112 1113 for (self.text_boxes.items, 0..) |*tb, i| { 1114 const tb_grid = tb.getGrid() orelse continue; 1115 const col = i % cols; 1116 const row = i / cols; 1117 const cell_x = col * cell_width; 1118 const cell_y = row * slot_height; 1119 try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, cell_x, cell_y); 1120 try grid.drawGrid(tb_grid, cell_x, cell_y); 1121 } 1122 1123 self.grid = grid; 1124 } 1125 1126 pub fn input(self: *FlowBox, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1127 _ = self; 1128 _ = allocator; 1129 _ = key; 1130 _ = root_focus; 1131 } 1132 1133 pub fn clearGrid(self: *FlowBox) void { 1134 if (self.grid) |*grid| { 1135 grid.deinit(); 1136 self.grid = null; 1137 } 1138 for (self.text_boxes.items) |*tb| tb.clearGrid(); 1139 } 1140 1141 pub fn getGrid(self: FlowBox) ?Grid { 1142 return self.grid; 1143 } 1144 1145 pub fn getFocus(self: *FlowBox) *Focus { 1146 return self.focus; 1147 } 1148 1149 pub fn cellRect(self: FlowBox, index: usize) ?layout.IRect { 1150 if (self.last_cols == 0 or index >= self.text_boxes.items.len) return null; 1151 const slot_height = self.options.cell_height + border_rows; 1152 const col = index % self.last_cols; 1153 const row = index / self.last_cols; 1154 return .{ 1155 .x = @intCast(col * self.options.cell_width), 1156 .y = @intCast(row * slot_height), 1157 .size = .{ .width = self.options.cell_width, .height = slot_height }, 1158 }; 1159 } 1160 1161 pub fn indexOfFocusId(self: FlowBox, focus_id: usize) ?usize { 1162 for (self.text_boxes.items, 0..) |tb, i| { 1163 if (tb.box.focus.id == focus_id) return i; 1164 } 1165 return null; 1166 } 1167 1168 pub const Scroll = struct { 1169 scroll: wgt.Scroll(Widget), 1170 1171 pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options, web_native: bool) !Scroll { 1172 var layout_inner = try FlowBox.init(allocator, options); 1173 errdefer layout_inner.deinit(allocator); 1174 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .{ .direction = .vert, .web_native = web_native }); 1175 errdefer scroll.deinit(allocator); 1176 return .{ .scroll = scroll }; 1177 } 1178 1179 pub fn deinit(self: *Scroll, allocator: std.mem.Allocator) void { 1180 self.scroll.deinit(allocator); 1181 } 1182 1183 pub fn setItems(self: *Scroll, allocator: std.mem.Allocator, items: []const Item) !void { 1184 self.scroll.x = 0; 1185 self.scroll.y = 0; 1186 try self.inner().setItems(allocator, items); 1187 } 1188 1189 pub fn build(self: *Scroll, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1190 try self.scroll.build(allocator, constraint, root_focus); 1191 } 1192 1193 pub fn input(self: *Scroll, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1194 _ = allocator; 1195 const in = self.inner(); 1196 const child_id = in.focus.child_id orelse return; 1197 const current_index = in.indexOfFocusId(child_id) orelse return; 1198 const count = in.text_boxes.items.len; 1199 if (count == 0) return; 1200 const cols = in.last_cols; 1201 const slot_height = in.options.cell_height + FlowBox.border_rows; 1202 1203 var index = current_index; 1204 switch (key) { 1205 .arrow_up => index -|= cols, 1206 .arrow_down => if (index + cols < count) { 1207 index += cols; 1208 }, 1209 .arrow_left => index -|= 1, 1210 .arrow_right => if (index + 1 < count) { 1211 index += 1; 1212 }, 1213 .home => index = 0, 1214 .end => index = count - 1, 1215 .page_up => { 1216 if (self.scroll.grid) |grid| if (slot_height > 0) { 1217 const rows_per_page = grid.size.height / slot_height; 1218 index -|= rows_per_page * cols; 1219 }; 1220 }, 1221 .page_down => { 1222 if (self.scroll.grid) |grid| if (slot_height > 0) { 1223 const rows_per_page = grid.size.height / slot_height; 1224 index = @min(index + rows_per_page * cols, count - 1); 1225 }; 1226 }, 1227 // scroll wheel moves the focused cell by a full row so the 1228 // viewport (via scrollToRect) follows in row-sized steps, 1229 // matching how a scroll wheel feels in a grid view. 1230 .mouse => |mouse| switch (mouse.action) { 1231 .scroll => |dir| switch (dir) { 1232 .up => index -|= cols, 1233 .down => if (index + cols < count) { 1234 index += cols; 1235 }, 1236 }, 1237 else => {}, 1238 }, 1239 else => {}, 1240 } 1241 1242 if (index != current_index) { 1243 try root_focus.setFocus(in.text_boxes.items[index].getFocus().id); 1244 if (in.cellRect(index)) |rect| { 1245 self.scroll.scrollToRect(rect); 1246 } 1247 } 1248 } 1249 1250 pub fn clearGrid(self: *Scroll) void { 1251 self.scroll.clearGrid(); 1252 } 1253 1254 pub fn getGrid(self: Scroll) ?Grid { 1255 return self.scroll.getGrid(); 1256 } 1257 1258 pub fn getFocus(self: *Scroll) *Focus { 1259 return self.scroll.getFocus(); 1260 } 1261 1262 pub fn getSelectedIndex(self: Scroll) ?usize { 1263 const in = self.scroll.child.flow_box; 1264 const child_id = in.focus.child_id orelse return null; 1265 return in.indexOfFocusId(child_id); 1266 } 1267 1268 fn inner(self: *Scroll) *FlowBox { 1269 return &self.scroll.child.flow_box; 1270 } 1271 }; 1272 }; 1273 1274 // an invisible widget that fills the horizontal space granted by its parent. 1275 // used inside Box(horiz) with a min_size so the box reserves space for the 1276 // children that follow, pushing them to the right. 1277 pub const Spacer = struct { 1278 focus: *Focus, 1279 grid: ?Grid, 1280 1281 pub fn init(allocator: std.mem.Allocator) !Spacer { 1282 return .{ 1283 .focus = try Focus.create(allocator, .container), 1284 .grid = null, 1285 }; 1286 } 1287 1288 pub fn deinit(self: *Spacer, allocator: std.mem.Allocator) void { 1289 self.focus.destroy(allocator); 1290 if (self.grid) |*grid| { 1291 grid.deinit(); 1292 self.grid = null; 1293 } 1294 } 1295 1296 pub fn build(self: *Spacer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1297 _ = root_focus; 1298 self.clearGrid(); 1299 const width = constraint.max_size.width orelse return; 1300 if (width == 0) return; 1301 self.grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 1302 } 1303 1304 pub fn input(self: *Spacer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1305 _ = self; 1306 _ = allocator; 1307 _ = key; 1308 _ = root_focus; 1309 } 1310 1311 pub fn clearGrid(self: *Spacer) void { 1312 if (self.grid) |*grid| { 1313 grid.deinit(); 1314 self.grid = null; 1315 } 1316 } 1317 1318 pub fn getGrid(self: Spacer) ?Grid { 1319 return self.grid; 1320 } 1321 1322 pub fn getFocus(self: *Spacer) *Focus { 1323 return self.focus; 1324 } 1325 }; 1326 1327 // a one-row, non-focusable status bar showing the current page's url 1328 pub const Footer = struct { 1329 focus: *Focus, 1330 grid: ?Grid, 1331 session: *Session, 1332 arena: std.heap.ArenaAllocator, 1333 1334 pub fn init(allocator: std.mem.Allocator, session: *Session) !Footer { 1335 return .{ 1336 .focus = try Focus.create(allocator, .container), 1337 .grid = null, 1338 .session = session, 1339 .arena = std.heap.ArenaAllocator.init(allocator), 1340 }; 1341 } 1342 1343 pub fn deinit(self: *Footer, allocator: std.mem.Allocator) void { 1344 self.focus.destroy(allocator); 1345 if (self.grid) |*grid| grid.deinit(); 1346 self.arena.deinit(); 1347 } 1348 1349 pub fn build(self: *Footer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1350 _ = root_focus; 1351 self.clearGrid(); 1352 const width = constraint.max_size.width orelse return; 1353 if (width == 0) return; 1354 1355 _ = self.arena.reset(.retain_capacity); 1356 const aa = self.arena.allocator(); 1357 const path = self.session.data.current_page.urlAlloc(&self.arena) catch return; 1358 const text = if (self.session.web_port) |port| 1359 std.fmt.allocPrint(aa, "http://localhost:{d}{s}", .{ port, path }) catch return 1360 else 1361 path; 1362 1363 var grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 1364 errdefer grid.deinit(); 1365 var utf8 = (std.unicode.Utf8View.init(text) catch return).iterator(); 1366 var i: usize = 0; 1367 while (utf8.nextCodepointSlice()) |ch| { 1368 if (i == width) break; 1369 grid.cells.items[try grid.cells.at(.{ 0, i })].rune = ch; 1370 i += 1; 1371 } 1372 self.grid = grid; 1373 } 1374 1375 pub fn input(self: *Footer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1376 _ = self; 1377 _ = allocator; 1378 _ = key; 1379 _ = root_focus; 1380 } 1381 1382 pub fn clearGrid(self: *Footer) void { 1383 if (self.grid) |*grid| { 1384 grid.deinit(); 1385 self.grid = null; 1386 } 1387 } 1388 1389 pub fn getGrid(self: Footer) ?Grid { 1390 return self.grid; 1391 } 1392 1393 pub fn getFocus(self: *Footer) *Focus { 1394 return self.focus; 1395 } 1396 }; 1397 1398 // a single-child wrapper that builds the child at its natural size and 1399 // positions its grid in the middle of the area granted by the parent 1400 pub const Center = struct { 1401 focus: *Focus, 1402 grid: ?Grid, 1403 child: *Widget, 1404 direction: Direction, 1405 1406 pub const Direction = enum { both, horiz, vert }; 1407 1408 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, direction: Direction) !Center { 1409 const child = try allocator.create(Widget); 1410 errdefer allocator.destroy(child); 1411 child.* = child_widget; 1412 return .{ 1413 .focus = try Focus.create(allocator, .container), 1414 .grid = null, 1415 .child = child, 1416 .direction = direction, 1417 }; 1418 } 1419 1420 pub fn deinit(self: *Center, allocator: std.mem.Allocator) void { 1421 self.focus.destroy(allocator); 1422 if (self.grid) |*grid| { 1423 grid.deinit(); 1424 self.grid = null; 1425 } 1426 self.child.deinit(allocator); 1427 allocator.destroy(self.child); 1428 } 1429 1430 pub fn build(self: *Center, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1431 self.clearGrid(); 1432 self.getFocus().clear(); 1433 1434 // build the child without forcing it to fill min_size; it sizes 1435 // itself to its content within the available max. 1436 try self.child.build(allocator, .{ 1437 .min_size = .{ .width = null, .height = null }, 1438 .max_size = constraint.max_size, 1439 }, root_focus); 1440 1441 const child_grid = self.child.getGrid() orelse return; 1442 1443 // prefer max when given; otherwise grow to max(min, child) so that 1444 // when only a min is set (e.g. the wasm path passes viewport rows 1445 // as min_height with no max), we can still vertically center while 1446 // letting taller content extend past the min. 1447 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; 1448 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; 1449 if (width == 0 or height == 0) return; 1450 1451 const offset_x: usize = if (self.direction == .horiz or self.direction == .both) 1452 (width -| child_grid.size.width) / 2 1453 else 1454 0; 1455 const offset_y: usize = if (self.direction == .vert or self.direction == .both) 1456 (height -| child_grid.size.height) / 2 1457 else 1458 0; 1459 1460 var grid = try Grid.init(allocator, .{ .width = width, .height = height }); 1461 errdefer grid.deinit(); 1462 try grid.drawGrid(child_grid, offset_x, offset_y); 1463 try self.getFocus().addChild(allocator, self.child.getFocus(), child_grid.size, offset_x, offset_y); 1464 self.getFocus().child_id = self.child.getFocus().id; 1465 1466 self.grid = grid; 1467 } 1468 1469 pub fn input(self: *Center, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1470 try self.child.input(allocator, key, root_focus); 1471 } 1472 1473 pub fn clearGrid(self: *Center) void { 1474 if (self.grid) |*grid| { 1475 grid.deinit(); 1476 self.grid = null; 1477 } 1478 self.child.clearGrid(); 1479 } 1480 1481 pub fn getGrid(self: Center) ?Grid { 1482 return self.grid; 1483 } 1484 1485 pub fn getFocus(self: *Center) *Focus { 1486 return self.focus; 1487 } 1488 }; 1489 1490 // renders truecolor ANSI art 1491 pub const AnsiArt = struct { 1492 focus: *Focus, 1493 grid: ?Grid, 1494 content: []const u8, 1495 1496 pub fn init(allocator: std.mem.Allocator, content: []const u8) !AnsiArt { 1497 return .{ 1498 .focus = try Focus.create(allocator, .container), 1499 .grid = null, 1500 .content = content, 1501 }; 1502 } 1503 1504 pub fn deinit(self: *AnsiArt, allocator: std.mem.Allocator) void { 1505 self.focus.destroy(allocator); 1506 if (self.grid) |*grid| { 1507 grid.deinit(); 1508 self.grid = null; 1509 } 1510 } 1511 1512 pub fn build(self: *AnsiArt, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1513 _ = root_focus; 1514 self.clearGrid(); 1515 1516 // parse into rows of cells, then copy into a rectangular grid 1517 var rows: std.ArrayList(std.ArrayList(Grid.Cell)) = .empty; 1518 defer { 1519 for (rows.items) |*row| row.deinit(allocator); 1520 rows.deinit(allocator); 1521 } 1522 var row: std.ArrayList(Grid.Cell) = .empty; 1523 errdefer row.deinit(allocator); 1524 1525 var style: Grid.Style = .{}; 1526 var width: usize = 0; 1527 const content = self.content; 1528 var i: usize = 0; 1529 while (i < content.len) { 1530 const byte = content[i]; 1531 if (byte == '\n') { 1532 width = @max(width, row.items.len); 1533 try rows.append(allocator, row); 1534 row = .empty; 1535 i += 1; 1536 } else if (byte == 0x1B and i + 1 < content.len and content[i + 1] == '[') { 1537 // CSI: scan to the final byte (0x40..0x7E); apply it if it's 'm' 1538 var j = i + 2; 1539 while (j < content.len and !(content[j] >= 0x40 and content[j] <= 0x7E)) j += 1; 1540 if (j >= content.len) { 1541 i = content.len; // malformed trailing escape; stop 1542 } else { 1543 if (content[j] == 'm') applySgr(&style, content[i + 2 .. j]); 1544 i = j + 1; 1545 } 1546 } else { 1547 const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; 1548 const end = @min(content.len, i + len); 1549 const rune = content[i..end]; 1550 const transparent = end - i == 1 and rune[0] == ' ' and 1551 style.fg == null and style.bg == null and !style.inverted; 1552 try row.append(allocator, .{ 1553 .rune = if (transparent) null else rune, 1554 .style = style, 1555 }); 1556 i = end; 1557 } 1558 } 1559 // a trailing row with content but no closing newline 1560 if (row.items.len > 0) { 1561 width = @max(width, row.items.len); 1562 try rows.append(allocator, row); 1563 } else { 1564 row.deinit(allocator); 1565 } 1566 row = .empty; // ownership moved into rows (or freed); keep errdefer safe 1567 1568 const height = rows.items.len; 1569 if (width == 0 or height == 0) return; 1570 1571 const clamped_w = @min(width, constraint.max_size.width orelse width); 1572 const clamped_h = @min(height, constraint.max_size.height orelse height); 1573 1574 var grid = try Grid.init(allocator, .{ .width = clamped_w, .height = clamped_h }); 1575 errdefer grid.deinit(); 1576 for (rows.items[0..clamped_h], 0..) |r, y| { 1577 const n = @min(r.items.len, clamped_w); 1578 for (r.items[0..n], 0..) |cell, x| { 1579 grid.cells.items[try grid.cells.at(.{ y, x })] = cell; 1580 } 1581 } 1582 self.grid = grid; 1583 } 1584 1585 pub fn input(self: *AnsiArt, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1586 _ = self; 1587 _ = allocator; 1588 _ = key; 1589 _ = root_focus; 1590 } 1591 1592 pub fn clearGrid(self: *AnsiArt) void { 1593 if (self.grid) |*grid| { 1594 grid.deinit(); 1595 self.grid = null; 1596 } 1597 } 1598 1599 pub fn getGrid(self: AnsiArt) ?Grid { 1600 return self.grid; 1601 } 1602 1603 pub fn getFocus(self: *AnsiArt) *Focus { 1604 return self.focus; 1605 } 1606 1607 fn applySgr(style: *Grid.Style, params: []const u8) void { 1608 var nums: [16]u32 = undefined; 1609 var n: usize = 0; 1610 var it = std.mem.splitScalar(u8, params, ';'); 1611 while (it.next()) |tok| { 1612 if (n >= nums.len) break; 1613 // an empty parameter (e.g. bare "\x1b[m") means 0 1614 nums[n] = std.fmt.parseInt(u32, tok, 10) catch 0; 1615 n += 1; 1616 } 1617 if (n == 0) { 1618 style.* = .{}; 1619 return; 1620 } 1621 var i: usize = 0; 1622 while (i < n) : (i += 1) { 1623 switch (nums[i]) { 1624 0 => style.* = .{}, 1625 7 => style.inverted = true, 1626 27 => style.inverted = false, 1627 39 => style.fg = null, 1628 49 => style.bg = null, 1629 38, 48 => { 1630 // truecolor form: 38;2;r;g;b — anything else is ignored 1631 if (i + 4 < n and nums[i + 1] == 2) { 1632 const c = Grid.Color{ 1633 .r = @truncate(nums[i + 2]), 1634 .g = @truncate(nums[i + 3]), 1635 .b = @truncate(nums[i + 4]), 1636 }; 1637 if (nums[i] == 38) style.fg = c else style.bg = c; 1638 i += 4; 1639 } 1640 }, 1641 else => {}, 1642 } 1643 } 1644 } 1645 }; 1646 1647 // a full-screen wrapper that renders ANSI art behind whatever page it wraps 1648 pub const AnsiBackground = struct { 1649 grid: ?Grid, 1650 child: *Widget, 1651 art: AnsiArt, 1652 session: *Session, 1653 1654 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, art_content: []const u8, session: *Session) !AnsiBackground { 1655 var cw = child_widget; 1656 const child = allocator.create(Widget) catch |e| { 1657 cw.deinit(allocator); 1658 return e; 1659 }; 1660 child.* = cw; 1661 errdefer { 1662 child.deinit(allocator); 1663 allocator.destroy(child); 1664 } 1665 return .{ .grid = null, .child = child, .art = try AnsiArt.init(allocator, art_content), .session = session }; 1666 } 1667 1668 pub fn deinit(self: *AnsiBackground, allocator: std.mem.Allocator) void { 1669 if (self.grid) |*grid| grid.deinit(); 1670 self.art.deinit(allocator); 1671 self.child.deinit(allocator); 1672 allocator.destroy(self.child); 1673 } 1674 1675 pub fn build(self: *AnsiBackground, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1676 self.clearGrid(); 1677 1678 // make the wrapped page fill the whole available area 1679 try self.child.build(allocator, .{ 1680 .min_size = .{ 1681 .width = constraint.max_size.width orelse constraint.min_size.width, 1682 .height = constraint.max_size.height orelse constraint.min_size.height, 1683 }, 1684 .max_size = constraint.max_size, 1685 }, root_focus); 1686 1687 if (self.session.data.enable_ansi) { 1688 if (self.child.getGrid()) |fg| { 1689 self.grid = try artBehind(allocator, fg, &self.art, .top_right, root_focus); 1690 } 1691 } 1692 } 1693 1694 pub fn input(self: *AnsiBackground, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1695 try self.child.input(allocator, key, root_focus); 1696 } 1697 1698 pub fn clearGrid(self: *AnsiBackground) void { 1699 if (self.grid) |*grid| { 1700 grid.deinit(); 1701 self.grid = null; 1702 } 1703 self.child.clearGrid(); 1704 self.art.clearGrid(); 1705 } 1706 1707 pub fn getGrid(self: AnsiBackground) ?Grid { 1708 return self.grid orelse self.child.getGrid(); 1709 } 1710 1711 pub fn getFocus(self: *AnsiBackground) *Focus { 1712 return self.child.getFocus(); 1713 } 1714 1715 // which top corner the art hugs. 1716 const ArtAnchor = enum { top_left, top_right }; 1717 1718 // a foreground cell counts as blank if it's empty or a bare space without styling 1719 fn cellIsBlank(cell: Grid.Cell) bool { 1720 if (cell.rune) |rune| { 1721 return std.mem.eql(u8, rune, " ") and 1722 cell.style.fg == null and cell.style.bg == null and !cell.style.inverted; 1723 } 1724 return true; 1725 } 1726 1727 // the art is dimmed to this fraction of its brightness so foreground text 1728 // stays legible over it 1729 const art_brightness = 45; // percent 1730 1731 fn dimColor(color: ?Grid.Color) ?Grid.Color { 1732 const c = color orelse return null; 1733 return .{ 1734 .r = @intCast(@as(u16, c.r) * art_brightness / 100), 1735 .g = @intCast(@as(u16, c.g) * art_brightness / 100), 1736 .b = @intCast(@as(u16, c.b) * art_brightness / 100), 1737 }; 1738 } 1739 1740 // terminals that don't support truecolor misparse a "38;2;r;g;b"/"48;2;…" 1741 // SGR as a list of plain SGR codes, so any channel value in 1..9 turns on 1742 // a text attribute (5/6 = blink, 7 = inverse, 8 = conceal, …). 1743 // snap such channels clear of that range so the backdrop doesn't blink or 1744 // hide text there; the ≤9/255 shift is imperceptible on terminals that 1745 // actually render truecolor. 1746 fn sgrSafe(color: ?Grid.Color) ?Grid.Color { 1747 const c = color orelse return null; 1748 const snap = struct { 1749 fn f(v: u8) u8 { 1750 return if (v >= 1 and v <= 9) 10 else v; 1751 } 1752 }.f; 1753 return .{ .r = snap(c.r), .g = snap(c.g), .b = snap(c.b) }; 1754 } 1755 1756 // composites ANSI art behind a foreground grid 1757 fn artBehind(allocator: std.mem.Allocator, foreground: Grid, art: *AnsiArt, anchor: ArtAnchor, root_focus: *Focus) !Grid { 1758 art.clearGrid(); 1759 try art.build(allocator, .{ 1760 .min_size = .{ .width = null, .height = null }, 1761 .max_size = .{ .width = foreground.size.width, .height = foreground.size.height }, 1762 }, root_focus); 1763 1764 var out = try Grid.initFromGrid(allocator, foreground, foreground.size, 0, 0); 1765 errdefer out.deinit(); 1766 1767 if (art.getGrid()) |art_grid| { 1768 const anchor_x = switch (anchor) { 1769 .top_left => 0, 1770 .top_right => foreground.size.width -| art_grid.size.width, 1771 }; 1772 for (0..art_grid.size.height) |y| { 1773 for (0..art_grid.size.width) |x| { 1774 const src = art_grid.cells.items[try art_grid.cells.at(.{ y, x })]; 1775 if (src.rune == null) continue; 1776 const idx = out.cells.at(.{ y, anchor_x + x }) catch continue; 1777 const dst = &out.cells.items[idx]; 1778 // composite the dimmed art behind every cell so it shows 1779 // through the whole UI: a blank cell becomes the art, while a 1780 // cell with a glyph keeps the glyph but takes the dimmed art as 1781 // its background (replacing whatever background it had, which 1782 // would otherwise obscure the art). 1783 if (cellIsBlank(dst.*)) { 1784 dst.* = src; 1785 dst.style.fg = sgrSafe(dimColor(dst.style.fg)); 1786 dst.style.bg = sgrSafe(dimColor(dst.style.bg)); 1787 } else { 1788 dst.style.bg = sgrSafe(dimColor(src.style.bg orelse src.style.fg)); 1789 } 1790 } 1791 } 1792 } 1793 return out; 1794 } 1795 }; 1796