╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────╮ │21 pub const Title = @import("./ui/Title.zig"); │ │22 pub const SubTitle = @import("./ui/SubTitle.zig"); │ │23 pub const Quit = @import("./ui/Quit.zig"); │ │24 -pub const CopyableText = @import("./ui/CopyableText.zig");│ │24 pub const Unauthorized = @import("./ui/Unauthorized.zig");│ │25 +pub const widget = @import("./ui/widget.zig"); │ │26 +pub const Widget = widget.Widget; │ │27 │ │28 pub const clipped_bottom_label_max_len = 10 * 4; │ │29 │ ╰──────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1765 session.host_request = .{ .show_copyable_text = try terminalWebUrl(session.page_arena.allocator(), session, url) };│ │1766 } │ │1767 │ │1767 -fn terminalWebUrl(allocator: std.mem.Allocator, session: *const Session, path: []const u8) ![]const u8 { │ │1768 +pub fn terminalWebUrl(allocator: std.mem.Allocator, session: *const Session, path: []const u8) ![]const u8 { │ │1769 return if (session.web_port) |port| │ │1770 try std.fmt.allocPrint(allocator, "http://localhost:{d}{s}", .{ port, path }) │ │1771 else │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2028 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │2029 errdefer box.deinit(allocator); │ │2030 const bg_id = bg_blk: { │ │2030 - var background = try AnsiBackground.init(allocator, page_widget, session); │ │2031 + var background = try widget.AnsiBackground.init(allocator, page_widget, session); │ │2032 errdefer background.deinit(allocator); │ │2033 const id = background.getFocus().id; │ │2034 try box.children.put(allocator, id, .{ .widget = .{ .background = background }, .rect = null, .min_size = null });│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2035 break :bg_blk id; │ │2036 }; │ │2037 { │ │2037 - var footer = try Footer.init(allocator, session); │ │2038 + var footer = try widget.Footer.init(allocator, session); │ │2039 errdefer footer.deinit(allocator); │ │2040 try box.children.put(allocator, footer.getFocus().id, .{ .widget = .{ .footer = footer }, .rect = null, .min_size = .{ .width = null, .height = 1 } });│ │2041 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2042 box.getFocus().child_id = bg_id; │ │2043 break :blk Widget{ .box = box }; │ │2043 - } else Widget{ .background = try AnsiBackground.init(allocator, page_widget, session) }; │ │2044 + } else Widget{ .background = try widget.AnsiBackground.init(allocator, page_widget, session) };│ │2045 errdefer root.deinit(allocator); │ │2046 │ │2047 // input-owning views build their TextInputs in init, so reset their │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2065 var random: usize = undefined; │ │2066 io.random(std.mem.asBytes(&random)); │ │2067 session.data.ansi_art = ansi_arts[random % ansi_arts.len]; │ │2067 -} │ │2068 - │ │2069 -pub const Widget = union(enum) { │ │2070 - text: wgt.Text(Widget), │ │2071 - box: wgt.Box(Widget), │ │2072 - text_box: wgt.TextBox(Widget), │ │2073 - text_input: wgt.TextInput(Widget), │ │2074 - scroll: wgt.Scroll(Widget), │ │2075 - stack: wgt.Stack(Widget), │ │2076 - flow_box: FlowBox, │ │2077 - flow_box_scroll: FlowBox.Scroll, │ │2078 - tag_flow: TagFlow, │ │2079 - spacer: Spacer, │ │2080 - center: Center, │ │2081 - section_label: SectionLabel, │ │2082 - submit_button: SubmitButton, │ │2083 - background: AnsiBackground, │ │2084 - home: Home.View, │ │2085 - user: User.View, │ │2086 - repo: Repo.View, │ │2087 - quit: Quit.View, │ │2088 - unauthorized: Unauthorized.View, │ │2089 - title: Title.View, │ │2090 - sub_title: SubTitle.View, │ │2091 - home_header: Home.Header.View, │ │2092 - user_header: User.Header.View, │ │2093 - repo_header: Repo.Header.View, │ │2094 - repo_files_header: Repo.Files.Header.View, │ │2095 - repo_commits_header: Repo.Commits.Header.View, │ │2096 - repo_issues_header: Repo.Issues.Header, │ │2097 - repo_patches_header: Repo.Patches.Header, │ │2098 - repo_discussions_header: Repo.Discussions.Header, │ │2099 - repo_files: Repo.Files.View, │ │2100 - repo_commits: Repo.Commits.View, │ │2101 - repo_refs: Repo.Refs.View, │ │2102 - repo_issues: Repo.Issues.View, │ │2103 - repo_patches: Repo.Patches.View, │ │2104 - repo_discussions: Repo.Discussions.View, │ │2105 - repo_events_header: Repo.Events.Header.View, │ │2106 - repo_events: Repo.Events.View, │ │2107 - repo_comment: Repo.Comment.Item, │ │2108 - copyable_text: CopyableText.View, │ │2109 - home_users: Home.Users.View, │ │2110 - home_repos: Home.Repos.View, │ │2111 - auth_tab: Home.Header.AuthTab.View, │ │2112 - home_settings: Home.Settings.View, │ │2113 - home_auth: Home.Auth.View, │ │2114 - footer: Footer, │ │2115 - │ │2116 - pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void { │ │2117 - switch (self.*) { │ │2118 - inline else => |*case| case.deinit(allocator), │ │2119 - } │ │2120 - } │ │2121 - │ │2122 - pub fn build(self: *Widget, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { │ │2123 - switch (self.*) { │ │2124 - inline else => |*case| try case.build(allocator, constraint, root_focus), │ │2125 - } │ │2126 - } │ │2127 - │ │2128 - pub fn input(self: *Widget, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) anyerror!void { │ │2129 - switch (self.*) { │ │2130 - inline else => |*case| try case.input(allocator, key, root_focus), │ │2131 - } │ │2132 - } │ │2133 - │ │2134 - pub fn clearGrid(self: *Widget) void { │ │2135 - switch (self.*) { │ │2136 - inline else => |*case| case.clearGrid(), │ │2137 - } │ │2138 - } │ │2139 - │ │2140 - pub fn getGrid(self: Widget) ?Grid { │ │2141 - switch (self) { │ │2142 - inline else => |*case| return case.getGrid(), │ │2143 - } │ │2144 - } │ │2145 - │ │2146 - pub fn getFocus(self: *Widget) *Focus { │ │2147 - switch (self.*) { │ │2148 - inline else => |*case| return case.getFocus(), │ │2149 - } │ │2150 - } │ │2151 -}; │ │2152 - │ │2153 -// move the focused row in `box` by `delta`, clamped to the ends, scrolling │ │2154 -// `scroll` to keep it visible. │ │2155 -pub fn moveRowFocus(box: *wgt.Box(Widget), scroll: *wgt.Scroll(Widget), root_focus: *Focus, delta: isize) void { │ │2156 - const keys = box.children.keys(); │ │2157 - if (keys.len == 0) return; │ │2158 - const cur_id = box.getFocus().child_id orelse return; │ │2159 - const cur: isize = @intCast(box.children.getIndex(cur_id) orelse return); │ │2160 - const last: isize = @intCast(keys.len - 1); │ │2161 - const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); │ │2162 - if (next == @as(usize, @intCast(cur))) return; │ │2163 - root_focus.setFocus(keys[next]); │ │2164 - if (box.children.values()[next].rect) |rect| scroll.scrollToRect(rect); │ │2068 } │ │2166 - │ │2167 -pub const FlowBox = struct { │ │2168 - focus: *Focus, │ │2169 - grid: ?Grid, │ │2170 - text_boxes: std.ArrayList(wgt.TextBox(Widget)), │ │2171 - // backs each link item's `.custom` focus kind (e.g. "a:/user/foo"). reset │ │2172 - // wholesale on each setItems so there's no per-string ownership to track. │ │2173 - arena: std.heap.ArenaAllocator, │ │2174 - // column count from the last build — FlowBox.Scroll.input uses it so arrow │ │2175 - // up/down can step by a row's worth of items. │ │2176 - last_cols: usize, │ │2177 - options: Options, │ │2178 - │ │2179 - const border_rows: usize = 2; │ │2180 - │ │2181 - pub const Options = struct { │ │2182 - cell_width: usize = 40, │ │2183 - cell_height: usize = 3, │ │2184 - }; │ │2185 - │ │2186 - pub fn init(allocator: std.mem.Allocator, options: Options) !FlowBox { │ │2187 - return .{ │ │2188 - .focus = try Focus.create(allocator, .container), │ │2189 - .grid = null, │ │2190 - .text_boxes = .empty, │ │2191 - .arena = std.heap.ArenaAllocator.init(allocator), │ │2192 - .last_cols = 1, │ │2193 - .options = options, │ │2194 - }; │ │2195 - } │ │2196 - │ │2197 - pub fn deinit(self: *FlowBox, allocator: std.mem.Allocator) void { │ │2198 - self.focus.destroy(allocator); │ │2199 - if (self.grid) |*grid| { │ │2200 - grid.deinit(); │ │2201 - self.grid = null; │ │2202 - } │ │2203 - for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │2204 - self.text_boxes.deinit(allocator); │ │2205 - self.arena.deinit(); │ │2206 - } │ │2207 - │ │2208 - // one flow item: its display text plus an optional link. a non-empty `link` │ │2209 - // sets the item's focus kind such as `.{ .custom = "a:/user/foo" }`, which │ │2210 - // the web renderer turns into an anchor. │ │2211 - pub const Item = struct { text: []const u8, link: []const u8 = "" }; │ │2212 - │ │2213 - // the text box copies the item text; links are copied into the arena, so the │ │2214 - // caller's slices needn't outlive this call. │ │2215 - pub fn setItems(self: *FlowBox, allocator: std.mem.Allocator, items: []const Item) !void { │ │2216 - for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │2217 - self.text_boxes.clearAndFree(allocator); │ │2218 - │ │2219 - // the old text boxes and their borrowed links are gone now, so the link │ │2220 - // arena can be reclaimed. │ │2221 - _ = self.arena.reset(.retain_capacity); │ │2222 - const aa = self.arena.allocator(); │ │2223 - │ │2224 - self.focus.clear(); │ │2225 - self.focus.child_id = null; │ │2226 - │ │2227 - for (items) |item| { │ │2228 - var text_box = try wgt.TextBox(Widget).init(allocator, item.text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); │ │2229 - errdefer text_box.deinit(allocator); │ │2230 - text_box.getFocus().focusable = true; │ │2231 - │ │2232 - if (item.link.len > 0) { │ │2233 - text_box.getFocus().kind = .{ .custom = try aa.dupe(u8, item.link) }; │ │2234 - } │ │2235 - │ │2236 - try self.text_boxes.append(allocator, text_box); │ │2237 - } │ │2238 - │ │2239 - if (self.text_boxes.items.len > 0) { │ │2240 - self.focus.child_id = self.text_boxes.items[0].getFocus().id; │ │2241 - } │ │2242 - } │ │2243 - │ │2244 - pub fn build(self: *FlowBox, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2245 - self.clearGrid(); │ │2246 - self.focus.clear(); │ │2247 - │ │2248 - const cell_width = self.options.cell_width; │ │2249 - const max_width = constraint.max_size.width orelse cell_width; │ │2250 - const cols = if (cell_width == 0) 1 else @max(1, max_width / cell_width); │ │2251 - self.last_cols = cols; │ │2252 - │ │2253 - const count = self.text_boxes.items.len; │ │2254 - if (count == 0) return; │ │2255 - const slot_height = self.options.cell_height + border_rows; │ │2256 - if (slot_height == 0) return; │ │2257 - │ │2258 - // build at the slot size so every text box fits a single grid cell │ │2259 - for (self.text_boxes.items) |*tb| { │ │2260 - tb.options.border_style = if (self.focus.child_id == tb.getFocus().id) .single else .hidden; │ │2261 - try tb.build(allocator, .{ │ │2262 - .min_size = .{ .width = cell_width, .height = null }, │ │2263 - .max_size = .{ .width = cell_width, .height = slot_height }, │ │2264 - }, root_focus); │ │2265 - } │ │2266 - │ │2267 - const rows = (count + cols - 1) / cols; │ │2268 - const content_height = rows * slot_height; │ │2269 - const total_width = cols * cell_width; │ │2270 - if (total_width == 0 or content_height == 0) return; │ │2271 - │ │2272 - var grid = try Grid.init(allocator, .{ .width = total_width, .height = content_height }); │ │2273 - errdefer grid.deinit(); │ │2274 - │ │2275 - for (self.text_boxes.items, 0..) |*tb, i| { │ │2276 - const tb_grid = tb.getGrid() orelse continue; │ │2277 - const col = i % cols; │ │2278 - const row = i / cols; │ │2279 - const cell_x = col * cell_width; │ │2280 - const cell_y = row * slot_height; │ │2281 - try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, cell_x, cell_y); │ │2282 - try grid.drawGrid(tb_grid, cell_x, cell_y); │ │2283 - } │ │2284 - │ │2285 - self.grid = grid; │ │2286 - } │ │2287 - │ │2288 - pub fn input(self: *FlowBox, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2289 - _ = self; │ │2290 - _ = allocator; │ │2291 - _ = key; │ │2292 - _ = root_focus; │ │2293 - } │ │2294 - │ │2295 - pub fn clearGrid(self: *FlowBox) void { │ │2296 - if (self.grid) |*grid| { │ │2297 - grid.deinit(); │ │2298 - self.grid = null; │ │2299 - } │ │2300 - for (self.text_boxes.items) |*tb| tb.clearGrid(); │ │2301 - } │ │2302 - │ │2303 - pub fn getGrid(self: FlowBox) ?Grid { │ │2304 - return self.grid; │ │2305 - } │ │2306 - │ │2307 - pub fn getFocus(self: *FlowBox) *Focus { │ │2308 - return self.focus; │ │2309 - } │ │2310 - │ │2311 - pub fn cellRect(self: FlowBox, index: usize) ?layout.IRect { │ │2312 - if (self.last_cols == 0 or index >= self.text_boxes.items.len) return null; │ │2313 - const slot_height = self.options.cell_height + border_rows; │ │2314 - const col = index % self.last_cols; │ │2315 - const row = index / self.last_cols; │ │2316 - return .{ │ │2317 - .x = @intCast(col * self.options.cell_width), │ │2318 - .y = @intCast(row * slot_height), │ │2319 - .size = .{ .width = self.options.cell_width, .height = slot_height }, │ │2320 - }; │ │2321 - } │ │2322 - │ │2323 - pub fn indexOfFocusId(self: FlowBox, focus_id: usize) ?usize { │ │2324 - for (self.text_boxes.items, 0..) |tb, i| { │ │2325 - if (tb.box.focus.id == focus_id) return i; │ │2326 - } │ │2327 - return null; │ │2328 - } │ │2329 - │ │2330 - pub const Scroll = struct { │ │2331 - scroll: wgt.Scroll(Widget), │ │2332 - │ │2333 - pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options, web_native: bool) !Scroll { │ │2334 - var layout_inner = try FlowBox.init(allocator, options); │ │2335 - errdefer layout_inner.deinit(allocator); │ │2336 - var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .{ .direction = .vert, .web_native = web_native }); │ │2337 - errdefer scroll.deinit(allocator); │ │2338 - return .{ .scroll = scroll }; │ │2339 - } │ │2340 - │ │2341 - pub fn deinit(self: *Scroll, allocator: std.mem.Allocator) void { │ │2342 - self.scroll.deinit(allocator); │ │2343 - } │ │2344 - │ │2345 - pub fn setItems(self: *Scroll, allocator: std.mem.Allocator, items: []const Item) !void { │ │2346 - self.scroll.x = 0; │ │2347 - self.scroll.y = 0; │ │2348 - try self.inner().setItems(allocator, items); │ │2349 - } │ │2350 - │ │2351 - pub fn build(self: *Scroll, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2352 - try self.scroll.build(allocator, constraint, root_focus); │ │2353 - } │ │2354 - │ │2355 - pub fn input(self: *Scroll, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2356 - _ = allocator; │ │2357 - const in = self.inner(); │ │2358 - const child_id = in.focus.child_id orelse return; │ │2359 - const current_index = in.indexOfFocusId(child_id) orelse return; │ │2360 - const count = in.text_boxes.items.len; │ │2361 - if (count == 0) return; │ │2362 - const cols = in.last_cols; │ │2363 - const slot_height = in.options.cell_height + FlowBox.border_rows; │ │2364 - │ │2365 - var index = current_index; │ │2366 - switch (key) { │ │2367 - .arrow_up => index -|= cols, │ │2368 - .arrow_down => if (index + cols < count) { │ │2369 - index += cols; │ │2370 - }, │ │2371 - .arrow_left => index -|= 1, │ │2372 - .arrow_right => if (index + 1 < count) { │ │2373 - index += 1; │ │2374 - }, │ │2375 - .home => index = 0, │ │2376 - .end => index = count - 1, │ │2377 - .page_up => { │ │2378 - if (self.scroll.grid) |grid| if (slot_height > 0) { │ │2379 - const rows_per_page = grid.size.height / slot_height; │ │2380 - index -|= rows_per_page * cols; │ │2381 - }; │ │2382 - }, │ │2383 - .page_down => { │ │2384 - if (self.scroll.grid) |grid| if (slot_height > 0) { │ │2385 - const rows_per_page = grid.size.height / slot_height; │ │2386 - index = @min(index + rows_per_page * cols, count - 1); │ │2387 - }; │ │2388 - }, │ │2389 - // scroll wheel moves the focused cell by a full row so the │ │2390 - // viewport (via scrollToRect) follows in row-sized steps, │ │2391 - // matching how a scroll wheel feels in a grid view. │ │2392 - .mouse => |mouse| switch (mouse.action) { │ │2393 - .scroll => |dir| switch (dir) { │ │2394 - .up => index -|= cols, │ │2395 - .down => if (index + cols < count) { │ │2396 - index += cols; │ │2397 - }, │ │2398 - }, │ │2399 - else => {}, │ │2400 - }, │ │2401 - else => {}, │ │2402 - } │ │2403 - │ │2404 - if (index != current_index) { │ │2405 - root_focus.setFocus(in.text_boxes.items[index].getFocus().id); │ │2406 - if (in.cellRect(index)) |rect| { │ │2407 - self.scroll.scrollToRect(rect); │ │2408 - } │ │2409 - } │ │2410 - } │ │2411 - │ │2412 - pub fn clearGrid(self: *Scroll) void { │ │2413 - self.scroll.clearGrid(); │ │2414 - } │ │2415 - │ │2416 - pub fn getGrid(self: Scroll) ?Grid { │ │2417 - return self.scroll.getGrid(); │ │2418 - } │ │2419 - │ │2420 - pub fn getFocus(self: *Scroll) *Focus { │ │2421 - return self.scroll.getFocus(); │ │2422 - } │ │2423 - │ │2424 - pub fn getSelectedIndex(self: Scroll) ?usize { │ │2425 - const in = self.scroll.child.flow_box; │ │2426 - const child_id = in.focus.child_id orelse return null; │ │2427 - return in.indexOfFocusId(child_id); │ │2428 - } │ │2429 - │ │2430 - fn inner(self: *Scroll) *FlowBox { │ │2431 - return &self.scroll.child.flow_box; │ │2432 - } │ │2433 - }; │ │2434 -}; │ │2435 - │ │2436 -// a left-to-right flow of variable-width focusable items that wraps at the │ │2437 -// available width, like words in a paragraph. unlike FlowBox there is no cell │ │2438 -// grid: each item is as wide as its text. selection movement is driven by the │ │2439 -// owning view via indexOfFocusId/verticalNeighbor. │ │2440 -pub const TagFlow = struct { │ │2441 - focus: *Focus, │ │2442 - grid: ?Grid, │ │2443 - text_boxes: std.ArrayList(wgt.TextBox(Widget)), │ │2444 - // last build's item rects (content space), for vertical navigation and │ │2445 - // scroll-into-view. │ │2446 - rects: std.ArrayList(layout.IRect), │ │2447 - // backs each link item's `.custom` focus kind. │ │2448 - arena: std.heap.ArenaAllocator, │ │2449 - │ │2450 - pub const Item = struct { text: []const u8, link: []const u8 = "" }; │ │2451 - │ │2452 - pub fn init(allocator: std.mem.Allocator) !TagFlow { │ │2453 - return .{ │ │2454 - .focus = try Focus.create(allocator, .container), │ │2455 - .grid = null, │ │2456 - .text_boxes = .empty, │ │2457 - .rects = .empty, │ │2458 - .arena = std.heap.ArenaAllocator.init(allocator), │ │2459 - }; │ │2460 - } │ │2461 - │ │2462 - pub fn deinit(self: *TagFlow, allocator: std.mem.Allocator) void { │ │2463 - self.focus.destroy(allocator); │ │2464 - if (self.grid) |*grid| { │ │2465 - grid.deinit(); │ │2466 - self.grid = null; │ │2467 - } │ │2468 - for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │2469 - self.text_boxes.deinit(allocator); │ │2470 - self.rects.deinit(allocator); │ │2471 - self.arena.deinit(); │ │2472 - } │ │2473 - │ │2474 - // the text box copies the item text; links are copied into the arena, so the │ │2475 - // caller's slices needn't outlive this call. │ │2476 - pub fn setItems(self: *TagFlow, allocator: std.mem.Allocator, items: []const Item) !void { │ │2477 - for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │2478 - self.text_boxes.clearAndFree(allocator); │ │2479 - │ │2480 - _ = self.arena.reset(.retain_capacity); │ │2481 - const aa = self.arena.allocator(); │ │2482 - │ │2483 - self.focus.clear(); │ │2484 - self.focus.child_id = null; │ │2485 - │ │2486 - for (items) |item| { │ │2487 - var text_box = try wgt.TextBox(Widget).init(allocator, item.text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │2488 - errdefer text_box.deinit(allocator); │ │2489 - text_box.getFocus().focusable = true; │ │2490 - │ │2491 - if (item.link.len > 0) { │ │2492 - text_box.getFocus().kind = .{ .custom = try aa.dupe(u8, item.link) }; │ │2493 - } │ │2494 - │ │2495 - try self.text_boxes.append(allocator, text_box); │ │2496 - } │ │2497 - │ │2498 - if (self.text_boxes.items.len > 0) { │ │2499 - self.focus.child_id = self.text_boxes.items[0].getFocus().id; │ │2500 - } │ │2501 - } │ │2502 - │ │2503 - pub fn build(self: *TagFlow, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2504 - self.clearGrid(); │ │2505 - self.focus.clear(); │ │2506 - │ │2507 - if (self.text_boxes.items.len == 0) return; │ │2508 - const max_width = constraint.max_size.width orelse 80; │ │2509 - if (max_width == 0) return; │ │2510 - │ │2511 - // lay the items out left to right, wrapping to a new row when the next │ │2512 - // one wouldn't fit. │ │2513 - self.rects.clearRetainingCapacity(); │ │2514 - var x: usize = 0; │ │2515 - var y: usize = 0; │ │2516 - var row_height: usize = 0; │ │2517 - var total_width: usize = 0; │ │2518 - for (self.text_boxes.items) |*tb| { │ │2519 - try tb.build(allocator, .{ │ │2520 - .min_size = .{ .width = null, .height = null }, │ │2521 - .max_size = .{ .width = max_width, .height = null }, │ │2522 - }, root_focus); │ │2523 - const tb_grid_maybe = tb.getGrid(); │ │2524 - const w: usize = if (tb_grid_maybe) |g| g.size.width else 0; │ │2525 - const h: usize = if (tb_grid_maybe) |g| g.size.height else 0; │ │2526 - if (x > 0 and x + w > max_width) { │ │2527 - x = 0; │ │2528 - y += row_height; │ │2529 - row_height = 0; │ │2530 - } │ │2531 - try self.rects.append(allocator, .{ .x = @intCast(x), .y = @intCast(y), .size = .{ .width = w, .height = h } }); │ │2532 - if (h > row_height) row_height = h; │ │2533 - x += w; │ │2534 - if (x > total_width) total_width = x; │ │2535 - } │ │2536 - │ │2537 - const total_height = y + row_height; │ │2538 - if (total_width == 0 or total_height == 0) return; │ │2539 - │ │2540 - var grid = try Grid.init(allocator, .{ .width = total_width, .height = total_height }); │ │2541 - errdefer grid.deinit(); │ │2542 - │ │2543 - for (self.text_boxes.items, self.rects.items) |*tb, rect| { │ │2544 - const tb_grid = tb.getGrid() orelse continue; │ │2545 - try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, @intCast(rect.x), @intCast(rect.y)); │ │2546 - try grid.drawGrid(tb_grid, @intCast(rect.x), @intCast(rect.y)); │ │2547 - } │ │2548 - │ │2549 - self.grid = grid; │ │2550 - } │ │2551 - │ │2552 - pub fn input(self: *TagFlow, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2553 - _ = self; │ │2554 - _ = allocator; │ │2555 - _ = key; │ │2556 - _ = root_focus; │ │2557 - } │ │2558 - │ │2559 - pub fn clearGrid(self: *TagFlow) void { │ │2560 - if (self.grid) |*grid| { │ │2561 - grid.deinit(); │ │2562 - self.grid = null; │ │2563 - } │ │2564 - for (self.text_boxes.items) |*tb| tb.clearGrid(); │ │2565 - } │ │2566 - │ │2567 - pub fn getGrid(self: TagFlow) ?Grid { │ │2568 - return self.grid; │ │2569 - } │ │2570 - │ │2571 - pub fn getFocus(self: *TagFlow) *Focus { │ │2572 - return self.focus; │ │2573 - } │ │2574 - │ │2575 - pub fn indexOfFocusId(self: TagFlow, focus_id: usize) ?usize { │ │2576 - for (self.text_boxes.items, 0..) |tb, i| { │ │2577 - if (tb.box.focus.id == focus_id) return i; │ │2578 - } │ │2579 - return null; │ │2580 - } │ │2581 - │ │2582 - // the first item of the row below (when `downward`) or above, or null at │ │2583 - // the flow's edge. items are laid out in order, so y is nondecreasing. │ │2584 - pub fn rowStep(self: TagFlow, index: usize, downward: bool) ?usize { │ │2585 - const rects = self.rects.items; │ │2586 - if (index >= rects.len) return null; │ │2587 - const cur_y = rects[index].y; │ │2588 - if (downward) { │ │2589 - for (rects[index + 1 ..], index + 1..) |r, i| { │ │2590 - if (r.y > cur_y) return i; │ │2591 - } │ │2592 - return null; │ │2593 - } │ │2594 - // walk the earlier rows; the last one visited is the row above. │ │2595 - var prev_first: ?usize = null; │ │2596 - var i: usize = 0; │ │2597 - while (i < rects.len and rects[i].y < cur_y) { │ │2598 - prev_first = i; │ │2599 - const y = rects[i].y; │ │2600 - while (i < rects.len and rects[i].y == y) i += 1; │ │2601 - } │ │2602 - return prev_first; │ │2603 - } │ │2604 -}; │ │2605 - │ │2606 -// an invisible widget that fills the horizontal space granted by its parent. │ │2607 -// used inside Box(horiz) with a min_size so the box reserves space for the │ │2608 -// children that follow, pushing them to the right. │ │2609 -pub const Spacer = struct { │ │2610 - focus: *Focus, │ │2611 - grid: ?Grid, │ │2612 - │ │2613 - pub fn init(allocator: std.mem.Allocator) !Spacer { │ │2614 - return .{ │ │2615 - .focus = try Focus.create(allocator, .container), │ │2616 - .grid = null, │ │2617 - }; │ │2618 - } │ │2619 - │ │2620 - pub fn deinit(self: *Spacer, allocator: std.mem.Allocator) void { │ │2621 - self.focus.destroy(allocator); │ │2622 - if (self.grid) |*grid| { │ │2623 - grid.deinit(); │ │2624 - self.grid = null; │ │2625 - } │ │2626 - } │ │2627 - │ │2628 - pub fn build(self: *Spacer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2629 - _ = root_focus; │ │2630 - self.clearGrid(); │ │2631 - const width = constraint.max_size.width orelse return; │ │2632 - if (width == 0) return; │ │2633 - self.grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); │ │2634 - } │ │2635 - │ │2636 - pub fn input(self: *Spacer, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2637 - _ = self; │ │2638 - _ = allocator; │ │2639 - _ = key; │ │2640 - _ = root_focus; │ │2641 - } │ │2642 - │ │2643 - pub fn clearGrid(self: *Spacer) void { │ │2644 - if (self.grid) |*grid| { │ │2645 - grid.deinit(); │ │2646 - self.grid = null; │ │2647 - } │ │2648 - } │ │2649 - │ │2650 - pub fn getGrid(self: Spacer) ?Grid { │ │2651 - return self.grid; │ │2652 - } │ │2653 - │ │2654 - pub fn getFocus(self: *Spacer) *Focus { │ │2655 - return self.focus; │ │2656 - } │ │2657 -}; │ │2658 - │ │2659 -// a one-row, non-focusable status bar showing the current page's url │ │2660 -pub const Footer = struct { │ │2661 - focus: *Focus, │ │2662 - grid: ?Grid, │ │2663 - session: *Session, │ │2664 - arena: std.heap.ArenaAllocator, │ │2665 - url: []const u8 = "", │ │2666 - url_width: usize = 0, │ │2667 - │ │2668 - pub fn init(allocator: std.mem.Allocator, session: *Session) !Footer { │ │2669 - return .{ │ │2670 - .focus = try Focus.create(allocator, .container), │ │2671 - .grid = null, │ │2672 - .session = session, │ │2673 - .arena = std.heap.ArenaAllocator.init(allocator), │ │2674 - }; │ │2675 - } │ │2676 - │ │2677 - pub fn deinit(self: *Footer, allocator: std.mem.Allocator) void { │ │2678 - self.focus.destroy(allocator); │ │2679 - if (self.grid) |*grid| grid.deinit(); │ │2680 - self.arena.deinit(); │ │2681 - } │ │2682 - │ │2683 - pub fn build(self: *Footer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2684 - _ = root_focus; │ │2685 - self.clearGrid(); │ │2686 - const width = constraint.max_size.width orelse return; │ │2687 - if (width == 0) return; │ │2688 - │ │2689 - _ = self.arena.reset(.retain_capacity); │ │2690 - self.url = ""; │ │2691 - self.url_width = 0; │ │2692 - const aa = self.arena.allocator(); │ │2693 - const path = self.session.data.current_page.toUrl(&self.arena) catch return; │ │2694 - const text = terminalWebUrl(aa, self.session, path) catch return; │ │2695 - self.url = text; │ │2696 - │ │2697 - var grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); │ │2698 - errdefer grid.deinit(); │ │2699 - var utf8 = (std.unicode.Utf8View.init(text) catch return).iterator(); │ │2700 - var i: usize = 0; │ │2701 - while (utf8.nextCodepointSlice()) |ch| { │ │2702 - if (i == width) break; │ │2703 - grid.cells.items[try grid.cells.at(.{ 0, i })].rune = ch; │ │2704 - i += 1; │ │2705 - } │ │2706 - self.url_width = i; │ │2707 - self.grid = grid; │ │2708 - } │ │2709 - │ │2710 - pub fn input(self: *Footer, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2711 - _ = allocator; │ │2712 - switch (key) { │ │2713 - .mouse => |mouse| { │ │2714 - if (mouse.ctrl or !inp.leftClickOn(root_focus, self.getFocus().id, mouse)) return; │ │2715 - const rect = (root_focus.children.get(self.getFocus().id) orelse return).rect; │ │2716 - if (mouse.x < rect.x + self.url_width) self.session.host_request = .{ .show_copyable_text = self.url }; │ │2717 - }, │ │2718 - else => {}, │ │2719 - } │ │2720 - } │ │2721 - │ │2722 - pub fn clearGrid(self: *Footer) void { │ │2723 - if (self.grid) |*grid| { │ │2724 - grid.deinit(); │ │2725 - self.grid = null; │ │2726 - } │ │2727 - } │ │2728 - │ │2729 - pub fn getGrid(self: Footer) ?Grid { │ │2730 - return self.grid; │ │2731 - } │ │2732 - │ │2733 - pub fn getFocus(self: *Footer) *Focus { │ │2734 - return self.focus; │ │2735 - } │ │2736 -}; │ │2737 - │ │2738 -// a single-child wrapper that builds the child at its natural size and │ │2739 -// positions its grid in the middle of the area granted by the parent │ │2740 -// a bordered label, indented past the content it precedes │ │2741 -pub const SectionLabel = struct { │ │2742 - box: wgt.Box(Widget), │ │2743 - │ │2744 - pub fn init(allocator: std.mem.Allocator, content: []const u8) !SectionLabel { │ │2745 - var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │2746 - errdefer box.deinit(allocator); │ │2747 - │ │2748 - { │ │2749 - var gap = try wgt.Text(Widget).init(allocator, " "); │ │2750 - errdefer gap.deinit(allocator); │ │2751 - try box.children.put(allocator, gap.getFocus().id, .{ .widget = .{ .text = gap }, .rect = null, .min_size = null }); │ │2752 - } │ │2753 - │ │2754 - { │ │2755 - var tb = try wgt.TextBox(Widget).init(allocator, content, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │2756 - errdefer tb.deinit(allocator); │ │2757 - tb.getFocus().focusable = true; │ │2758 - try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │2759 - } │ │2760 - │ │2761 - box.getFocus().child_id = box.children.keys()[1]; │ │2762 - return .{ .box = box }; │ │2763 - } │ │2764 - │ │2765 - pub fn deinit(self: *SectionLabel, allocator: std.mem.Allocator) void { │ │2766 - self.box.deinit(allocator); │ │2767 - } │ │2768 - │ │2769 - pub fn build(self: *SectionLabel, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2770 - try self.box.build(allocator, constraint, root_focus); │ │2771 - } │ │2772 - │ │2773 - pub fn input(self: *SectionLabel, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2774 - _ = self; │ │2775 - _ = allocator; │ │2776 - _ = key; │ │2777 - _ = root_focus; │ │2778 - } │ │2779 - │ │2780 - pub fn clearGrid(self: *SectionLabel) void { │ │2781 - self.box.clearGrid(); │ │2782 - } │ │2783 - │ │2784 - pub fn getGrid(self: SectionLabel) ?Grid { │ │2785 - return self.box.getGrid(); │ │2786 - } │ │2787 - │ │2788 - pub fn getFocus(self: *SectionLabel) *Focus { │ │2789 - return self.box.getFocus(); │ │2790 - } │ │2791 -}; │ │2792 - │ │2793 -// a submit button, indented past its form's content. the web overlay POSTs │ │2794 -// the button's form; terminal hosts detect presses through `buttonId`. │ │2795 -pub const SubmitButton = struct { │ │2796 - box: wgt.Box(Widget), │ │2797 - │ │2798 - pub fn init(allocator: std.mem.Allocator) !SubmitButton { │ │2799 - return initLabeled(allocator, "submit"); │ │2800 - } │ │2801 - │ │2802 - pub fn initLabeled(allocator: std.mem.Allocator, label: []const u8) !SubmitButton { │ │2803 - var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │2804 - errdefer box.deinit(allocator); │ │2805 - │ │2806 - { │ │2807 - var gap = try wgt.Text(Widget).init(allocator, " "); │ │2808 - errdefer gap.deinit(allocator); │ │2809 - try box.children.put(allocator, gap.getFocus().id, .{ .widget = .{ .text = gap }, .rect = null, .min_size = null }); │ │2810 - } │ │2811 - │ │2812 - { │ │2813 - var button = try wgt.TextBox(Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │2814 - errdefer button.deinit(allocator); │ │2815 - button.getFocus().focusable = true; │ │2816 - // the renderer distinguishes plain clickables from buttons that │ │2817 - // should POST to a server route by this kind. │ │2818 - button.getFocus().kind = .{ .custom = "submit" }; │ │2819 - try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = label.len + 2, .height = null } });│ │2820 - } │ │2821 - │ │2822 - box.getFocus().child_id = box.children.keys()[1]; │ │2823 - return .{ .box = box }; │ │2824 - } │ │2825 - │ │2826 - pub fn deinit(self: *SubmitButton, allocator: std.mem.Allocator) void { │ │2827 - self.box.deinit(allocator); │ │2828 - } │ │2829 - │ │2830 - pub fn build(self: *SubmitButton, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2831 - try self.box.build(allocator, constraint, root_focus); │ │2832 - } │ │2833 - │ │2834 - pub fn input(self: *SubmitButton, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2835 - _ = self; │ │2836 - _ = allocator; │ │2837 - _ = key; │ │2838 - _ = root_focus; │ │2839 - } │ │2840 - │ │2841 - pub fn clearGrid(self: *SubmitButton) void { │ │2842 - self.box.clearGrid(); │ │2843 - } │ │2844 - │ │2845 - pub fn getGrid(self: SubmitButton) ?Grid { │ │2846 - return self.box.getGrid(); │ │2847 - } │ │2848 - │ │2849 - pub fn getFocus(self: *SubmitButton) *Focus { │ │2850 - return self.box.getFocus(); │ │2851 - } │ │2852 - │ │2853 - // the button's focus id, for click hit-testing │ │2854 - pub fn buttonId(self: *const SubmitButton) usize { │ │2855 - return self.box.children.keys()[1]; │ │2856 - } │ │2857 -}; │ │2858 - │ │2859 -pub const Center = struct { │ │2860 - focus: *Focus, │ │2861 - grid: ?Grid, │ │2862 - child: *Widget, │ │2863 - │ │2864 - pub fn init(allocator: std.mem.Allocator, child_widget: Widget) !Center { │ │2865 - const child = try allocator.create(Widget); │ │2866 - errdefer allocator.destroy(child); │ │2867 - child.* = child_widget; │ │2868 - return .{ │ │2869 - .focus = try Focus.create(allocator, .container), │ │2870 - .grid = null, │ │2871 - .child = child, │ │2872 - }; │ │2873 - } │ │2874 - │ │2875 - pub fn deinit(self: *Center, allocator: std.mem.Allocator) void { │ │2876 - self.focus.destroy(allocator); │ │2877 - if (self.grid) |*grid| { │ │2878 - grid.deinit(); │ │2879 - self.grid = null; │ │2880 - } │ │2881 - self.child.deinit(allocator); │ │2882 - allocator.destroy(self.child); │ │2883 - } │ │2884 - │ │2885 - pub fn build(self: *Center, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2886 - self.clearGrid(); │ │2887 - self.getFocus().clear(); │ │2888 - │ │2889 - // build the child without forcing it to fill min_size; it sizes │ │2890 - // itself to its content within the available max. │ │2891 - try self.child.build(allocator, .{ │ │2892 - .min_size = .{ .width = null, .height = null }, │ │2893 - .max_size = constraint.max_size, │ │2894 - }, root_focus); │ │2895 - │ │2896 - const child_grid = self.child.getGrid() orelse return; │ │2897 - │ │2898 - // prefer max when given; otherwise grow to max(min, child) so that │ │2899 - // when only a min is set (e.g. the wasm path passes viewport rows │ │2900 - // as min_height with no max), we can still vertically center while │ │2901 - // letting taller content extend past the min. │ │2902 - 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; │ │2903 - 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; │ │2904 - if (width == 0 or height == 0) return; │ │2905 - │ │2906 - const offset_x: usize = (width -| child_grid.size.width) / 2; │ │2907 - const offset_y: usize = (height -| child_grid.size.height) / 2; │ │2908 - │ │2909 - var grid = try Grid.init(allocator, .{ .width = width, .height = height }); │ │2910 - errdefer grid.deinit(); │ │2911 - try grid.drawGrid(child_grid, offset_x, offset_y); │ │2912 - try self.getFocus().addChild(allocator, self.child.getFocus(), child_grid.size, offset_x, offset_y); │ │2913 - self.getFocus().child_id = self.child.getFocus().id; │ │2914 - │ │2915 - self.grid = grid; │ │2916 - } │ │2917 - │ │2918 - pub fn input(self: *Center, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2919 - try self.child.input(allocator, key, root_focus); │ │2920 - } │ │2921 - │ │2922 - pub fn clearGrid(self: *Center) void { │ │2923 - if (self.grid) |*grid| { │ │2924 - grid.deinit(); │ │2925 - self.grid = null; │ │2926 - } │ │2927 - self.child.clearGrid(); │ │2928 - } │ │2929 - │ │2930 - pub fn getGrid(self: Center) ?Grid { │ │2931 - return self.grid; │ │2932 - } │ │2933 - │ │2934 - pub fn getFocus(self: *Center) *Focus { │ │2935 - return self.focus; │ │2936 - } │ │2937 -}; │ │2938 - │ │2939 -// renders truecolor ANSI art │ │2940 -pub const AnsiArt = struct { │ │2941 - focus: *Focus, │ │2942 - grid: ?Grid, │ │2943 - content: []const u8, │ │2944 - │ │2945 - pub fn init(allocator: std.mem.Allocator, content: []const u8) !AnsiArt { │ │2946 - return .{ │ │2947 - .focus = try Focus.create(allocator, .container), │ │2948 - .grid = null, │ │2949 - .content = content, │ │2950 - }; │ │2951 - } │ │2952 - │ │2953 - pub fn deinit(self: *AnsiArt, allocator: std.mem.Allocator) void { │ │2954 - self.focus.destroy(allocator); │ │2955 - if (self.grid) |*grid| { │ │2956 - grid.deinit(); │ │2957 - self.grid = null; │ │2958 - } │ │2959 - } │ │2960 - │ │2961 - pub fn build(self: *AnsiArt, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │2962 - _ = root_focus; │ │2963 - self.clearGrid(); │ │2964 - │ │2965 - // parse into rows of cells, then copy into a rectangular grid │ │2966 - var rows: std.ArrayList(std.ArrayList(Grid.Cell)) = .empty; │ │2967 - defer { │ │2968 - for (rows.items) |*row| row.deinit(allocator); │ │2969 - rows.deinit(allocator); │ │2970 - } │ │2971 - var row: std.ArrayList(Grid.Cell) = .empty; │ │2972 - errdefer row.deinit(allocator); │ │2973 - │ │2974 - var style: Grid.Style = .{}; │ │2975 - var width: usize = 0; │ │2976 - const content = self.content; │ │2977 - var i: usize = 0; │ │2978 - while (i < content.len) { │ │2979 - const byte = content[i]; │ │2980 - if (byte == '\n') { │ │2981 - width = @max(width, row.items.len); │ │2982 - try rows.append(allocator, row); │ │2983 - row = .empty; │ │2984 - i += 1; │ │2985 - } else if (byte == 0x1B and i + 1 < content.len and content[i + 1] == '[') { │ │2986 - // CSI: scan to the final byte (0x40..0x7E); apply it if it's 'm' │ │2987 - var j = i + 2; │ │2988 - while (j < content.len and !(content[j] >= 0x40 and content[j] <= 0x7E)) j += 1; │ │2989 - if (j >= content.len) { │ │2990 - i = content.len; // malformed trailing escape; stop │ │2991 - } else { │ │2992 - if (content[j] == 'm') applySgr(&style, content[i + 2 .. j]); │ │2993 - i = j + 1; │ │2994 - } │ │2995 - } else { │ │2996 - const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; │ │2997 - const end = @min(content.len, i + len); │ │2998 - const rune = content[i..end]; │ │2999 - // a foreground color does not make a space visible. only an │ │3000 - // effective background makes it opaque; foreground state often │ │3001 - // remains active through the padding at the end of a row. │ │3002 - const effective_background = if (style.inverted) style.fg else style.bg; │ │3003 - const transparent = end - i == 1 and rune[0] == ' ' and effective_background == null; │ │3004 - try row.append(allocator, .{ │ │3005 - .rune = if (transparent) null else rune, │ │3006 - .style = style, │ │3007 - }); │ │3008 - i = end; │ │3009 - } │ │3010 - } │ │3011 - // a trailing row with content but no closing newline │ │3012 - if (row.items.len > 0) { │ │3013 - width = @max(width, row.items.len); │ │3014 - try rows.append(allocator, row); │ │3015 - } else { │ │3016 - row.deinit(allocator); │ │3017 - } │ │3018 - row = .empty; // ownership moved into rows (or freed); keep errdefer safe │ │3019 - │ │3020 - const height = rows.items.len; │ │3021 - if (width == 0 or height == 0) return; │ │3022 - │ │3023 - const clamped_w = @min(width, constraint.max_size.width orelse width); │ │3024 - const clamped_h = @min(height, constraint.max_size.height orelse height); │ │3025 - │ │3026 - var grid = try Grid.init(allocator, .{ .width = clamped_w, .height = clamped_h }); │ │3027 - errdefer grid.deinit(); │ │3028 - for (rows.items[0..clamped_h], 0..) |r, y| { │ │3029 - const n = @min(r.items.len, clamped_w); │ │3030 - for (r.items[0..n], 0..) |cell, x| { │ │3031 - grid.cells.items[try grid.cells.at(.{ y, x })] = cell; │ │3032 - } │ │3033 - } │ │3034 - self.grid = grid; │ │3035 - } │ │3036 - │ │3037 - pub fn clearGrid(self: *AnsiArt) void { │ │3038 - if (self.grid) |*grid| { │ │3039 - grid.deinit(); │ │3040 - self.grid = null; │ │3041 - } │ │3042 - } │ │3043 - │ │3044 - pub fn getGrid(self: AnsiArt) ?Grid { │ │3045 - return self.grid; │ │3046 - } │ │3047 - │ │3048 - pub fn getFocus(self: *AnsiArt) *Focus { │ │3049 - return self.focus; │ │3050 - } │ │2069 │ │3052 - fn applySgr(style: *Grid.Style, params: []const u8) void { │ │3053 - var nums: [16]u32 = undefined; │ │3054 - var n: usize = 0; │ │3055 - var it = std.mem.splitScalar(u8, params, ';'); │ │3056 - while (it.next()) |tok| { │ │3057 - if (n >= nums.len) break; │ │3058 - // an empty parameter (e.g. bare "\x1b[m") means 0 │ │3059 - nums[n] = std.fmt.parseInt(u32, tok, 10) catch 0; │ │3060 - n += 1; │ │3061 - } │ │3062 - if (n == 0) { │ │3063 - style.* = .{}; │ │3064 - return; │ │3065 - } │ │3066 - var i: usize = 0; │ │3067 - while (i < n) : (i += 1) { │ │3068 - switch (nums[i]) { │ │3069 - 0 => style.* = .{}, │ │3070 - 7 => style.inverted = true, │ │3071 - 27 => style.inverted = false, │ │3072 - 39 => style.fg = null, │ │3073 - 49 => style.bg = null, │ │3074 - 38, 48 => { │ │3075 - // truecolor form: 38;2;r;g;b — anything else is ignored │ │3076 - if (i + 4 < n and nums[i + 1] == 2) { │ │3077 - const c = Grid.Color{ │ │3078 - .r = @truncate(nums[i + 2]), │ │3079 - .g = @truncate(nums[i + 3]), │ │3080 - .b = @truncate(nums[i + 4]), │ │3081 - }; │ │3082 - if (nums[i] == 38) style.fg = c else style.bg = c; │ │3083 - i += 4; │ │3084 - } │ │3085 - }, │ │3086 - else => {}, │ │3087 - } │ │3088 - } │ │3089 - } │ │3090 -}; │ │3091 - │ │3092 -// a full-screen wrapper that renders ANSI art behind whatever page it wraps │ │3093 -pub const AnsiBackground = struct { │ │3094 - grid: ?Grid, │ │3095 - child: *Widget, │ │3096 - art: AnsiArt, │ │3097 - session: *Session, │ │3098 - │ │3099 - pub fn init(allocator: std.mem.Allocator, child_widget: Widget, session: *Session) !AnsiBackground { │ │3100 - var cw = child_widget; │ │3101 - const child = allocator.create(Widget) catch |e| { │ │3102 - cw.deinit(allocator); │ │3103 - return e; │ │3104 - }; │ │3105 - child.* = cw; │ │3106 - errdefer { │ │3107 - child.deinit(allocator); │ │3108 - allocator.destroy(child); │ │3109 - } │ │3110 - return .{ .grid = null, .child = child, .art = try AnsiArt.init(allocator, session.data.ansi_art), .session = session }; │ │3111 - } │ │3112 - │ │3113 - pub fn deinit(self: *AnsiBackground, allocator: std.mem.Allocator) void { │ │3114 - if (self.grid) |*grid| grid.deinit(); │ │3115 - self.art.deinit(allocator); │ │3116 - self.child.deinit(allocator); │ │3117 - allocator.destroy(self.child); │ │3118 - } │ │3119 - │ │3120 - pub fn build(self: *AnsiBackground, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │3121 - self.clearGrid(); │ │3122 - │ │3123 - // make the wrapped page fill the whole available area │ │3124 - try self.child.build(allocator, .{ │ │3125 - .min_size = .{ │ │3126 - .width = constraint.max_size.width orelse constraint.min_size.width, │ │3127 - .height = constraint.max_size.height orelse constraint.min_size.height, │ │3128 - }, │ │3129 - .max_size = constraint.max_size, │ │3130 - }, root_focus); │ │3131 - │ │3132 - if (!self.session.data.enable_ansi) return; │ │3133 - const foreground = self.child.getGrid() orelse return; │ │3134 - self.art.content = self.session.data.ansi_art; │ │3135 - try self.buildArt(allocator, foreground.size, root_focus); │ │3136 - if (!self.session.is_terminal) return; │ │3137 - const art_grid = self.art.getGrid() orelse return; │ │3138 - self.grid = try artBehind(allocator, foreground, art_grid); │ │3139 - } │ │3140 - │ │3141 - pub fn input(self: *AnsiBackground, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │3142 - try self.child.input(allocator, key, root_focus); │ │3143 - } │ │3144 - │ │3145 - pub fn clearGrid(self: *AnsiBackground) void { │ │3146 - if (self.grid) |*grid| { │ │3147 - grid.deinit(); │ │3148 - self.grid = null; │ │3149 - } │ │3150 - self.child.clearGrid(); │ │3151 - self.art.clearGrid(); │ │3152 - } │ │3153 - │ │3154 - pub fn getGrid(self: AnsiBackground) ?Grid { │ │3155 - return self.grid orelse self.child.getGrid(); │ │3156 - } │ │3157 - │ │3158 - pub fn getFocus(self: *AnsiBackground) *Focus { │ │3159 - return self.child.getFocus(); │ │3160 - } │ │3161 - │ │3162 - // a foreground cell counts as blank if it's empty or a bare space without styling │ │3163 - fn cellIsBlank(cell: Grid.Cell) bool { │ │3164 - if (cell.rune) |rune| { │ │3165 - return std.mem.eql(u8, rune, " ") and │ │3166 - cell.style.fg == null and cell.style.bg == null and !cell.style.inverted; │ │3167 - } │ │3168 - return true; │ │3169 - } │ │3170 - │ │3171 - // the art is dimmed to this fraction of its brightness so foreground text │ │3172 - // stays legible over it │ │3173 - const art_brightness = 35; // percent │ │3174 - │ │3175 - fn dimColor(color: ?Grid.Color) ?Grid.Color { │ │3176 - const c = color orelse return null; │ │3177 - return .{ │ │3178 - .r = @intCast(@as(u16, c.r) * art_brightness / 100), │ │3179 - .g = @intCast(@as(u16, c.g) * art_brightness / 100), │ │3180 - .b = @intCast(@as(u16, c.b) * art_brightness / 100), │ │3181 - }; │ │3182 - } │ │3183 - │ │3184 - fn buildArt(self: *AnsiBackground, allocator: std.mem.Allocator, size: layout.Size, root_focus: *Focus) !void { │ │3185 - try self.art.build(allocator, .{ │ │3186 - .min_size = .{ .width = null, .height = null }, │ │3187 - .max_size = .{ .width = size.width, .height = size.height }, │ │3188 - }, root_focus); │ │3189 - if (self.art.grid) |*grid| { │ │3190 - for (grid.cells.items) |*cell| { │ │3191 - cell.style.fg = dimColor(cell.style.fg); │ │3192 - cell.style.bg = dimColor(cell.style.bg); │ │3193 - } │ │3194 - } │ │3195 - } │ │3196 - │ │3197 - // terminals that don't support truecolor misparse a "38;2;r;g;b"/"48;2;…" │ │3198 - // SGR as a list of plain SGR codes, so any channel value in 1..9 turns on │ │3199 - // a text attribute (5/6 = blink, 7 = inverse, 8 = conceal, …). │ │3200 - // snap such channels clear of that range so the backdrop doesn't blink or │ │3201 - // hide text there; the ≤9/255 shift is imperceptible on terminals that │ │3202 - // actually render truecolor. │ │3203 - fn sgrSafe(color: ?Grid.Color) ?Grid.Color { │ │3204 - const c = color orelse return null; │ │3205 - const snap = struct { │ │3206 - fn f(v: u8) u8 { │ │3207 - return if (v >= 1 and v <= 9) 10 else v; │ │3208 - } │ │3209 - }.f; │ │3210 - return .{ .r = snap(c.r), .g = snap(c.g), .b = snap(c.b) }; │ │3211 - } │ │3212 - │ │3213 - // composites ANSI art behind a foreground grid │ │3214 - fn artBehind(allocator: std.mem.Allocator, foreground: Grid, art_grid: Grid) !Grid { │ │3215 - var out = try Grid.initFromGrid(allocator, foreground, foreground.size, 0, 0); │ │3216 - errdefer out.deinit(); │ │3217 - │ │3218 - const anchor_x = foreground.size.width -| art_grid.size.width; │ │3219 - for (0..art_grid.size.height) |y| { │ │3220 - for (0..art_grid.size.width) |x| { │ │3221 - const src = art_grid.cells.items[try art_grid.cells.at(.{ y, x })]; │ │3222 - if (src.rune == null) continue; │ │3223 - const idx = try out.cells.at(.{ y, anchor_x + x }); │ │3224 - const dst = &out.cells.items[idx]; │ │3225 - // blank cells take the art; occupied cells take its background │ │3226 - if (cellIsBlank(dst.*)) { │ │3227 - dst.* = src; │ │3228 - dst.style.fg = sgrSafe(dst.style.fg); │ │3229 - dst.style.bg = sgrSafe(dst.style.bg); │ │3230 - } else { │ │3231 - const background_maybe = sgrSafe(src.style.bg orelse src.style.fg); │ │3232 - dst.style.bg = background_maybe; │ │3233 - // change unstyled text to contrast with the art behind it │ │3234 - if (dst.style.fg == null) { │ │3235 - if (background_maybe) |background| { │ │3236 - // use near-black and near-white, which is easier on the eyes │ │3237 - // while retaining high contrast │ │3238 - const luminance = (@as(u32, background.r) * 299 + │ │3239 - @as(u32, background.g) * 587 + │ │3240 - @as(u32, background.b) * 114) / 1000; │ │3241 - dst.style.fg = if (luminance >= 128) │ │3242 - .{ .r = 16, .g = 16, .b = 16 } │ │3243 - else │ │3244 - .{ .r = 240, .g = 240, .b = 240 }; │ │3245 - } │ │3246 - } │ │3247 - } │ │3248 - } │ │3249 - } │ │3250 - return out; │ │3251 - } │ │3252 -}; │ │3253 - │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯