╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
445 ) !Self { 446 var session = Self{ 447 .data = data, 448 .arena = arena, 449 // until a host swaps in a page-scoped arena (Nav does this), page 450 // allocations land in the session arena. on the web path that's the 451 // intended behavior, since the whole arena is per-request. 452 .page_arena = arena, 453 .haxy_moment = try evt.currentMoment(evt.admin_repo_opts, repo), 454 }; 455 try session.loadUserPrefs(); 456 return session; 457 } 458 459 // load the logged-in user's persisted preferences from the db 460 pub fn loadUserPrefs(self: *Self) !void { 461 const user_id = self.data.user_id orelse return; 462 const moment = self.haxy_moment orelse return; 463 if (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, self.arena, user_id)) |user| { 464 self.data.enable_ansi = user.enable_ansi; 465 } 466 } 467 468 // queue an action for the host to drain this frame. 469 pub fn push(self: *Self, action: Action) !void { 470 try self.pending.append(self.arena.allocator(), action); 471 } 472 473 // apply a single action's in-memory effect only (no persistence). 474 fn apply(self: *Self, action: Action) void { 475 switch (action) { 476 .toggle_ansi => self.data.enable_ansi = !self.data.enable_ansi, 477 } 478 } 479 480 // drain the session's queued actions by applying them to the session. 481 // used on the wasm path, which has no repo to persist to. 482 pub fn applyPending(self: *Self) void { 483 for (self.pending.items) |action| self.apply(action); 484 self.pending.clearRetainingCapacity(); 485 } 486 487 // drain the session's queued actions by applying them to the session and 488 // writing them to the db 489 pub fn applyAndWritePending( 490 self: *Self, 491 io: std.Io, 492 allocator: std.mem.Allocator, 493 repo: *rp.Repo(.xit, evt.admin_repo_opts), 494 ) !void { 495 defer self.pending.clearRetainingCapacity(); 496 for (self.pending.items) |action| { 497 self.apply(action); 498 switch (action) { 499 .toggle_ansi => if (self.data.user_id) |user_id| { 500 try evt.User.toggleAnsi(evt.admin_repo_opts, io, allocator, repo, user_id); 501 }, 502 } 503 } 504 } 505 506 // request a forward navigation to `route`; the host consumes next_page 507 // (Nav.sync on the terminal, the wasm tick on the web). 508 pub fn navigate(self: *Session, route: RoutablePage) !void { 509 self.next_page = route; 510 } 511 }; 512 513 pub fn run(io: std.Io, allocator: std.mem.Allocator, session: *Session) !void { 514 var nav = try Nav.init(allocator, session); 515 defer nav.deinit(allocator); 516 517 var terminal = try term.Terminal.init(io, allocator); 518 defer terminal.deinit(io); 519 520 // set term as active so it will be properly cooked 521 // when a panic/segfault happens 522 term.setActive(&terminal); 523 defer term.setActive(null); 524 525 var last_size = layout.Size{ .width = 0, .height = 0 }; 526 var last_grid = try Grid.init(allocator, last_size); 527 defer last_grid.deinit(); 528 529 while (!terminal.shouldQuit()) { 530 const grid_changed = try terminal.render(&nav.root, &last_grid, &last_size); 531 532 // process any inputs. 533 // 534 // if the grid didn't change, then first do a blocking 535 // read, so the thread will sleep until further input. 536 // after that, all remaining reads are non-blocking so 537 // we can process the rest of the queued inputs. 538 // 539 // if the grid *did* change, then only do non-blocking 540 // reads. we do not want to sleep the thread because 541 // there may be an animation that requires more looping. 542 var blocking = !grid_changed; 543 while (try terminal.readKey(io, blocking)) |key| { 544 blocking = false; 545 try inputKey(allocator, &nav.root, key, session); 546 } 547 548 // local run has no repo to persist to, so just apply in-memory 549 session.applyPending(); 550 551 // reconcile navigation: forward to a new page, or back on escape. 552 try nav.sync(allocator, session); 553 554 // the quit button (on the quit tab) asks the host to tear down. 555 if (session.quit_requested) terminal.requestQuit(); 556 557 try nav.root.build(allocator, .{ 558 .min_size = .{ .width = null, .height = null }, 559 .max_size = .{ .width = last_size.width, .height = last_size.height }, 560 }, nav.root.getFocus()); 561 } 562 } 563 564 pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: inp.Key, session: *Session) !void { 565 switch (key) { 566 // request a navigation pop; the host's Nav.sync goes back a page, or 567 // quits when there's no history left. 568 .escape => session.nav_back = true, 569 .enter => { 570 const root_focus = root.getFocus(); 571 if (root_focus.grandchild_id) |gid| { 572 // follow a cross-page link 573 if (crossPageLink(root_focus, gid, session.data.current_page)) |route| { 574 return session.navigate(route); 575 } 576 } 577 try root.input(allocator, key, root_focus); 578 }, 579 .mouse => |mouse| { 580 if (mouse.action == .press and mouse.action.press == .left) { 581 const root_focus = root.getFocus(); 582 var clicked: ?usize = null; 583 var iter = root_focus.children.iterator(); 584 while (iter.next()) |entry| { 585 const child = entry.value_ptr.*; 586 if (!child.focus.focusable) continue; 587 const r = child.rect; 588 if (mouse.x >= r.x and mouse.y >= r.y and 589 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 590 { 591 clicked = entry.key_ptr.*; 592 break; 593 } 594 } 595 if (clicked) |focus_id| { 596 // follow a cross-page link 597 if (crossPageLink(root_focus, focus_id, session.data.current_page)) |route| { 598 return session.navigate(route); 599 } 600 try root_focus.setFocus(focus_id); 601 } 602 // forward the press into the widget tree so buttons (and any 603 // future click-aware widgets) can react. widgets that don't 604 // care about presses ignore it. 605 try root.input(allocator, key, root.getFocus()); 606 } else { 607 try root.input(allocator, key, root.getFocus()); 608 } 609 }, 610 else => try root.input(allocator, key, root.getFocus()), 611 } 612 } 613 614 // if the focus target at focus_id is an `a:` link that should navigate (a 615 // different parent page, or a different files directory within the repo page), 616 // return its route; otherwise null. lets a host turn a click / enter on such a 617 // link into a navigation rather than a focus change. 618 pub fn crossPageLink(root_focus: *Focus, focus_id: usize, current: RoutablePage) ?RoutablePage { 619 const child = root_focus.children.get(focus_id) orelse return null; 620 const custom = switch (child.focus.kind) { 621 .custom => |c| c, 622 else => return null, 623 }; 624 const a_prefix = "a:"; 625 if (!std.mem.startsWith(u8, custom, a_prefix)) return null; 626 const url = custom[a_prefix.len..]; 627 const q = std.mem.indexOfScalar(u8, url, '?'); 628 const route = RoutablePage.fromUrl(if (q) |i| url[0..i] else url, if (q) |i| url[i + 1 ..] else null) orelse return null; 629 // a link to a different parent page always navigates; within a page, a 630 // files-directory / commits-page / list-window change navigates while tab 631 // links stay in-page (header-handled). 632 if (route.parent() != current.parent() or RoutablePage.repoPageChanged(route, current) or RoutablePage.homePageChanged(route, current)) return route; 633 return null; 634 } 635 636 // native-TUI navigation 637 pub const Nav = struct { 638 root: Widget, 639 route: RoutablePage, 640 // backs the current page (its Page data and the page-scoped strings its 641 // widgets build). owned here, swapped on each navigation. session.page_arena 642 // tracks whichever of these is current so widgets allocate into it. 643 arena: *std.heap.ArenaAllocator, 644 history: std.ArrayList(Entry), 645 646 // each retained page keeps its own arena, freed when the page leaves the 647 // stack (popped on back, evicted at cap, or on deinit). this is what keeps a 648 // long-lived session from accumulating every page it ever visited. 649 const Entry = struct { root: Widget, route: RoutablePage, arena: *std.heap.ArenaAllocator }; 650 651 // cap on retained back-history so the chain can't grow memory without bound 652 const max_history: usize = 16; 653 654 pub fn init(allocator: std.mem.Allocator, session: *Session) !Nav { 655 const arena = try allocator.create(std.heap.ArenaAllocator); 656 arena.* = std.heap.ArenaAllocator.init(allocator); 657 errdefer { 658 arena.deinit(); 659 allocator.destroy(arena); 660 } 661 662 session.page_arena = arena; 663 const route = session.data.current_page; 664 665 const page = try arena.allocator().create(Page); 666 page.* = try Page.init(arena, session, route); 667 return .{ 668 .root = try initRoot(allocator, page, session), 669 .route = route, 670 .arena = arena, 671 .history = .empty, 672 }; 673 } 674 675 pub fn deinit(self: *Nav, allocator: std.mem.Allocator) void { 676 self.root.deinit(allocator); 677 freeArena(allocator, self.arena); 678 for (self.history.items) |*entry| { 679 entry.root.deinit(allocator); 680 freeArena(allocator, entry.arena); 681 } 682 self.history.deinit(allocator); 683 } 684 685 fn freeArena(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator) void { 686 arena.deinit(); 687 allocator.destroy(arena); 688 } 689 690 // reconcile the displayed root with the session's navigation state. a 691 // forward nav (current_page moved to a different parent page) pushes the 692 // current root and builds the new page in a fresh arena; a back request 693 // frees the current page and restores the previous one. when escape is 694 // pressed with no history left we switch to the quit tab instead of quitting. 695 pub fn sync(self: *Nav, allocator: std.mem.Allocator, session: *Session) !void { 696 if (session.nav_back) { 697 session.nav_back = false; 698 if (self.history.pop()) |entry| { 699 self.root.deinit(allocator); 700 freeArena(allocator, self.arena); 701 self.root = entry.root; 702 self.route = entry.route; 703 self.arena = entry.arena; 704 session.page_arena = entry.arena; 705 session.data.current_page = entry.route; 706 return; 707 } 708 // nothing to go back to; switch to the quit confirmation 709 const root_focus = self.root.getFocus(); 710 var iter = root_focus.children.iterator(); 711 while (iter.next()) |entry| { 712 switch (entry.value_ptr.focus.kind) { 713 .custom => |custom| if (std.mem.eql(u8, custom, Quit.tab_kind)) { 714 try root_focus.setFocus(entry.key_ptr.*); 715 // a Stack only builds its selected child, so the quit 716 // button isn't in the focus tree until a build runs with 717 // the quit tab selected. build once, then send a 718 // synthetic arrow_down to drop focus from the tab onto 719 // the button so it's ready to confirm. 720 try self.root.build(allocator, .{ 721 .min_size = .{ .width = null, .height = 40 }, 722 .max_size = .{ .width = 80, .height = null }, 723 }, root_focus); 724 try self.root.input(allocator, .arrow_down, root_focus); 725 break; 726 }, 727 else => {}, 728 } 729 } 730 return; 731 } 732 733 // forward navigation: navigate() set next_page to the page to move to (a 734 // cross-page link or tab change crossing pages) 735 if (session.next_page) |route| { 736 session.next_page = null; 737 if (session.haxy_moment == null) return; 738 // the page we navigated to becomes the current page 739 session.data.current_page = route; 740 741 const arena = try allocator.create(std.heap.ArenaAllocator); 742 arena.* = std.heap.ArenaAllocator.init(allocator); 743 errdefer freeArena(allocator, arena); 744 745 session.page_arena = arena; 746 747 const page = try arena.allocator().create(Page); 748 page.* = try Page.init(arena, session, route); 749 const new_root = try initRoot(allocator, page, session); 750 751 try self.history.append(allocator, .{ .root = self.root, .route = self.route, .arena = self.arena }); 752 // drop the oldest entry (freeing its widget tree and arena) once over cap 753 if (self.history.items.len > max_history) { 754 var oldest = self.history.orderedRemove(0); 755 oldest.root.deinit(allocator); 756 freeArena(allocator, oldest.arena); 757 } 758 self.root = new_root; 759 self.route = route; 760 self.arena = arena; 761 } 762 } 763 }; 764 765 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { 766 const page_widget: Widget = switch (page.*) { 767 .home => |*p| .{ .home = try .init(allocator, p, session) }, 768 .user => |*p| .{ .user = try .init(allocator, p, session) }, 769 .repo => |*p| .{ .repo = try .init(allocator, p, session) }, 770 }; 771 772 const demon_art = @embedFile("embed/demon.ans"); 773 774 // on the TUI/SSH, the page sits above a one-row footer showing the url 775 var root = if (session.is_terminal) blk: { 776 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 777 errdefer box.deinit(allocator); 778 const bg_id = bg_blk: { 779 var background = try AnsiBackground.init(allocator, page_widget, demon_art, session); 780 errdefer background.deinit(allocator); 781 const id = background.getFocus().id; 782 try box.children.put(allocator, id, .{ .widget = .{ .background = background }, .rect = null, .min_size = null }); 783 break :bg_blk id; 784 }; 785 { 786 var footer = try Footer.init(allocator, session); 787 errdefer footer.deinit(allocator); 788 try box.children.put(allocator, footer.getFocus().id, .{ .widget = .{ .footer = footer }, .rect = null, .min_size = .{ .width = null, .height = 1 } }); 789 } 790 box.getFocus().child_id = bg_id; 791 break :blk Widget{ .box = box }; 792 } else Widget{ .background = try AnsiBackground.init(allocator, page_widget, demon_art, session) }; 793 errdefer root.deinit(allocator); 794 795 // input-owning views build their TextInputs in init — so reset the 796 // focus-id -> *TextInput map here 797 session.text_inputs.clearRetainingCapacity(); 798 799 try root.build(allocator, .{ 800 .min_size = .{ .width = null, .height = 40 }, 801 .max_size = .{ .width = 80, .height = null }, 802 }, root.getFocus()); 803 if (root.getFocus().child_id) |child_id| { 804 try root.getFocus().setFocus(child_id); 805 } 806 807 return root; 808 } 809 810 pub const Widget = union(enum) { 811 text: wgt.Text(Widget), 812 box: wgt.Box(Widget), 813 text_box: wgt.TextBox(Widget), 814 text_input: wgt.TextInput(Widget), 815 scroll: wgt.Scroll(Widget), 816 stack: wgt.Stack(Widget), 817 flow_box: FlowBox, 818 flow_box_scroll: FlowBox.Scroll, 819 spacer: Spacer, 820 center: Center, 821 ansi_art: AnsiArt, 822 background: AnsiBackground, 823 home: Home.View, 824 user: User.View, 825 repo: Repo.View, 826 quit: Quit.View, 827 title: Title.View, 828 home_header: Home.Header.View, 829 user_header: User.Header.View, 830 repo_header: Repo.Header.View, 831 repo_files: Repo.Files.View, 832 repo_commits: Repo.Commits.View, 833 home_users: Home.Users.View, 834 home_repos: Home.Repos.View, 835 auth_tab: Home.Header.AuthTab.View, 836 home_settings: Home.Settings.View, 837 home_auth: Home.Auth.View, 838 footer: Footer, 839 840 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void { 841 switch (self.*) { 842 inline else => |*case| case.deinit(allocator), 843 } 844 } 845 846 pub fn build(self: *Widget, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 847 switch (self.*) { 848 inline else => |*case| try case.build(allocator, constraint, root_focus), 849 } 850 } 851 852 pub fn input(self: *Widget, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) anyerror!void { 853 switch (self.*) { 854 inline else => |*case| try case.input(allocator, key, root_focus), 855 } 856 } 857 858 pub fn clearGrid(self: *Widget) void { 859 switch (self.*) { 860 inline else => |*case| case.clearGrid(), 861 } 862 } 863 864 pub fn getGrid(self: Widget) ?Grid { 865 switch (self) { 866 inline else => |*case| return case.getGrid(), 867 } 868 } 869 870 pub fn getFocus(self: *Widget) *Focus { 871 switch (self.*) { 872 inline else => |*case| return case.getFocus(), 873 } 874 } 875 }; 876 877 pub const FlowBox = struct { 878 focus: *Focus, 879 grid: ?Grid, 880 text_boxes: std.ArrayList(wgt.TextBox(Widget)), 881 // backs the per-item strings the text boxes borrow: each box's `content` 882 // and, for link items, its `.custom` focus kind (e.g. "a:/user/foo"). reset 883 // wholesale on each setItems so there's no per-string ownership to track. 884 arena: std.heap.ArenaAllocator, 885 // column count from the last build — FlowBox.Scroll.input uses it so arrow 886 // up/down can step by a row's worth of items. 887 last_cols: usize, 888 options: Options, 889 890 const border_rows: usize = 2; 891 892 pub const Options = struct { 893 cell_width: usize = 40, 894 cell_height: usize = 3, 895 }; 896 897 pub fn init(allocator: std.mem.Allocator, options: Options) !FlowBox { 898 return .{ 899 .focus = try Focus.create(allocator, .container), 900 .grid = null, 901 .text_boxes = .empty, 902 .arena = std.heap.ArenaAllocator.init(allocator), 903 .last_cols = 1, 904 .options = options, 905 }; 906 } 907 908 pub fn deinit(self: *FlowBox, allocator: std.mem.Allocator) void { 909 self.focus.destroy(allocator); 910 if (self.grid) |*grid| { 911 grid.deinit(); 912 self.grid = null; 913 } 914 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 915 self.text_boxes.deinit(allocator); 916 self.arena.deinit(); 917 } 918 919 // one flow item: its display text plus an optional link. a non-empty `link` 920 // sets the item's focus kind such as `.{ .custom = "a:/user/foo" }`, which 921 // the web renderer turns into an anchor. 922 pub const Item = struct { text: []const u8, link: []const u8 = "" }; 923 924 // both the item text and the link are copied into the arena, so the caller's 925 // slices needn't outlive this call. 926 pub fn setItems(self: *FlowBox, allocator: std.mem.Allocator, items: []const Item) !void { 927 for (self.text_boxes.items) |*tb| tb.deinit(allocator); 928 self.text_boxes.clearAndFree(allocator); 929 930 // the old text boxes (and their borrowed content/links) are gone now, so 931 // the arena that backed those strings can be reclaimed. 932 _ = self.arena.reset(.retain_capacity); 933 const aa = self.arena.allocator(); 934 935 self.focus.clear(); 936 self.focus.child_id = null; 937 938 for (items) |item| { 939 const line = try aa.dupe(u8, item.text); 940 941 var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 942 errdefer text_box.deinit(allocator); 943 text_box.getFocus().focusable = true; 944 945 if (item.link.len > 0) { 946 text_box.getFocus().kind = .{ .custom = try aa.dupe(u8, item.link) }; 947 } 948 949 try self.text_boxes.append(allocator, text_box); 950 } 951 952 if (self.text_boxes.items.len > 0) { 953 self.focus.child_id = self.text_boxes.items[0].getFocus().id; 954 } 955 } 956 957 pub fn build(self: *FlowBox, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 958 self.clearGrid(); 959 self.focus.clear(); 960 961 const cell_width = self.options.cell_width; 962 const max_width = constraint.max_size.width orelse cell_width; 963 const cols = if (cell_width == 0) 1 else @max(1, max_width / cell_width); 964 self.last_cols = cols; 965 966 const count = self.text_boxes.items.len; 967 if (count == 0) return; 968 const slot_height = self.options.cell_height + border_rows; 969 if (slot_height == 0) return; 970 971 // build at the slot size so every text box fits a single grid cell 972 for (self.text_boxes.items) |*tb| { 973 tb.options.border_style = if (self.focus.child_id == tb.getFocus().id) .single else .hidden; 974 try tb.build(allocator, .{ 975 .min_size = .{ .width = cell_width, .height = null }, 976 .max_size = .{ .width = cell_width, .height = slot_height }, 977 }, root_focus); 978 } 979 980 const rows = (count + cols - 1) / cols; 981 const content_height = rows * slot_height; 982 const total_width = cols * cell_width; 983 if (total_width == 0 or content_height == 0) return; 984 985 var grid = try Grid.init(allocator, .{ .width = total_width, .height = content_height }); 986 errdefer grid.deinit(); 987 988 for (self.text_boxes.items, 0..) |*tb, i| { 989 const tb_grid = tb.getGrid() orelse continue; 990 const col = i % cols; 991 const row = i / cols; 992 const cell_x = col * cell_width; 993 const cell_y = row * slot_height; 994 try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, cell_x, cell_y); 995 try grid.drawGrid(tb_grid, cell_x, cell_y); 996 } 997 998 self.grid = grid; 999 } 1000 1001 pub fn input(self: *FlowBox, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1002 _ = self; 1003 _ = allocator; 1004 _ = key; 1005 _ = root_focus; 1006 } 1007 1008 pub fn clearGrid(self: *FlowBox) void { 1009 if (self.grid) |*grid| { 1010 grid.deinit(); 1011 self.grid = null; 1012 } 1013 for (self.text_boxes.items) |*tb| tb.clearGrid(); 1014 } 1015 1016 pub fn getGrid(self: FlowBox) ?Grid { 1017 return self.grid; 1018 } 1019 1020 pub fn getFocus(self: *FlowBox) *Focus { 1021 return self.focus; 1022 } 1023 1024 pub fn cellRect(self: FlowBox, index: usize) ?layout.IRect { 1025 if (self.last_cols == 0 or index >= self.text_boxes.items.len) return null; 1026 const slot_height = self.options.cell_height + border_rows; 1027 const col = index % self.last_cols; 1028 const row = index / self.last_cols; 1029 return .{ 1030 .x = @intCast(col * self.options.cell_width), 1031 .y = @intCast(row * slot_height), 1032 .size = .{ .width = self.options.cell_width, .height = slot_height }, 1033 }; 1034 } 1035 1036 pub fn indexOfFocusId(self: FlowBox, focus_id: usize) ?usize { 1037 for (self.text_boxes.items, 0..) |tb, i| { 1038 if (tb.box.focus.id == focus_id) return i; 1039 } 1040 return null; 1041 } 1042 1043 pub const Scroll = struct { 1044 scroll: wgt.Scroll(Widget), 1045 1046 pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options, web_native: bool) !Scroll { 1047 var layout_inner = try FlowBox.init(allocator, options); 1048 errdefer layout_inner.deinit(allocator); 1049 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .{ .direction = .vert, .web_native = web_native }); 1050 errdefer scroll.deinit(allocator); 1051 return .{ .scroll = scroll }; 1052 } 1053 1054 pub fn deinit(self: *Scroll, allocator: std.mem.Allocator) void { 1055 self.scroll.deinit(allocator); 1056 } 1057 1058 pub fn setItems(self: *Scroll, allocator: std.mem.Allocator, items: []const Item) !void { 1059 self.scroll.x = 0; 1060 self.scroll.y = 0; 1061 try self.inner().setItems(allocator, items); 1062 } 1063 1064 pub fn build(self: *Scroll, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1065 try self.scroll.build(allocator, constraint, root_focus); 1066 } 1067 1068 pub fn input(self: *Scroll, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1069 _ = allocator; 1070 const in = self.inner(); 1071 const child_id = in.focus.child_id orelse return; 1072 const current_index = in.indexOfFocusId(child_id) orelse return; 1073 const count = in.text_boxes.items.len; 1074 if (count == 0) return; 1075 const cols = in.last_cols; 1076 const slot_height = in.options.cell_height + FlowBox.border_rows; 1077 1078 var index = current_index; 1079 switch (key) { 1080 .arrow_up => index -|= cols, 1081 .arrow_down => if (index + cols < count) { 1082 index += cols; 1083 }, 1084 .arrow_left => index -|= 1, 1085 .arrow_right => if (index + 1 < count) { 1086 index += 1; 1087 }, 1088 .home => index = 0, 1089 .end => index = count - 1, 1090 .page_up => { 1091 if (self.scroll.grid) |grid| if (slot_height > 0) { 1092 const rows_per_page = grid.size.height / slot_height; 1093 index -|= rows_per_page * cols; 1094 }; 1095 }, 1096 .page_down => { 1097 if (self.scroll.grid) |grid| if (slot_height > 0) { 1098 const rows_per_page = grid.size.height / slot_height; 1099 index = @min(index + rows_per_page * cols, count - 1); 1100 }; 1101 }, 1102 // scroll wheel moves the focused cell by a full row so the 1103 // viewport (via scrollToRect) follows in row-sized steps, 1104 // matching how a scroll wheel feels in a grid view. 1105 .mouse => |mouse| switch (mouse.action) { 1106 .scroll => |dir| switch (dir) { 1107 .up => index -|= cols, 1108 .down => if (index + cols < count) { 1109 index += cols; 1110 }, 1111 }, 1112 else => {}, 1113 }, 1114 else => {}, 1115 } 1116 1117 if (index != current_index) { 1118 try root_focus.setFocus(in.text_boxes.items[index].getFocus().id); 1119 if (in.cellRect(index)) |rect| { 1120 self.scroll.scrollToRect(rect); 1121 } 1122 } 1123 } 1124 1125 pub fn clearGrid(self: *Scroll) void { 1126 self.scroll.clearGrid(); 1127 } 1128 1129 pub fn getGrid(self: Scroll) ?Grid { 1130 return self.scroll.getGrid(); 1131 } 1132 1133 pub fn getFocus(self: *Scroll) *Focus { 1134 return self.scroll.getFocus(); 1135 } 1136 1137 pub fn getSelectedIndex(self: Scroll) ?usize { 1138 const in = self.scroll.child.flow_box; 1139 const child_id = in.focus.child_id orelse return null; 1140 return in.indexOfFocusId(child_id); 1141 } 1142 1143 fn inner(self: *Scroll) *FlowBox { 1144 return &self.scroll.child.flow_box; 1145 } 1146 }; 1147 }; 1148 1149 // an invisible widget that fills the horizontal space granted by its parent. 1150 // used inside Box(horiz) with a min_size so the box reserves space for the 1151 // children that follow, pushing them to the right. 1152 pub const Spacer = struct { 1153 focus: *Focus, 1154 grid: ?Grid, 1155 1156 pub fn init(allocator: std.mem.Allocator) !Spacer { 1157 return .{ 1158 .focus = try Focus.create(allocator, .container), 1159 .grid = null, 1160 }; 1161 } 1162 1163 pub fn deinit(self: *Spacer, allocator: std.mem.Allocator) void { 1164 self.focus.destroy(allocator); 1165 if (self.grid) |*grid| { 1166 grid.deinit(); 1167 self.grid = null; 1168 } 1169 } 1170 1171 pub fn build(self: *Spacer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1172 _ = root_focus; 1173 self.clearGrid(); 1174 const width = constraint.max_size.width orelse return; 1175 if (width == 0) return; 1176 self.grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 1177 } 1178 1179 pub fn input(self: *Spacer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1180 _ = self; 1181 _ = allocator; 1182 _ = key; 1183 _ = root_focus; 1184 } 1185 1186 pub fn clearGrid(self: *Spacer) void { 1187 if (self.grid) |*grid| { 1188 grid.deinit(); 1189 self.grid = null; 1190 } 1191 } 1192 1193 pub fn getGrid(self: Spacer) ?Grid { 1194 return self.grid; 1195 } 1196 1197 pub fn getFocus(self: *Spacer) *Focus { 1198 return self.focus; 1199 } 1200 }; 1201 1202 // a one-row, non-focusable status bar showing the current page's url 1203 pub const Footer = struct { 1204 focus: *Focus, 1205 grid: ?Grid, 1206 session: *Session, 1207 arena: std.heap.ArenaAllocator, 1208 1209 pub fn init(allocator: std.mem.Allocator, session: *Session) !Footer { 1210 return .{ 1211 .focus = try Focus.create(allocator, .container), 1212 .grid = null, 1213 .session = session, 1214 .arena = std.heap.ArenaAllocator.init(allocator), 1215 }; 1216 } 1217 1218 pub fn deinit(self: *Footer, allocator: std.mem.Allocator) void { 1219 self.focus.destroy(allocator); 1220 if (self.grid) |*grid| grid.deinit(); 1221 self.arena.deinit(); 1222 } 1223 1224 pub fn build(self: *Footer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1225 _ = root_focus; 1226 self.clearGrid(); 1227 const width = constraint.max_size.width orelse return; 1228 if (width == 0) return; 1229 1230 _ = self.arena.reset(.retain_capacity); 1231 const aa = self.arena.allocator(); 1232 const path = self.session.data.current_page.urlAlloc(&self.arena) catch return; 1233 const text = if (self.session.web_port) |port| 1234 std.fmt.allocPrint(aa, "http://localhost:{d}{s}", .{ port, path }) catch return 1235 else 1236 path; 1237 1238 var grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 1239 errdefer grid.deinit(); 1240 var utf8 = (std.unicode.Utf8View.init(text) catch return).iterator(); 1241 var i: usize = 0; 1242 while (utf8.nextCodepointSlice()) |ch| { 1243 if (i == width) break; 1244 grid.cells.items[try grid.cells.at(.{ 0, i })].rune = ch; 1245 i += 1; 1246 } 1247 self.grid = grid; 1248 } 1249 1250 pub fn input(self: *Footer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1251 _ = self; 1252 _ = allocator; 1253 _ = key; 1254 _ = root_focus; 1255 } 1256 1257 pub fn clearGrid(self: *Footer) void { 1258 if (self.grid) |*grid| { 1259 grid.deinit(); 1260 self.grid = null; 1261 } 1262 } 1263 1264 pub fn getGrid(self: Footer) ?Grid { 1265 return self.grid; 1266 } 1267 1268 pub fn getFocus(self: *Footer) *Focus { 1269 return self.focus; 1270 } 1271 }; 1272 1273 // a single-child wrapper that builds the child at its natural size and 1274 // positions its grid in the middle of the area granted by the parent 1275 pub const Center = struct { 1276 focus: *Focus, 1277 grid: ?Grid, 1278 child: *Widget, 1279 direction: Direction, 1280 1281 pub const Direction = enum { both, horiz, vert }; 1282 1283 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, direction: Direction) !Center { 1284 const child = try allocator.create(Widget); 1285 errdefer allocator.destroy(child); 1286 child.* = child_widget; 1287 return .{ 1288 .focus = try Focus.create(allocator, .container), 1289 .grid = null, 1290 .child = child, 1291 .direction = direction, 1292 }; 1293 } 1294 1295 pub fn deinit(self: *Center, allocator: std.mem.Allocator) void { 1296 self.focus.destroy(allocator); 1297 if (self.grid) |*grid| { 1298 grid.deinit(); 1299 self.grid = null; 1300 } 1301 self.child.deinit(allocator); 1302 allocator.destroy(self.child); 1303 } 1304 1305 pub fn build(self: *Center, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1306 self.clearGrid(); 1307 self.getFocus().clear(); 1308 1309 // build the child without forcing it to fill min_size; it sizes 1310 // itself to its content within the available max. 1311 try self.child.build(allocator, .{ 1312 .min_size = .{ .width = null, .height = null }, 1313 .max_size = constraint.max_size, 1314 }, root_focus); 1315 1316 const child_grid = self.child.getGrid() orelse return; 1317 1318 // prefer max when given; otherwise grow to max(min, child) so that 1319 // when only a min is set (e.g. the wasm path passes viewport rows 1320 // as min_height with no max), we can still vertically center while 1321 // letting taller content extend past the min. 1322 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; 1323 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; 1324 if (width == 0 or height == 0) return; 1325 1326 const offset_x: usize = if (self.direction == .horiz or self.direction == .both) 1327 (width -| child_grid.size.width) / 2 1328 else 1329 0; 1330 const offset_y: usize = if (self.direction == .vert or self.direction == .both) 1331 (height -| child_grid.size.height) / 2 1332 else 1333 0; 1334 1335 var grid = try Grid.init(allocator, .{ .width = width, .height = height }); 1336 errdefer grid.deinit(); 1337 try grid.drawGrid(child_grid, offset_x, offset_y); 1338 try self.getFocus().addChild(allocator, self.child.getFocus(), child_grid.size, offset_x, offset_y); 1339 self.getFocus().child_id = self.child.getFocus().id; 1340 1341 self.grid = grid; 1342 } 1343 1344 pub fn input(self: *Center, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1345 try self.child.input(allocator, key, root_focus); 1346 } 1347 1348 pub fn clearGrid(self: *Center) void { 1349 if (self.grid) |*grid| { 1350 grid.deinit(); 1351 self.grid = null; 1352 } 1353 self.child.clearGrid(); 1354 } 1355 1356 pub fn getGrid(self: Center) ?Grid { 1357 return self.grid; 1358 } 1359 1360 pub fn getFocus(self: *Center) *Focus { 1361 return self.focus; 1362 } 1363 }; 1364 1365 // renders truecolor ANSI art 1366 pub const AnsiArt = struct { 1367 focus: *Focus, 1368 grid: ?Grid, 1369 content: []const u8, 1370 1371 pub fn init(allocator: std.mem.Allocator, content: []const u8) !AnsiArt { 1372 return .{ 1373 .focus = try Focus.create(allocator, .container), 1374 .grid = null, 1375 .content = content, 1376 }; 1377 } 1378 1379 pub fn deinit(self: *AnsiArt, allocator: std.mem.Allocator) void { 1380 self.focus.destroy(allocator); 1381 if (self.grid) |*grid| { 1382 grid.deinit(); 1383 self.grid = null; 1384 } 1385 } 1386 1387 pub fn build(self: *AnsiArt, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1388 _ = root_focus; 1389 self.clearGrid(); 1390 1391 // parse into rows of cells, then copy into a rectangular grid 1392 var rows: std.ArrayList(std.ArrayList(Grid.Cell)) = .empty; 1393 defer { 1394 for (rows.items) |*row| row.deinit(allocator); 1395 rows.deinit(allocator); 1396 } 1397 var row: std.ArrayList(Grid.Cell) = .empty; 1398 errdefer row.deinit(allocator); 1399 1400 var style: Grid.Style = .{}; 1401 var width: usize = 0; 1402 const content = self.content; 1403 var i: usize = 0; 1404 while (i < content.len) { 1405 const byte = content[i]; 1406 if (byte == '\n') { 1407 width = @max(width, row.items.len); 1408 try rows.append(allocator, row); 1409 row = .empty; 1410 i += 1; 1411 } else if (byte == 0x1B and i + 1 < content.len and content[i + 1] == '[') { 1412 // CSI: scan to the final byte (0x40..0x7E); apply it if it's 'm' 1413 var j = i + 2; 1414 while (j < content.len and !(content[j] >= 0x40 and content[j] <= 0x7E)) j += 1; 1415 if (j >= content.len) { 1416 i = content.len; // malformed trailing escape; stop 1417 } else { 1418 if (content[j] == 'm') applySgr(&style, content[i + 2 .. j]); 1419 i = j + 1; 1420 } 1421 } else { 1422 const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; 1423 const end = @min(content.len, i + len); 1424 const rune = content[i..end]; 1425 const transparent = end - i == 1 and rune[0] == ' ' and 1426 style.fg == null and style.bg == null and !style.inverted; 1427 try row.append(allocator, .{ 1428 .rune = if (transparent) null else rune, 1429 .style = style, 1430 }); 1431 i = end; 1432 } 1433 } 1434 // a trailing row with content but no closing newline 1435 if (row.items.len > 0) { 1436 width = @max(width, row.items.len); 1437 try rows.append(allocator, row); 1438 } else { 1439 row.deinit(allocator); 1440 } 1441 row = .empty; // ownership moved into rows (or freed); keep errdefer safe 1442 1443 const height = rows.items.len; 1444 if (width == 0 or height == 0) return; 1445 1446 const clamped_w = @min(width, constraint.max_size.width orelse width); 1447 const clamped_h = @min(height, constraint.max_size.height orelse height); 1448 1449 var grid = try Grid.init(allocator, .{ .width = clamped_w, .height = clamped_h }); 1450 errdefer grid.deinit(); 1451 for (rows.items[0..clamped_h], 0..) |r, y| { 1452 const n = @min(r.items.len, clamped_w); 1453 for (r.items[0..n], 0..) |cell, x| { 1454 grid.cells.items[try grid.cells.at(.{ y, x })] = cell; 1455 } 1456 } 1457 self.grid = grid; 1458 } 1459 1460 pub fn input(self: *AnsiArt, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1461 _ = self; 1462 _ = allocator; 1463 _ = key; 1464 _ = root_focus; 1465 } 1466 1467 pub fn clearGrid(self: *AnsiArt) void { 1468 if (self.grid) |*grid| { 1469 grid.deinit(); 1470 self.grid = null; 1471 } 1472 } 1473 1474 pub fn getGrid(self: AnsiArt) ?Grid { 1475 return self.grid; 1476 } 1477 1478 pub fn getFocus(self: *AnsiArt) *Focus { 1479 return self.focus; 1480 } 1481 1482 fn applySgr(style: *Grid.Style, params: []const u8) void { 1483 var nums: [16]u32 = undefined; 1484 var n: usize = 0; 1485 var it = std.mem.splitScalar(u8, params, ';'); 1486 while (it.next()) |tok| { 1487 if (n >= nums.len) break; 1488 // an empty parameter (e.g. bare "\x1b[m") means 0 1489 nums[n] = std.fmt.parseInt(u32, tok, 10) catch 0; 1490 n += 1; 1491 } 1492 if (n == 0) { 1493 style.* = .{}; 1494 return; 1495 } 1496 var i: usize = 0; 1497 while (i < n) : (i += 1) { 1498 switch (nums[i]) { 1499 0 => style.* = .{}, 1500 7 => style.inverted = true, 1501 27 => style.inverted = false, 1502 39 => style.fg = null, 1503 49 => style.bg = null, 1504 38, 48 => { 1505 // truecolor form: 38;2;r;g;b — anything else is ignored 1506 if (i + 4 < n and nums[i + 1] == 2) { 1507 const c = Grid.Color{ 1508 .r = @truncate(nums[i + 2]), 1509 .g = @truncate(nums[i + 3]), 1510 .b = @truncate(nums[i + 4]), 1511 }; 1512 if (nums[i] == 38) style.fg = c else style.bg = c; 1513 i += 4; 1514 } 1515 }, 1516 else => {}, 1517 } 1518 } 1519 } 1520 }; 1521 1522 // a full-screen wrapper that renders ANSI art behind whatever page it wraps 1523 pub const AnsiBackground = struct { 1524 grid: ?Grid, 1525 child: *Widget, 1526 art: AnsiArt, 1527 session: *Session, 1528 1529 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, art_content: []const u8, session: *Session) !AnsiBackground { 1530 var cw = child_widget; 1531 const child = allocator.create(Widget) catch |e| { 1532 cw.deinit(allocator); 1533 return e; 1534 }; 1535 child.* = cw; 1536 errdefer { 1537 child.deinit(allocator); 1538 allocator.destroy(child); 1539 } 1540 return .{ .grid = null, .child = child, .art = try AnsiArt.init(allocator, art_content), .session = session }; 1541 } 1542 1543 pub fn deinit(self: *AnsiBackground, allocator: std.mem.Allocator) void { 1544 if (self.grid) |*grid| grid.deinit(); 1545 self.art.deinit(allocator); 1546 self.child.deinit(allocator); 1547 allocator.destroy(self.child); 1548 } 1549 1550 pub fn build(self: *AnsiBackground, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1551 self.clearGrid(); 1552 1553 // make the wrapped page fill the whole available area 1554 try self.child.build(allocator, .{ 1555 .min_size = .{ 1556 .width = constraint.max_size.width orelse constraint.min_size.width, 1557 .height = constraint.max_size.height orelse constraint.min_size.height, 1558 }, 1559 .max_size = constraint.max_size, 1560 }, root_focus); 1561 1562 if (self.session.data.enable_ansi) { 1563 if (self.child.getGrid()) |fg| { 1564 self.grid = try artBehind(allocator, fg, &self.art, .top_right, root_focus); 1565 } 1566 } 1567 } 1568 1569 pub fn input(self: *AnsiBackground, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 1570 try self.child.input(allocator, key, root_focus); 1571 } 1572 1573 pub fn clearGrid(self: *AnsiBackground) void { 1574 if (self.grid) |*grid| { 1575 grid.deinit(); 1576 self.grid = null; 1577 } 1578 self.child.clearGrid(); 1579 self.art.clearGrid(); 1580 } 1581 1582 pub fn getGrid(self: AnsiBackground) ?Grid { 1583 return self.grid orelse self.child.getGrid(); 1584 } 1585 1586 pub fn getFocus(self: *AnsiBackground) *Focus { 1587 return self.child.getFocus(); 1588 } 1589 1590 // which top corner the art hugs. 1591 const ArtAnchor = enum { top_left, top_right }; 1592 1593 // a foreground cell counts as blank if it's empty or a bare space without styling 1594 fn cellIsBlank(cell: Grid.Cell) bool { 1595 if (cell.rune) |rune| { 1596 return std.mem.eql(u8, rune, " ") and 1597 cell.style.fg == null and cell.style.bg == null and !cell.style.inverted; 1598 } 1599 return true; 1600 } 1601 1602 // the art is dimmed to this fraction of its brightness so foreground text 1603 // stays legible over it 1604 const art_brightness = 45; // percent 1605 1606 fn dimColor(color: ?Grid.Color) ?Grid.Color { 1607 const c = color orelse return null; 1608 return .{ 1609 .r = @intCast(@as(u16, c.r) * art_brightness / 100), 1610 .g = @intCast(@as(u16, c.g) * art_brightness / 100), 1611 .b = @intCast(@as(u16, c.b) * art_brightness / 100), 1612 }; 1613 } 1614 1615 // terminals that don't support truecolor misparse a "38;2;r;g;b"/"48;2;…" 1616 // SGR as a list of plain SGR codes, so any channel value in 1..9 turns on 1617 // a text attribute (5/6 = blink, 7 = inverse, 8 = conceal, …). 1618 // snap such channels clear of that range so the backdrop doesn't blink or 1619 // hide text there; the ≤9/255 shift is imperceptible on terminals that 1620 // actually render truecolor. 1621 fn sgrSafe(color: ?Grid.Color) ?Grid.Color { 1622 const c = color orelse return null; 1623 const snap = struct { 1624 fn f(v: u8) u8 { 1625 return if (v >= 1 and v <= 9) 10 else v; 1626 } 1627 }.f; 1628 return .{ .r = snap(c.r), .g = snap(c.g), .b = snap(c.b) }; 1629 } 1630 1631 // composites ANSI art behind a foreground grid 1632 fn artBehind(allocator: std.mem.Allocator, foreground: Grid, art: *AnsiArt, anchor: ArtAnchor, root_focus: *Focus) !Grid { 1633 art.clearGrid(); 1634 try art.build(allocator, .{ 1635 .min_size = .{ .width = null, .height = null }, 1636 .max_size = .{ .width = foreground.size.width, .height = foreground.size.height }, 1637 }, root_focus); 1638 1639 var out = try Grid.initFromGrid(allocator, foreground, foreground.size, 0, 0); 1640 errdefer out.deinit(); 1641 1642 if (art.getGrid()) |art_grid| { 1643 const anchor_x = switch (anchor) { 1644 .top_left => 0, 1645 .top_right => foreground.size.width -| art_grid.size.width, 1646 }; 1647 for (0..art_grid.size.height) |y| { 1648 for (0..art_grid.size.width) |x| { 1649 const src = art_grid.cells.items[try art_grid.cells.at(.{ y, x })]; 1650 if (src.rune == null) continue; 1651 const idx = out.cells.at(.{ y, anchor_x + x }) catch continue; 1652 const dst = &out.cells.items[idx]; 1653 // composite the dimmed art behind every cell so it shows 1654 // through the whole UI: a blank cell becomes the art, while a 1655 // cell with a glyph keeps the glyph but takes the dimmed art as 1656 // its background (replacing whatever background it had, which 1657 // would otherwise obscure the art). 1658 if (cellIsBlank(dst.*)) { 1659 dst.* = src; 1660 dst.style.fg = sgrSafe(dimColor(dst.style.fg)); 1661 dst.style.bg = sgrSafe(dimColor(dst.style.bg)); 1662 } else { 1663 dst.style.bg = sgrSafe(dimColor(src.style.bg orelse src.style.fg)); 1664 } 1665 } 1666 } 1667 } 1668 return out; 1669 } 1670 }; 1671