╭ changed ────╮ │src/serve.zig│ ╰─────────────╯ ╭──────────────────────────────────────────────────────────────────╮ │11 ssh_listen: []const u8 = "127.0.0.1:8022", │ │12 wui_listen: []const u8 = "127.0.0.1:8000", │ │13 data_dir: []const u8 = ".", │ │14 + │ │15 + // the port from `wui_listen`, for the TUI footer's url. │ │16 + pub fn wuiPort(self: Options) !u16 { │ │17 + return (try parseListenAddress(self.wui_listen)).port;│ │18 + } │ │19 }; │ │20 │ │21 const ListenAddress = struct { │ ╰──────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 99 const ssh_session_handler = serve_ssh.SessionHandler{ │ │100 .admin_repo_path = admin_repo_path, │ │101 .repo_root_path = repo_root_path, │ │102 + .wui_port = wui_listen_address.port, │ │103 .err = err, │ │104 }; │ │105 serve_ssh.runListener(io, allocator, &host_key, &ssh_session_handler, &ssh_server, &tasks, err);│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │13 pub const SessionHandler = struct { │ │14 admin_repo_path: []const u8, │ │15 repo_root_path: []const u8, │ │16 + wui_port: u16, // port the web UI is served on, shown in the TUI footer's url │ │17 err: *std.Io.Writer, │ │18 │ │19 pub fn handleSession(self: *const SessionHandler, sess: *ssh.SessionCtx, request: ssh.Request) !void {│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │119 defer repo.deinit(io, allocator); │ │120 var ui_session = try ui.Session.init(&session_arena, &repo, .{}); │ │121 ui_session.is_terminal = true; │ │122 + ui_session.web_port = handler.wui_port; │ │123 // let page builders open on-disk repos (sibling "repos" dir of the admin repo) │ │124 ui_session.io = io; │ │125 ui_session.repos_dir = try std.fs.path.join(session_arena.allocator(), &.{ std.fs.path.dirname(handler.admin_repo_path) orelse ".", "repos" });│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │291 break :blk try ui.Session.init(&session_arena, &repo, .{}); │ │292 }; │ │293 session.is_terminal = true; │ │294 + // try uses the default serve options below, so the footer's url points at│ │295 + // the default web UI port. │ │296 + session.web_port = try (srv.Options{}).wuiPort(); │ │297 │ │298 // start the server │ │299 │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │353 // does not navigate. │ │354 next_page: ?RoutablePage = null, │ │355 is_terminal: bool = false, // true on remote SSH and local TUI │ │356 + // port the web UI is served on, for the TUI/SSH footer's "http://localhost:<port>..."│ │357 + // url. null on the web itself (no footer there). │ │358 + web_port: ?u16 = null, │ │359 quit_requested: bool = false, │ │360 │ │361 const Self = @This(); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │709 │ │710 const demon_art = @embedFile("embed/demon.ans"); │ │711 │ │709 - var root = Widget{ .background = try AnsiBackground.init(allocator, page_widget, demon_art, session) }; │ │712 + // on the TUI/SSH, the page sits above a one-row footer showing the url │ │713 + var root = if (session.is_terminal) blk: { │ │714 + var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │715 + errdefer box.deinit(allocator); │ │716 + const bg_id = bg_blk: { │ │717 + var background = try AnsiBackground.init(allocator, page_widget, demon_art, session); │ │718 + errdefer background.deinit(allocator); │ │719 + const id = background.getFocus().id; │ │720 + try box.children.put(allocator, id, .{ .widget = .{ .background = background }, .rect = null, .min_size = null }); │ │721 + break :bg_blk id; │ │722 + }; │ │723 + { │ │724 + var footer = try Footer.init(allocator, session); │ │725 + errdefer footer.deinit(allocator); │ │726 + try box.children.put(allocator, footer.getFocus().id, .{ .widget = .{ .footer = footer }, .rect = null, .min_size = .{ .width = null, .height = 1 } });│ │727 + } │ │728 + box.getFocus().child_id = bg_id; │ │729 + break :blk Widget{ .box = box }; │ │730 + } else Widget{ .background = try AnsiBackground.init(allocator, page_widget, demon_art, session) }; │ │731 errdefer root.deinit(allocator); │ │732 │ │733 // input-owning views build their TextInputs in init — so reset the │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │773 auth_tab: Home.Header.AuthTab.View, │ │774 home_settings: Home.Settings.View, │ │775 home_auth: Home.Auth.View, │ │776 + footer: Footer, │ │777 │ │778 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void {│ │779 switch (self.*) { │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1136 return self.focus; │ │1137 } │ │1138 }; │ │1139 + │ │1140 +// a one-row, non-focusable status bar showing the current page's url │ │1141 +pub const Footer = struct { │ │1142 + focus: *Focus, │ │1143 + grid: ?Grid, │ │1144 + session: *Session, │ │1145 + arena: std.heap.ArenaAllocator, │ │1146 + │ │1147 + pub fn init(allocator: std.mem.Allocator, session: *Session) !Footer { │ │1148 + return .{ │ │1149 + .focus = try Focus.create(allocator, .container), │ │1150 + .grid = null, │ │1151 + .session = session, │ │1152 + .arena = std.heap.ArenaAllocator.init(allocator), │ │1153 + }; │ │1154 + } │ │1155 + │ │1156 + pub fn deinit(self: *Footer, allocator: std.mem.Allocator) void { │ │1157 + self.focus.destroy(allocator); │ │1158 + if (self.grid) |*grid| grid.deinit(); │ │1159 + self.arena.deinit(); │ │1160 + } │ │1161 │ │1162 + pub fn build(self: *Footer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void {│ │1163 + _ = root_focus; │ │1164 + self.clearGrid(); │ │1165 + const width = constraint.max_size.width orelse return; │ │1166 + if (width == 0) return; │ │1167 + │ │1168 + _ = self.arena.reset(.retain_capacity); │ │1169 + const aa = self.arena.allocator(); │ │1170 + const path = self.session.data.current_page.urlAlloc(&self.arena) catch return; │ │1171 + const text = if (self.session.web_port) |port| │ │1172 + std.fmt.allocPrint(aa, "http://localhost:{d}{s}", .{ port, path }) catch return │ │1173 + else │ │1174 + path; │ │1175 + │ │1176 + var grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); │ │1177 + errdefer grid.deinit(); │ │1178 + var utf8 = (std.unicode.Utf8View.init(text) catch return).iterator(); │ │1179 + var i: usize = 0; │ │1180 + while (utf8.nextCodepointSlice()) |ch| { │ │1181 + if (i == width) break; │ │1182 + grid.cells.items[try grid.cells.at(.{ 0, i })].rune = ch; │ │1183 + i += 1; │ │1184 + } │ │1185 + self.grid = grid; │ │1186 + } │ │1187 + │ │1188 + pub fn input(self: *Footer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ │1189 + _ = self; │ │1190 + _ = allocator; │ │1191 + _ = key; │ │1192 + _ = root_focus; │ │1193 + } │ │1194 + │ │1195 + pub fn clearGrid(self: *Footer) void { │ │1196 + if (self.grid) |*grid| { │ │1197 + grid.deinit(); │ │1198 + self.grid = null; │ │1199 + } │ │1200 + } │ │1201 + │ │1202 + pub fn getGrid(self: Footer) ?Grid { │ │1203 + return self.grid; │ │1204 + } │ │1205 + │ │1206 + pub fn getFocus(self: *Footer) *Focus { │ │1207 + return self.focus; │ │1208 + } │ │1209 +}; │ │1210 + │ │1211 // a single-child wrapper that builds the child at its natural size and │ │1212 // positions its grid in the middle of the area granted by the parent │ │1213 pub const Center = struct { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯