╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Threads.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
38 data: *const Self, 39 session: *ui.Session, 40 // per-split state, indexed like the stack's split children: the issue the 41 // pane shows, and its description, author and open/close button focus ids. 42 detailed_index: [view_count]?usize, 43 description_id: [view_count]?usize, 44 author_id: [view_count]?usize, 45 status_button_id: [view_count]?usize, 46 remove_button_id: [view_count]?usize, 47 48 const header_index: usize = 0; 49 const stack_index: usize = 1; 50 // indices within the stack, 1:1 with the header tabs. 51 const status_count = @typeInfo(Status).@"enum".fields.len; 52 const tags_view_index: usize = status_count; 53 // the new-issue form, or the edit, comment, or resolve form when the page was 54 // loaded at one of their urls. 55 const form_view_index: usize = tags_view_index + 1; 56 // the conflicts split; the tab and stack child exist only when the repo 57 // has conflicted issues. 58 const conflict_view_index: usize = form_view_index + 1; 59 const view_count: usize = conflict_view_index + @intFromBool(has_conflicts); 60 // indices within a split (the horizontal box inside the stack). 61 const list_index: usize = 0; 62 const detail_index: usize = 1; 63 const list_max_width: usize = 40; 64 const detail_min_width: usize = 40; 65 // indices within the issue form. 66 const title_field_index: usize = 0; 67 const tags_field_index: usize = 1; 68 const description_field_index: usize = 2; 69 const submit_field_index: usize = 3; 70 const comment_author_field_index: usize = 0; 71 const comment_body_field_index: usize = 1; 72 const comment_submit_field_index: usize = 2; 73 74 pub fn viewIndex(view: ViewKind) usize { 75 const name = @tagName(view); 76 if (std.mem.eql(u8, name, "tags")) return tags_view_index; 77 if (std.mem.eql(u8, name, "new") or std.mem.eql(u8, name, "edit") or std.mem.eql(u8, name, "new_comment") or std.mem.eql(u8, name, "edit_comment") or std.mem.eql(u8, name, "resolve")) return form_view_index; 78 if (std.mem.eql(u8, name, "conflicts")) return conflict_view_index; 79 if (std.mem.eql(u8, name, "description")) unreachable; 80 inline for (@typeInfo(Status).@"enum".fields, 0..) |field, index| { 81 if (std.mem.eql(u8, name, field.name)) return index; 82 } 83 unreachable; 84 } 85 86 // the status a status split lists; the conflicts split has none (its 87 // issues carry their own). 88 fn splitStatus(index: usize) Status { 89 return @enumFromInt(index); 90 } 91 92 fn entryConflicted(entry: Entry) bool { 93 return if (has_conflicts) entry.conflicted else false; 94 } 95 96 fn entryStatus(entry: Entry) Status { 97 return if (has_status) entry.record.event.status else @enumFromInt(0); 98 } 99 100 fn listRoute(identity: []const u8, status: Status, tag: []const u8, selected: []const u8) ?ui.RoutablePage { 101 return switch (kind) { 102 .issue => ui.RoutablePage.repoIssuesRoute(identity, status, tag, selected), 103 .discussion => ui.RoutablePage.repoDiscussionsRoute(identity, tag, selected), 104 else => @compileError("unsupported thread event kind"), 105 }; 106 } 107 108 fn commentsRoute(identity: []const u8, selected: []const u8, start: usize) ?ui.RoutablePage { 109 return ui.RoutablePage.repoThreadCommentsRoute(kind, identity, selected, start); 110 } 111 112 fn commentRoute(identity: []const u8, thread_id: []const u8, comment_id: []const u8, start: usize) ?ui.RoutablePage { 113 return ui.RoutablePage.repoThreadCommentRoute(kind, identity, thread_id, comment_id, start); 114 } 115 116 fn newCommentRoute(identity: []const u8, thread_id: []const u8, parent_id: []const u8) ?ui.RoutablePage { 117 return ui.RoutablePage.repoThreadCommentNewRoute(kind, identity, thread_id, parent_id); 118 } 119 120 fn editCommentRoute(identity: []const u8, thread_id: []const u8, comment_id: []const u8) ?ui.RoutablePage { 121 return ui.RoutablePage.repoThreadCommentEditRoute(kind, identity, thread_id, comment_id); 122 } 123 124 fn editRoute(identity: []const u8, selected: []const u8) ?ui.RoutablePage { 125 return ui.RoutablePage.repoThreadEditRoute(kind, identity, selected); 126 } 127 128 fn newRoute(identity: []const u8) ?ui.RoutablePage { 129 return ui.RoutablePage.repoThreadNewRoute(kind, identity); 130 } 131 132 fn descriptionRoute(identity: []const u8, selected: []const u8) ?ui.RoutablePage { 133 return ui.RoutablePage.repoThreadDescriptionRoute(kind, identity, selected); 134 } 135 136 fn tagsRoute(identity: []const u8, tag: []const u8) ?ui.RoutablePage { 137 return ui.RoutablePage.repoThreadTagsRoute(kind, identity, tag); 138 } 139 140 fn conflictsRoute(identity: []const u8, selected: []const u8) ?ui.RoutablePage { 141 return switch (kind) { 142 .issue => ui.RoutablePage.repoIssuesConflictsRoute(identity, selected), 143 else => @compileError("thread event kind has no conflict route"), 144 }; 145 } 146 147 fn resolveRoute(identity: []const u8, selected: []const u8, picks: []const u8) ?ui.RoutablePage { 148 return switch (kind) { 149 .issue => ui.RoutablePage.repoIssuesResolveRoute(identity, selected, picks), 150 else => @compileError("thread event kind has no resolve route"), 151 }; 152 } 153 154 fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8, id: []const u8) ![]const u8 { 155 const route = listRoute(identity, status, tag, id) orelse return error.RouteTooLong; 156 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{try route.toUrl(page_arena)}); 157 } 158 159 fn rowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 160 const route = listRoute(identity, @enumFromInt(0), "", id) orelse return error.RouteTooLong; 161 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{try route.toUrl(page_arena)}); 162 } 163 164 fn windowLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, status_maybe: ?Status, id: []const u8) ![]const u8 { 165 if (has_conflicts and status_maybe == null) { 166 const route = conflictsRoute(data.identity, id) orelse return error.RouteTooLong; 167 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{try route.toUrl(page_arena)}); 168 } 169 return listLink(page_arena, data.identity, status_maybe orelse @enumFromInt(0), data.tag, id); 170 } 171 172 fn descriptionLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 173 const route = descriptionRoute(identity, id) orelse return error.RouteTooLong; 174 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{try route.toUrl(page_arena)}); 175 } 176 177 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8) ![]const u8 { 178 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 179 return listLink(page_arena, identity, status, encoded, ""); 180 } 181 182 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !This { 183 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 184 errdefer outer.deinit(allocator); 185 186 // the tabs at the top. 187 { 188 var hdr = try Header.init(allocator, session, data); 189 errdefer hdr.deinit(allocator); 190 switch (kind) { 191 .issue => try outer.children.put(allocator, hdr.getFocus().id, .{ .widget = .{ .repo_issues_header = hdr }, .rect = null, .min_size = null }), 192 .discussion => try outer.children.put(allocator, hdr.getFocus().id, .{ .widget = .{ .repo_discussions_header = hdr }, .rect = null, .min_size = null }), 193 else => @compileError("unsupported thread event kind"), 194 } 195 } 196 197 // the stack enters `outer` before its children enter it, so an error 198 // frees each child exactly once. 199 { 200 var stack = try wgt.Stack(ui.Widget).init(allocator); 201 errdefer stack.deinit(allocator); 202 try outer.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 203 } 204 const stack = &outer.children.values()[stack_index].widget.stack; 205 206 inline for (@typeInfo(Status).@"enum".fields) |field| { 207 var split = try initSplit(allocator, session, data, @enumFromInt(field.value)); 208 errdefer split.deinit(allocator); 209 try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); 210 } 211 212 // the tags view 213 { 214 var tf = try ui.TagFlow.init(allocator); 215 errdefer tf.deinit(allocator); 216 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 217 defer items.deinit(allocator); 218 // when filtered, the first item clears the filter 219 if (data.tag.len != 0) 220 try items.append(allocator, .{ .text = "✕", .link = try listLink(session.page_arena, data.identity, @enumFromInt(0), "", "") }); 221 for (data.tags) |tag| 222 try items.append(allocator, .{ .text = tag, .link = try tagLink(session.page_arena, data.identity, @enumFromInt(0), tag) }); 223 try tf.setItems(allocator, items.items); 224 try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); 225 } 226 227 // the new-issue form, or — on an edit, comment, or resolve url — that form in 228 // its place, prefilled with the selected issue's content. a 229 // logged-out session can't create events, so the unauthorized view 230 // stands in. 231 if (session.data.is_local or session.data.user_id != null) { 232 if (data.view == .new_comment or data.view == .edit_comment) { 233 const editing = data.view == .edit_comment; 234 const route = if (editing) 235 editCommentRoute(data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong 236 else 237 newCommentRoute(data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong; 238 const page_url = try route.toUrl(session.page_arena); 239 const action = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{page_url}); 240 const page = data.comment_page; 241 const top_level = if (page) |p| std.mem.eql(u8, &p.selected.comment.event.parent_id, &p.selected.comment.event.thread_id) else false; 242 const parent_route = if (editing) 243 if (top_level) 244 commentsRoute(data.identity, data.selected_id, 0) 245 else if (page) |p| 246 commentRoute(data.identity, data.selected_id, &p.selected.comment.event.parent_id, 0) 247 else 248 null 249 else if (page) |p| 250 commentRoute(data.identity, data.selected_id, &p.selected.id, 0) 251 else 252 commentsRoute(data.identity, data.selected_id, 0); 253 const parent_author: ui.Author = if (editing) 254 if (top_level) 255 if (data.selectedThread()) |entry| entry.author else .unknown 256 else if (page) |p| 257 p.selected.parent_author orelse .unknown 258 else 259 .unknown 260 else if (page) |p| 261 p.selected.author 262 else if (data.selectedThread()) |entry| 263 entry.author 264 else 265 .unknown; 266 const initial_body: ?[]const u8 = if (editing) if (page) |p| p.selected.comment.event.body else null else null; 267 var form = try initCommentForm(allocator, session, action, parent_author, parent_route orelse return error.RouteTooLong, initial_body); 268 errdefer form.deinit(allocator); 269 try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); 270 } else if (has_conflicts and data.view == .resolve) { 271 // on the web the page grows to the form's height and the 272 // browser scrolls it; the terminal scrolls the form itself 273 var form_widget: ui.Widget = blk: { 274 var form = try initResolveForm(allocator, session, data); 275 errdefer form.deinit(allocator); 276 break :blk if (session.is_terminal) 277 .{ .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = form }, .{ .direction = .vert }) } 278 else 279 .{ .box = form }; 280 }; 281 errdefer form_widget.deinit(allocator); 282 try stack.children.put(allocator, form_widget.getFocus().id, form_widget); 283 } else { 284 const aa = session.page_arena.allocator(); 285 const route = if (data.view == .edit) 286 editRoute(data.identity, data.selected_id) orelse return error.RouteTooLong 287 else 288 newRoute(data.identity) orelse return error.RouteTooLong; 289 const action = try std.fmt.allocPrint(aa, "form:{s}", .{try route.toUrl(session.page_arena)}); 290 const record = if (data.view == .edit) 291 (if (data.selectedThread()) |entry| &entry.record else null) 292 else 293 null; 294 var form = try initThreadForm(allocator, session, action, record); 295 errdefer form.deinit(allocator); 296 try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); 297 } 298 } else { 299 var message = try ui.Unauthorized.View.init(allocator); 300 errdefer message.deinit(allocator); 301 try stack.children.put(allocator, message.getFocus().id, .{ .unauthorized = message }); 302 } 303 304 // the conflicts split; its tab exists only when the repo has 305 // conflicts, so skip the child too to keep the stack 1:1 with the 306 // tabs. 307 if (has_conflicts and data.conflicts.count > 0) { 308 var split = try initSplit(allocator, session, data, null); 309 errdefer split.deinit(allocator); 310 try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); 311 } 312 313 // the stack starts on the page's view. 314 stack.getFocus().child_id = stack.children.keys()[viewIndex(data.view)]; 315 316 // focus entering the view lands on the tabs first. 317 outer.getFocus().child_id = outer.children.keys()[header_index]; 318 319 return .{ 320 .box = outer, 321 .data = data, 322 .session = session, 323 .detailed_index = @splat(null), 324 .description_id = @splat(null), 325 .author_id = @splat(null), 326 .status_button_id = @splat(null), 327 .remove_button_id = @splat(null), 328 }; 329 } 330 331 // the master-detail split showing `status`'s window, or the conflicts 332 // window when null. 333 fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status_maybe: ?Status) !wgt.Box(ui.Widget) { 334 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 335 errdefer box.deinit(allocator); 336 337 const win = if (status_maybe) |status| data.window(status) else if (has_conflicts) &data.conflicts else unreachable; 338 339 // the issue list (one focusable row per title), plus a "next" link that 340 // reloads the page rooted at the following issue. 341 { 342 var list_scroll = blk: { 343 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 344 errdefer list_box.deinit(allocator); 345 if (win.prev_id) |prev| 346 try addRow(allocator, &list_box, "← previous", "", try windowLink(session.page_arena, data, status_maybe, prev)); 347 for (win.items) |entry| { 348 // conflicts take precedence over the comment count 349 const label: []const u8 = if (entryConflicted(entry) and status_maybe != null) 350 " conflict " 351 else if (entry.comments.count > 0) 352 try std.fmt.allocPrint(session.page_arena.allocator(), " {d} comments ", .{entry.comments.count}) 353 else 354 ""; 355 try addRow(allocator, &list_box, entry.record.event.title, label, try rowLink(session.page_arena, data.identity, entry.id)); 356 } 357 if (win.next_id) |next| 358 try addRow(allocator, &list_box, "next →", "", try windowLink(session.page_arena, data, status_maybe, next)); 359 // select the window's first issue (past a leading "previous" 360 // row) so its description shows on load. 361 if (win.items.len > 0) 362 list_box.getFocus().child_id = list_box.children.keys()[if (win.prev_id != null) 1 else 0] 363 else if (list_box.children.count() > 0) 364 list_box.getFocus().child_id = list_box.children.keys()[0]; 365 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 366 }; 367 errdefer list_scroll.deinit(allocator); 368 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 369 } 370 371 // the detail pane — a frame around a scroll of the description 372 { 373 var detail_outer = blk: { 374 var detail_scroll = blk2: { 375 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 376 errdefer detail_inner.deinit(allocator); 377 // fill the pane (content top-left, scroll bar pinned to the 378 // edge) rather than shrinking to the description. 379 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 380 }; 381 errdefer detail_scroll.deinit(allocator); 382 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 383 errdefer frame.deinit(allocator); 384 // the tool row sits above the scroll (populateDetail fills 385 // it per issue) so it can't scroll out from under the web 386 // overlay <form>, whose position doesn't track pane scrolling. 387 { 388 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 389 errdefer row.deinit(allocator); 390 try frame.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 391 } 392 // the frame's selected child is its scroll, so the focus chain 393 // reaches the description (populateDetail points the scroll's 394 // inner box at it), letting focus recovery descend into the pane. 395 frame.getFocus().child_id = detail_scroll.getFocus().id; 396 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 397 break :blk frame; 398 }; 399 errdefer detail_outer.deinit(allocator); 400 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 401 } 402 403 box.getFocus().child_id = box.children.keys()[list_index]; 404 return box; 405 } 406 407 // a thread form: title/tags/description inputs and a submit button, 408 // prefilled from `record` when given. its form: subtree makes the web 409 // overlay wrap them in a <form> POSTing to `action`'s route. 410 fn initThreadForm(allocator: std.mem.Allocator, session: *ui.Session, action: []const u8, record: ?*const Event.Record) !wgt.Box(ui.Widget) { 411 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 412 errdefer box.deinit(allocator); 413 box.getFocus().kind = .{ .custom = action }; 414 415 { 416 var title = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " title ", .name = "title", .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal }); 417 errdefer title.deinit(allocator); 418 title.getFocus().focusable = true; 419 if (record) |r| try title.setContent(allocator, r.event.title); 420 try box.children.put(allocator, title.getFocus().id, .{ .widget = .{ .text_input = title }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 421 } 422 423 { 424 var tags = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " tags (separate with spaces) ", .name = "tags", .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal }); 425 errdefer tags.deinit(allocator); 426 tags.getFocus().focusable = true; 427 if (record) |r| try tags.setContent(allocator, r.event.tags); 428 try box.children.put(allocator, tags.getFocus().id, .{ .widget = .{ .text_input = tags }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 429 } 430 431 { 432 var description = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " description ", .name = "description", .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal, .multiline = true, .scroll = .{ .fill = true } }); 433 errdefer description.deinit(allocator); 434 description.getFocus().focusable = true; 435 if (record) |r| try description.setContent(allocator, r.event.description); 436 try box.children.put(allocator, description.getFocus().id, .{ .widget = .{ .text_input = description }, .rect = null, .min_size = null }); 437 } 438 439 try addSubmitButton(allocator, &box); 440 441 box.getFocus().child_id = box.children.keys()[title_field_index]; 442 return box; 443 } 444 445 fn initCommentForm(allocator: std.mem.Allocator, session: *ui.Session, action: []const u8, author: ui.Author, parent_route: ui.RoutablePage, initial_body: ?[]const u8) !wgt.Box(ui.Widget) { 446 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 447 errdefer box.deinit(allocator); 448 box.getFocus().kind = .{ .custom = action }; 449 450 { 451 var parent = try ui.authorBox(allocator, session.page_arena, author); 452 errdefer parent.deinit(allocator); 453 parent.options.label = " replying to "; 454 parent.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try parent_route.toUrl(session.page_arena)}) }; 455 try box.children.put(allocator, parent.getFocus().id, .{ .widget = .{ .text_box = parent }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 456 } 457 458 { 459 var body = try wgt.TextInput(ui.Widget).init(allocator, .{ 460 .label = " comment ", 461 .name = "body", 462 .visible_width = null, 463 .rounded_corners = true, 464 .render_content = session.is_terminal, 465 .multiline = true, 466 .visible_height = 5, 467 .scroll = .{ .fill = true }, 468 }); 469 errdefer body.deinit(allocator); 470 body.getFocus().focusable = true; 471 if (initial_body) |text| try body.setContent(allocator, text); 472 try box.children.put(allocator, body.getFocus().id, .{ .widget = .{ .text_input = body }, .rect = null, .min_size = null }); 473 } 474 475 try addSubmitButtonLabeled(allocator, &box, "submit"); 476 box.getFocus().child_id = box.children.keys()[comment_author_field_index]; 477 return box; 478 } 479 480 // a form's submit button, then a spacer absorbing the leftover 481 // min-height the box hands its last child, so the button keeps its 482 // natural height 483 fn addSubmitButton(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget)) !void { 484 return addSubmitButtonLabeled(allocator, box, "submit"); 485 } 486 487 fn addSubmitButtonLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8) !void { 488 { 489 var submit = try ui.SubmitButton.initLabeled(allocator, label); 490 errdefer submit.deinit(allocator); 491 try box.children.put(allocator, submit.getFocus().id, .{ .widget = .{ .submit_button = submit }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 492 } 493 { 494 var spacer = try ui.Spacer.init(allocator); 495 errdefer spacer.deinit(allocator); 496 try box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 497 } 498 } 499 500 // the conflict resolve form. the "use this" links are navigations that 501 // flip the url's theirs: pick, reloading the prefills; one submit 502 // settles every conflict. the form: subtree makes the web overlay POST 503 // to the resolve route. 504 fn initResolveForm(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !wgt.Box(ui.Widget) { 505 const aa = session.page_arena.allocator(); 506 // init only picks the resolve view once it has read the conflict 507 const conflict = if (data.conflict) |*c| c else unreachable; 508 509 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 510 errdefer box.deinit(allocator); 511 const route = resolveRoute(data.identity, data.selected_id, "") orelse return error.RouteTooLong; 512 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(aa, "form:{s}", .{try route.toUrl(session.page_arena)}) }; 513 514 if (conflict.title) |*fc| { 515 try addLabel(allocator, &box, "title conflict:"); 516 try addFieldConflict(allocator, &box, session, data, "title", fc); 517 } 518 if (conflict.tags) |*fc| { 519 try addLabel(allocator, &box, "tags conflict:"); 520 try addFieldConflict(allocator, &box, session, data, "tags", fc); 521 } 522 523 if (conflict.description) |*desc| { 524 try addLabel(allocator, &box, "description conflict:"); 525 var hunk_index: usize = 0; 526 for (desc.chunks, 0..) |*chunk, chunk_index| { 527 switch (chunk.*) { 528 .same => |text| { 529 var tb = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word }); 530 errdefer tb.deinit(allocator); 531 tb.getFocus().focusable = true; 532 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 533 }, 534 // an auto-resolved chunk: only one side changed it, so its 535 // version stands, shown distinctly with its author 536 .auto => |auto| { 537 const author = if (auto.theirs) desc.theirs_author else desc.ours_author; 538 const verb: []const u8 = if (auto.text == null) "removed" else "edited"; 539 const label = switch (author) { 540 .user_name, .email => |name| try std.fmt.allocPrint(aa, " {s} by {s} ", .{ verb, name }), 541 .unknown => try std.fmt.allocPrint(aa, " {s} by {s} ", .{ verb, if (auto.theirs) @as([]const u8, "them") else "us" }), 542 }; 543 var tb = try wgt.TextBox(ui.Widget).init(allocator, auto.text orelse "(removed)", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = label }); 544 errdefer tb.deinit(allocator); 545 tb.getFocus().focusable = true; 546 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 547 }, 548 .conflict => |hunk| { 549 // a blank row on each side sets the group apart from 550 // the flowing shared and auto-resolved chunks 551 if (chunk_index > 0) try addGap(allocator, &box); 552 const name = try std.fmt.allocPrint(aa, "d{d}", .{hunk_index}); 553 hunk_index += 1; 554 const picked_theirs = data.theirsPicked(name); 555 try addVersionRow(allocator, &box, try sideLabel(aa, desc.ours_author, true), hunk.ours orelse "", try useThisLink(session, data, name, false)); 556 try addVersionRow(allocator, &box, try sideLabel(aa, desc.theirs_author, false), hunk.theirs orelse "", try useThisLink(session, data, name, true)); 557 var resolution_input = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " resolution ", .name = name, .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal, .multiline = true }); 558 errdefer resolution_input.deinit(allocator); 559 resolution_input.getFocus().focusable = true; 560 try resolution_input.setContent(allocator, (if (picked_theirs) hunk.theirs else hunk.ours) orelse ""); 561 try box.children.put(allocator, resolution_input.getFocus().id, .{ .widget = .{ .text_input = resolution_input }, .rect = null, .min_size = null }); 562 if (chunk_index + 1 < desc.chunks.len) try addGap(allocator, &box); 563 }, 564 } 565 } 566 } 567 568 try addSubmitButton(allocator, &box); 569 570 box.getFocus().child_id = box.children.keys()[0]; 571 return box; 572 } 573 574 // one conflicted scalar field: both sides' version rows, then the 575 // resolution input prefilled from the picked side. 576 fn addFieldConflict(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, data: *const Self, comptime name: []const u8, fc: *const FieldConflict) !void { 577 const aa = session.page_arena.allocator(); 578 try addVersionRow(allocator, box, try sideLabel(aa, fc.ours.author, true), fc.ours.text, try useThisLink(session, data, name, false)); 579 try addVersionRow(allocator, box, try sideLabel(aa, fc.theirs.author, false), fc.theirs.text, try useThisLink(session, data, name, true)); 580 581 var resolution_input = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " resolution ", .name = name, .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal }); 582 errdefer resolution_input.deinit(allocator); 583 resolution_input.getFocus().focusable = true; 584 try resolution_input.setContent(allocator, if (data.theirsPicked(name)) fc.theirs.text else fc.ours.text); 585 try box.children.put(allocator, resolution_input.getFocus().id, .{ .widget = .{ .text_input = resolution_input }, .rect = null, .min_size = null }); 586 } 587 588 // a blank row setting a conflict group apart from its neighbors 589 fn addGap(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget)) !void { 590 var text = try wgt.Text(ui.Widget).init(allocator, " "); 591 errdefer text.deinit(allocator); 592 try box.children.put(allocator, text.getFocus().id, .{ .widget = .{ .text = text }, .rect = null, .min_size = null }); 593 } 594 595 fn addLabel(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), content: []const u8) !void { 596 var label = try ui.SectionLabel.init(allocator, content); 597 errdefer label.deinit(allocator); 598 try box.children.put(allocator, label.getFocus().id, .{ .widget = .{ .section_label = label }, .rect = null, .min_size = null }); 599 } 600 601 // one side of a conflict: the "use this" link on the left, the version 602 // itself as a labeled read-only box. 603 fn addVersionRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: []const u8) !void { 604 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 605 errdefer row.deinit(allocator); 606 607 { 608 const use_label = "use this"; 609 var use = try wgt.TextBox(ui.Widget).init(allocator, use_label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 610 errdefer use.deinit(allocator); 611 use.getFocus().focusable = true; 612 use.getFocus().kind = .{ .custom = link }; 613 try row.children.put(allocator, use.getFocus().id, .{ 614 .widget = .{ .text_box = use }, 615 .rect = null, 616 .min_size = .{ .width = use_label.len + 2, .height = null }, 617 .max_size = .{ .width = use_label.len + 2, .height = null }, 618 }); 619 } 620 621 { 622 var tb = try wgt.TextBox(ui.Widget).init(allocator, if (text.len > 0) text else "(removed)", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = label }); 623 errdefer tb.deinit(allocator); 624 tb.getFocus().focusable = true; 625 try row.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 626 } 627 628 row.getFocus().child_id = row.children.keys()[0]; 629 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 630 } 631 632 // the label of one side's version box, naming its author 633 fn sideLabel(aa: std.mem.Allocator, author: ui.Author, ours: bool) ![]const u8 { 634 return switch (author) { 635 .user_name, .email => |name| try std.fmt.allocPrint(aa, " edited by {s} ", .{name}), 636 .unknown => if (ours) " current version " else " their version ", 637 }; 638 } 639 640 // the a: link that reloads the resolve page with `name` picked from ours 641 // or theirs, reprefilling its resolution input 642 fn useThisLink(session: *ui.Session, data: *const Self, name: []const u8, theirs: bool) ![]const u8 { 643 const aa = session.page_arena.allocator(); 644 var picks: std.ArrayList(u8) = .empty; 645 var it = std.mem.splitScalar(u8, data.theirs_picks, ','); 646 while (it.next()) |pick| { 647 if (pick.len == 0 or std.mem.eql(u8, pick, name)) continue; 648 if (picks.items.len > 0) try picks.append(aa, ','); 649 try picks.appendSlice(aa, pick); 650 } 651 if (theirs) { 652 if (picks.items.len > 0) try picks.append(aa, ','); 653 try picks.appendSlice(aa, name); 654 } 655 const route = resolveRoute(data.identity, data.selected_id, picks.items) orelse return error.RouteTooLong; 656 return std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(session.page_arena)}); 657 } 658 659 // the resolve form's page-constant initial value for the input `name`: 660 // the picked side of its conflicted field or hunk 661 fn resolvePrefill(self: *This, name: []const u8) ?[]const u8 { 662 const conflict = if (self.data.conflict) |*c| c else return null; 663 const theirs = self.data.theirsPicked(name); 664 if (std.mem.eql(u8, name, "title")) { 665 const fc = if (conflict.title) |*f| f else return null; 666 return if (theirs) fc.theirs.text else fc.ours.text; 667 } 668 if (std.mem.eql(u8, name, "tags")) { 669 const fc = if (conflict.tags) |*f| f else return null; 670 return if (theirs) fc.theirs.text else fc.ours.text; 671 } 672 if (std.mem.startsWith(u8, name, "d")) { 673 const desc = if (conflict.description) |*d| d else return null; 674 const hunk_index = std.fmt.parseInt(usize, name[1..], 10) catch return null; 675 var seen: usize = 0; 676 for (desc.chunks) |chunk| switch (chunk) { 677 .conflict => |hunk| { 678 if (seen == hunk_index) return (if (theirs) hunk.theirs else hunk.ours) orelse ""; 679 seen += 1; 680 }, 681 else => {}, 682 }; 683 } 684 return null; 685 } 686 687 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, bottom_label: []const u8, link: []const u8) !void { 688 var row = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word, .bottom_label = bottom_label }); 689 errdefer row.deinit(allocator); 690 row.getFocus().focusable = true; 691 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 692 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 693 } 694 695 pub fn deinit(self: *This, allocator: std.mem.Allocator) void { 696 self.box.deinit(allocator); 697 } 698 699 fn header(self: *This) *Header { 700 return switch (kind) { 701 .issue => &self.box.children.values()[header_index].widget.repo_issues_header, 702 .discussion => &self.box.children.values()[header_index].widget.repo_discussions_header, 703 else => @compileError("unsupported thread event kind"), 704 }; 705 } 706 707 fn viewStack(self: *This) *wgt.Stack(ui.Widget) { 708 return &self.box.children.values()[stack_index].widget.stack; 709 } 710 711 // `index`'s master-detail split inside the stack. 712 fn resultsBox(self: *This, index: usize) *wgt.Box(ui.Widget) { 713 return &self.viewStack().children.values()[index].box; 714 } 715 716 // the tags view's flow inside the stack. 717 fn tagsView(self: *This) *ui.TagFlow { 718 return &self.viewStack().children.values()[tags_view_index].tag_flow; 719 } 720 721 // the new-issue, edit, or resolve form inside the stack, or null when the 722 // unauthorized view stands in for it. 723 fn threadForm(self: *This) ?*wgt.Box(ui.Widget) { 724 return switch (self.viewStack().children.values()[form_view_index]) { 725 .box => |*box| box, 726 // the resolve form sits inside a scroll on the terminal 727 .scroll => |*scroll| &scroll.child.box, 728 else => null, 729 }; 730 } 731 732 // the resolve form's scroll on the terminal (the web page scrolls itself) 733 fn resolveScroll(self: *This) ?*wgt.Scroll(ui.Widget) { 734 return switch (self.viewStack().children.values()[form_view_index]) { 735 .scroll => |*scroll| scroll, 736 else => null, 737 }; 738 } 739 740 fn listScroll(self: *This, index: usize) *wgt.Scroll(ui.Widget) { 741 return &self.resultsBox(index).children.values()[list_index].widget.scroll; 742 } 743 744 fn listBox(self: *This, index: usize) *wgt.Box(ui.Widget) { 745 return &self.listScroll(index).child.box; 746 } 747 748 // the detail frame's children: the tool row above the scroll pane. 749 const tool_row_index: usize = 0; 750 const detail_scroll_index: usize = 1; 751 752 fn detailOuter(self: *This, index: usize) *wgt.Box(ui.Widget) { 753 return &self.resultsBox(index).children.values()[detail_index].widget.box; 754 } 755 756 fn toolRow(self: *This, index: usize) *wgt.Box(ui.Widget) { 757 return &self.detailOuter(index).children.values()[tool_row_index].widget.box; 758 } 759 760 fn detailScroll(self: *This, index: usize) *wgt.Scroll(ui.Widget) { 761 return &self.detailOuter(index).children.values()[detail_scroll_index].widget.scroll; 762 } 763 764 fn detailInner(self: *This, index: usize) *wgt.Box(ui.Widget) { 765 return &self.detailScroll(index).child.box; 766 } 767 768 fn window(self: *This, index: usize) *const Window { 769 if (has_conflicts and index == conflict_view_index) return &self.data.conflicts; 770 return self.data.window(splitStatus(index)); 771 } 772 773 fn stackSelectedIndex(self: *This) ?usize { 774 const stack = self.viewStack(); 775 const cid = stack.getFocus().child_id orelse return null; 776 return stack.children.getIndex(cid); 777 } 778 779 // the stack's selected master-detail split (null when the tags view or the 780 // new-issue form shows). 781 fn selectedSplitIndex(self: *This) ?usize { 782 const idx = self.stackSelectedIndex() orelse return null; 783 return if (idx < status_count or idx == conflict_view_index) idx else null; 784 } 785 786 fn detailActive(self: *This, index: usize) bool { 787 const rb = self.resultsBox(index); 788 const cid = rb.getFocus().child_id orelse return false; 789 return rb.children.getIndex(cid) == detail_index; 790 } 791 792 fn headerActive(self: *This) bool { 793 const cid = self.box.getFocus().child_id orelse return false; 794 return self.box.children.getIndex(cid) == header_index; 795 } 796 797 fn tagsViewActive(self: *This) bool { 798 if (self.headerActive()) return false; 799 return self.stackSelectedIndex() == tags_view_index; 800 } 801 802 fn formViewActive(self: *This) bool { 803 if (self.headerActive()) return false; 804 return self.stackSelectedIndex() == form_view_index; 805 } 806 807 // the selected issue's index, or null when a window-navigation row is 808 // selected (a leading "previous" row shifts the issue rows down by one). 809 fn selectedThreadIndex(self: *This, index: usize) ?usize { 810 const lb = self.listBox(index); 811 const cid = lb.getFocus().child_id orelse return null; 812 const idx = lb.children.getIndex(cid) orelse return null; 813 const win = self.window(index); 814 const lead: usize = if (win.prev_id != null) 1 else 0; 815 if (idx < lead or idx - lead >= win.items.len) return null; 816 return idx - lead; 817 } 818 819 pub fn build(self: *This, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 820 self.clearGrid(); 821 822 // each header tab maps 1:1 to a stack child by position. mirror the 823 // selection into the url: the view the page's window is rooted at 824 // keeps its rooted url, the others get their list route (keeping the 825 // tag filter). 826 if (self.header().getSelectedIndex()) |index| { 827 const stack = self.viewStack(); 828 stack.getFocus().child_id = stack.children.keys()[index]; 829 self.session.data.current_page = (if (index == form_view_index) 830 (if (has_conflicts) switch (self.data.view) { 831 .edit => editRoute(self.data.identity, self.data.selected_id), 832 .new_comment => newCommentRoute(self.data.identity, self.data.selected_id, self.data.comment_id), 833 .edit_comment => editCommentRoute(self.data.identity, self.data.selected_id, self.data.comment_id), 834 .resolve => resolveRoute(self.data.identity, self.data.selected_id, self.data.theirs_picks), 835 else => newRoute(self.data.identity), 836 } else switch (self.data.view) { 837 .edit => editRoute(self.data.identity, self.data.selected_id), 838 .new_comment => newCommentRoute(self.data.identity, self.data.selected_id, self.data.comment_id), 839 .edit_comment => editCommentRoute(self.data.identity, self.data.selected_id, self.data.comment_id), 840 else => newRoute(self.data.identity), 841 }) 842 else if (has_conflicts and index == conflict_view_index) 843 conflictsRoute(self.data.identity, if (self.data.view == .conflicts) self.data.selected_id else "") 844 else if (index == viewIndex(self.data.view) and self.data.selected_id.len != 0) 845 (if (self.data.description_page) 846 descriptionRoute(self.data.identity, self.data.selected_id) 847 else if (self.data.comment_page) |page| 848 commentRoute(self.data.identity, self.data.selected_id, &page.selected.id, page.replies.start) 849 else 850 commentsRoute(self.data.identity, self.data.selected_id, self.data.comments_start)) 851 else if (index == tags_view_index) 852 tagsRoute(self.data.identity, self.data.tag) 853 else 854 listRoute(self.data.identity, splitStatus(index), self.data.tag, "")) orelse self.session.data.current_page; 855 } 856 857 if (self.selectedSplitIndex()) |i| { 858 // swap the detail pane to the selected issue when it changes. 859 try self.refreshDetail(allocator, i); 860 861 // mirror the focused issue into the url, but only while focus is 862 // inside the split. an issue's url is the same whether or not the 863 // list is filtered, so the mirror drops the tag. 864 if (root_focus.grandchild_id) |g| { 865 if (self.resultsBox(i).getFocus().children.contains(g)) { 866 if (self.selectedThreadIndex(i)) |sel| { 867 // selecting another issue leaves the description page 868 // behind, like it leaves the tag filter behind. 869 const entry = &self.window(i).items[sel]; 870 const mirrored = if (self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id)) 871 descriptionRoute(self.data.identity, entry.id) 872 else if (self.data.comment_page) |page| 873 if (std.mem.eql(u8, entry.id, self.data.selected_id)) 874 commentRoute(self.data.identity, entry.id, &page.selected.id, page.replies.start) 875 else 876 commentsRoute(self.data.identity, entry.id, 0) 877 else if (std.mem.eql(u8, entry.id, self.data.selected_id)) 878 commentsRoute(self.data.identity, entry.id, entry.comments.start) 879 else 880 commentsRoute(self.data.identity, entry.id, 0); 881 if (mirrored) |route| self.session.data.current_page = route; 882 } 883 } 884 } 885 886 // the selected list row shows a border (the focused TextBox 887 // upgrades it to a double border itself); the rest stay borderless. 888 const lb = self.listBox(i); 889 for (lb.children.keys(), lb.children.values()) |id, *child| { 890 switch (child.widget) { 891 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 892 else => {}, 893 } 894 } 895 896 // the pane's selected child shows the selection border: the 897 // description directly, the tags via the flow's selected item. 898 // cap the list at list_max_width only while the detail pane fits 899 // beside it. the box drops the detail when the width can't hold 900 // both minimums, so when it's that narrow we lift the cap and let 901 // the list fill the whole width. 902 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 903 self.resultsBox(i).children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 904 905 // stretch the detail pane across the rest of the width so it fills 906 // the area rather than shrinking to its content; its scroll fills 907 // the pane. 908 if (constraint.max_size.width) |w| { 909 self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 910 } else { 911 self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 912 } 913 } 914 915 // refresh the form inputs' entries in the session's focus-id -> input 916 // map with this frame's addresses, so the web/wasm form handling can 917 // find them by focus id 918 if (self.threadForm()) |form| { 919 const inputs_arena = self.session.arena.allocator(); 920 for (form.children.values()) |*child| switch (child.widget) { 921 .text_input => |*ti| try self.session.text_inputs.put(inputs_arena, ti.getFocus().id, ti), 922 else => {}, 923 }; 924 925 // on an edit page, register the issue's content as the inputs' 926 // page-constant initial values, which the web overlay renders 927 // into them. 928 if (self.data.view == .edit) { 929 if (self.data.selectedThread()) |entry| { 930 const fields = form.children.values(); 931 try self.session.input_values.put(inputs_arena, fields[title_field_index].widget.text_input.getFocus().id, entry.record.event.title); 932 try self.session.input_values.put(inputs_arena, fields[tags_field_index].widget.text_input.getFocus().id, entry.record.event.tags); 933 try self.session.input_values.put(inputs_arena, fields[description_field_index].widget.text_input.getFocus().id, entry.record.event.description); 934 } 935 } 936 937 if (self.data.view == .edit_comment) { 938 if (self.data.comment_page) |page| { 939 const body = &form.children.values()[comment_body_field_index].widget.text_input; 940 try self.session.input_values.put(inputs_arena, body.getFocus().id, page.selected.comment.event.body); 941 } 942 } 943 944 // the resolve form's inputs prefill from the picked side, which 945 // is url state and so page-constant like the edit values above 946 if (has_conflicts and self.data.view == .resolve) { 947 for (form.children.values()) |*child| switch (child.widget) { 948 .text_input => |*ti| if (self.resolvePrefill(ti.options.name)) |value| { 949 try self.session.input_values.put(inputs_arena, ti.getFocus().id, value); 950 }, 951 else => {}, 952 }; 953 } 954 } 955 956 try self.box.build(allocator, constraint, root_focus); 957 } 958 959 fn refreshDetail(self: *This, allocator: std.mem.Allocator, index: usize) !void { 960 const sel = self.selectedThreadIndex(index) orelse return; 961 if (self.detailed_index[index]) |d| if (d == sel) return; 962 try self.populateDetail(allocator, index, sel); 963 self.detailed_index[index] = sel; 964 } 965 966 fn populateDetail(self: *This, allocator: std.mem.Allocator, index: usize, sel: usize) !void { 967 const entry = self.window(index).items[sel]; 968 const inner = self.detailInner(index); 969 // the /description page replaces its issue's detail with a back link 970 // and the whole description; other issues keep their normal detail. 971 const description_page = self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id); 972 const comment_page = if (std.mem.eql(u8, entry.id, self.data.selected_id)) self.data.comment_page else null; 973 974 for (inner.children.values()) |*child| child.widget.deinit(allocator); 975 inner.children.clearAndFree(allocator); 976 inner.getFocus().child_id = null; 977 self.author_id[index] = null; 978 self.status_button_id[index] = null; 979 self.remove_button_id[index] = null; 980 981 // the tool row: the open/close and edit buttons; the description page 982 // shows none. 983 { 984 const row = self.toolRow(index); 985 for (row.children.values()) |*child| child.widget.deinit(allocator); 986 row.children.clearAndFree(allocator); 987 row.getFocus().child_id = null; 988 row.getFocus().kind = .container; 989 } 990 if (!description_page and comment_page == null) { 991 const row = self.toolRow(index); 992 const pa = self.session.page_arena.allocator(); 993 994 { 995 var spacer = try ui.Spacer.init(allocator); 996 errdefer spacer.deinit(allocator); 997 try row.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 998 } 999 1000 // a terminal has no file picker, so attaching is web only. the 1001 // renderer covers this button with a file input, so clicking it 1002 // opens the browser's own picker and posts what it chose. 1003 if (!self.session.is_terminal and !entryConflicted(entry) and (self.session.data.is_local or self.session.data.user_id != null)) { 1004 const label = "add attachment"; 1005 var button = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1006 errdefer button.deinit(allocator); 1007 button.getFocus().focusable = true; 1008 button.getFocus().kind = .{ .custom = if (self.data.identity.len == 0) 1009 try std.fmt.allocPrint(pa, "{s}/{s}:{s}/attach", .{ ui.file_input_prefix, @tagName(kind), entry.id }) 1010 else 1011 try std.fmt.allocPrint(pa, "{s}/repo/{s}/{s}:{s}/attach", .{ ui.file_input_prefix, self.data.identity, @tagName(kind), entry.id }) }; 1012 try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = label.len + 2, .height = null } }); 1013 } 1014 1015 // a conflicted issue's only action is resolving it. the button 1016 // stays visible logged out; the resolve page shows the 1017 // unauthorized view then. 1018 if (has_conflicts and entryConflicted(entry)) { 1019 const label = "resolve conflict"; 1020 var button = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1021 errdefer button.deinit(allocator); 1022 button.getFocus().focusable = true; 1023 const route = resolveRoute(self.data.identity, entry.id, "") orelse return error.RouteTooLong; 1024 button.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)}) }; 1025 try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = label.len + 2, .height = null } }); 1026 } else { 1027 // a logged-out session can't flip the status, so it gets no 1028 // open/close button 1029 if (has_status and (self.session.data.is_local or self.session.data.user_id != null)) { 1030 const action: []const u8 = switch (entryStatus(entry)) { 1031 .open => "close", 1032 .closed => "open", 1033 }; 1034 const route = commentsRoute(self.data.identity, entry.id, 0) orelse return error.RouteTooLong; 1035 row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/{s}", .{ try route.toUrl(self.session.page_arena), action }) }; 1036 1037 var button = try wgt.TextBox(ui.Widget).init(allocator, action, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1038 errdefer button.deinit(allocator); 1039 button.getFocus().focusable = true; 1040 // the renderer distinguishes plain clickables from buttons 1041 // that should POST to a server route by this kind. 1042 button.getFocus().kind = .{ .custom = "submit" }; 1043 self.status_button_id[index] = button.getFocus().id; 1044 try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = action.len + 2, .height = null } }); 1045 } 1046 1047 // the edit button links to the issue's edit page. 1048 { 1049 var button = try wgt.TextBox(ui.Widget).init(allocator, "edit", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1050 errdefer button.deinit(allocator); 1051 button.getFocus().focusable = true; 1052 const route = editRoute(self.data.identity, entry.id) orelse return error.RouteTooLong; 1053 button.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)}) }; 1054 try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = "edit".len + 2, .height = null } }); 1055 } 1056 } 1057 1058 if (self.session.data.is_local or self.session.data.user_id != null) { 1059 const thread_route = commentsRoute(self.data.identity, entry.id, 0) orelse return error.RouteTooLong; 1060 const remove_url = try std.fmt.allocPrint(pa, "{s}/remove", .{try thread_route.toUrl(self.session.page_arena)}); 1061 const action_kind: []const u8 = if (entryConflicted(entry)) blk: { 1062 row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}", .{remove_url}) }; 1063 break :blk "submit"; 1064 } else try std.fmt.allocPrint(pa, "{s}{s}", .{ ui.submit_action_prefix, remove_url }); 1065 1066 var remove = try wgt.TextBox(ui.Widget).init(allocator, "✕", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1067 errdefer remove.deinit(allocator); 1068 remove.getFocus().focusable = true; 1069 remove.getFocus().kind = .{ .custom = action_kind }; 1070 self.remove_button_id[index] = remove.getFocus().id; 1071 try row.children.put(allocator, remove.getFocus().id, .{ .widget = .{ .text_box = remove }, .rect = null, .min_size = .{ .width = 3, .height = null } }); 1072 } 1073 1074 // the leftmost button: the attachment button when the session has 1075 // one, else the resolve button on a conflicted issue, the 1076 // open/close button when present, the edit button otherwise 1077 row.getFocus().child_id = row.children.keys()[first_in_row_index]; 1078 } 1079 1080 if (comment_page) |page| { 1081 const back_label = try std.fmt.allocPrint(self.session.page_arena.allocator(), "← back to {s}", .{@tagName(kind)}); 1082 var back = try Comment.linkBox(allocator, self.session, back_label, commentsRoute(self.data.identity, entry.id, 0) orelse return error.RouteTooLong); 1083 errdefer back.deinit(allocator); 1084 try inner.children.put(allocator, back.getFocus().id, .{ .widget = .{ .text_box = back }, .rect = null, .min_size = null }); 1085 1086 try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, page.selected); 1087 try Comment.appendCount(allocator, inner, page.replies.count, "reply", "replies", self.session.page_arena.allocator()); 1088 for (page.replies.comments) |comment| try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, comment); 1089 try Comment.appendWindowNav(allocator, inner, self.session, self.data.identity, kind, &page.selected.comment.event.thread_id, &page.selected.id, page.replies); 1090 1091 var spacer = try ui.Spacer.init(allocator); 1092 errdefer spacer.deinit(allocator); 1093 try inner.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 1094 1095 inner.getFocus().child_id = inner.children.keys()[0]; 1096 const sc = self.detailScroll(index); 1097 sc.x = 0; 1098 sc.y = 0; 1099 sc.getFocus().version +%= 1; 1100 return; 1101 } 1102 1103 if (description_page) { 1104 // the back link, in the title slot so the pane's input handling 1105 // applies to it unchanged. 1106 const back_label = try std.fmt.allocPrint(self.session.page_arena.allocator(), "← back to {s}", .{@tagName(kind)}); 1107 var tb = try wgt.TextBox(ui.Widget).init(allocator, back_label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1108 errdefer tb.deinit(allocator); 1109 tb.getFocus().focusable = true; 1110 tb.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id) }; 1111 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1112 } else { 1113 // the issue's title as a focusable word-wrapped text box. 1114 { 1115 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.record.event.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " }); 1116 errdefer tb.deinit(allocator); 1117 tb.getFocus().focusable = true; 1118 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1119 } 1120 1121 self.author_id[index] = blk: { 1122 var tb = try ui.authorBox(allocator, self.session.page_arena, entry.author); 1123 errdefer tb.deinit(allocator); 1124 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1125 break :blk tb.getFocus().id; 1126 }; 1127 1128 // the issue's tags, each linking to this status's list filtered to 1129 // that tag. 1130 { 1131 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 1132 defer items.deinit(allocator); 1133 var tag_iter = Event.tagIterator(entry.record.event.tags); 1134 while (tag_iter.next()) |tag| { 1135 if (tag.len == 0) continue; 1136 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, entryStatus(entry), tag) }); 1137 } 1138 if (items.items.len > 0) { 1139 var tf = try ui.TagFlow.init(allocator); 1140 errdefer tf.deinit(allocator); 1141 try tf.setItems(allocator, items.items); 1142 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 1143 } 1144 } 1145 } 1146 1147 // the description as a focusable word-wrapped text box. past the limit 1148 // it's cut at the last whole line and links to the /description page, 1149 // which shows the whole thing. 1150 self.description_id[index] = blk: { 1151 const whole = entry.record.event.description; 1152 const cut_short = !description_page and whole.len > Data.max_description_size; 1153 const shown = if (cut_short) 1154 std.mem.trimEnd(u8, whole[0 .. std.mem.lastIndexOfScalar(u8, whole[0..Data.max_description_size], '\n') orelse 0], " \t\r\n") 1155 else if (whole.len == 0) 1156 "(no description)" 1157 else 1158 whole; 1159 var tb = try wgt.TextBox(ui.Widget).init(allocator, shown, .{ 1160 .border_style = .single, 1161 .rounded_corners = true, 1162 .wrap_kind = .word, 1163 .label = " description ", 1164 .bottom_label = if (cut_short) " click or press enter to see more " else "", 1165 }); 1166 errdefer tb.deinit(allocator); 1167 tb.getFocus().focusable = true; 1168 if (cut_short) tb.getFocus().kind = .{ .custom = try descriptionLink(self.session.page_arena, self.data.identity, entry.id) }; 1169 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1170 break :blk tb.getFocus().id; 1171 }; 1172 1173 const parent_route = commentsRoute(self.data.identity, entry.id, 0) orelse return error.RouteTooLong; 1174 try Attachment.appendRows(allocator, inner, self.session, self.data.identity, try parent_route.toUrl(self.session.page_arena), entry.attachments); 1175 1176 if (!description_page) { 1177 var reply = try Comment.linkBox(allocator, self.session, "new comment", newCommentRoute(self.data.identity, entry.id, "") orelse return error.RouteTooLong); 1178 errdefer reply.deinit(allocator); 1179 try inner.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = null }); 1180 1181 try Comment.appendCount(allocator, inner, entry.comments.count, "comment", "comments", self.session.page_arena.allocator()); 1182 for (entry.comments.comments) |comment| try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, comment); 1183 try Comment.appendWindowNav(allocator, inner, self.session, self.data.identity, kind, entry.id, null, entry.comments); 1184 1185 var spacer = try ui.Spacer.init(allocator); 1186 errdefer spacer.deinit(allocator); 1187 try inner.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 1188 } 1189 1190 // select the title by default 1191 inner.getFocus().child_id = inner.children.keys()[title_child_index]; 1192 1193 // reset the scroll to the top for the newly-shown issue: directly on the 1194 // terminal (the wasm offset), and via a version bump on the web (so the 1195 // renderer's scroll id changes and JS drops the preserved position). 1196 const sc = self.detailScroll(index); 1197 sc.x = 0; 1198 sc.y = 0; 1199 sc.getFocus().version +%= 1; 1200 } 1201 1202 pub fn input(self: *This, allocator: std.mem.Allocator, raw_key: Key, root_focus: *Focus) !void { 1203 // wheel input moves focus like the arrows 1204 const key: Key = if (raw_key == .mouse and raw_key.mouse.action == .scroll) 1205 (if (raw_key.mouse.action.scroll == .up) .arrow_up else .arrow_down) 1206 else 1207 raw_key; 1208 1209 if (self.headerActive()) { 1210 // down from the tabs re-enters the stack if the selected view has 1211 // something to focus; other keys move the tabs. 1212 if (inp.vertDirection(key) == .down) { 1213 const enterable = if (self.viewStack().getSelected()) |selected| switch (selected.*) { 1214 .tag_flow => |*tf| tf.text_boxes.items.len > 0, 1215 // nothing focusable in the unauthorized view 1216 .unauthorized => false, 1217 else => true, 1218 } else false; 1219 if (enterable) root_focus.setFocus(self.box.children.keys()[stack_index]); 1220 } else { 1221 try self.header().input(allocator, key, root_focus); 1222 } 1223 return; 1224 } 1225 if (self.tagsViewActive()) { 1226 self.tagsViewInput(key, root_focus); 1227 return; 1228 } 1229 if (self.formViewActive()) { 1230 if (has_conflicts and self.data.view == .resolve) { 1231 try self.resolveInput(allocator, key, root_focus); 1232 } else if (self.data.view == .new_comment or self.data.view == .edit_comment) { 1233 try self.commentFormInput(allocator, key, root_focus); 1234 } else { 1235 try self.formInput(allocator, key, root_focus); 1236 } 1237 return; 1238 } 1239 const i = self.selectedSplitIndex() orelse return; 1240 if (self.detailActive(i)) { 1241 try self.detailInput(allocator, i, key, root_focus); 1242 } else { 1243 try self.listInput(i, key, root_focus); 1244 } 1245 } 1246 1247 // arrow keys move the tag selection; up from the top row crosses to the 1248 // header tabs. 1249 fn tagsViewInput(self: *This, key: Key, root_focus: *Focus) void { 1250 const tf = self.tagsView(); 1251 const cid = tf.focus.child_id orelse return; 1252 const cur = tf.indexOfFocusId(cid) orelse return; 1253 const count = tf.text_boxes.items.len; 1254 switch (key) { 1255 .arrow_left => if (cur > 0) root_focus.setFocus(tf.text_boxes.items[cur - 1].getFocus().id), 1256 .arrow_right => if (cur + 1 < count) root_focus.setFocus(tf.text_boxes.items[cur + 1].getFocus().id), 1257 .arrow_up => if (tf.rowStep(cur, false)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id) else self.focusHeader(root_focus), 1258 .arrow_down => if (tf.rowStep(cur, true)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id), 1259 .home => root_focus.setFocus(tf.text_boxes.items[0].getFocus().id), 1260 .end => root_focus.setFocus(tf.text_boxes.items[count - 1].getFocus().id), 1261 else => {}, 1262 } 1263 } 1264 1265 fn listInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1266 // up/down (and the scroll wheel) move the selection a row; page up/down 1267 // jump a fixed amount. right/Enter cross into the detail pane. up from 1268 // the top row crosses into the header tabs. 1269 if (inp.rowDelta(key, @intCast(self.listBox(index).children.count()))) |delta| { 1270 const lb = self.listBox(index); 1271 const at_top = if (lb.getFocus().child_id) |cid| lb.children.getIndex(cid) == 0 else true; 1272 if (delta < 0 and at_top) return self.focusHeader(root_focus); 1273 ui.moveRowFocus(lb, self.listScroll(index), root_focus, delta); 1274 return; 1275 } 1276 switch (key) { 1277 .enter, .arrow_right => try self.focusDetail(index, root_focus), 1278 else => {}, 1279 } 1280 } 1281 1282 fn detailInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1283 switch (key) { 1284 .page_up => { 1285 self.pageDetail(index, root_focus, -10); 1286 return; 1287 }, 1288 .page_down => { 1289 self.pageDetail(index, root_focus, 10); 1290 return; 1291 }, 1292 .home => { 1293 self.jumpDetail(index, root_focus, false); 1294 return; 1295 }, 1296 .end => { 1297 self.jumpDetail(index, root_focus, true); 1298 return; 1299 }, 1300 else => {}, 1301 } 1302 if (self.data.comment_page != null and std.mem.eql(u8, self.window(index).items[self.detailed_index[index] orelse return].id, self.data.selected_id)) { 1303 try self.commentInput(allocator, index, key, root_focus); 1304 } else if (self.toolRowFocused(index)) { 1305 try self.toolRowInput(allocator, index, key, root_focus); 1306 } else if (self.titleFocused(index)) { 1307 try self.titleInput(index, key, root_focus); 1308 } else if (self.authorFocused(index)) { 1309 try self.authorInput(index, key, root_focus); 1310 } else if (self.tagsFocused(index)) { 1311 try self.tagsInput(index, key, root_focus); 1312 } else if (self.descriptionFocused(index)) { 1313 try self.descriptionInput(index, key, root_focus); 1314 } else if (try self.attachmentInput(allocator, index, key, root_focus)) {} else { 1315 try self.commentInput(allocator, index, key, root_focus); 1316 } 1317 } 1318 1319 fn attachmentInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !bool { 1320 const child_index = self.focusedDetailChild(index, root_focus) orelse return false; 1321 const child = &self.detailInner(index).children.values()[child_index].widget; 1322 const row = switch (child.*) { 1323 .box => |*box| box, 1324 else => return false, 1325 }; 1326 const attachment_id = Attachment.removeId(row) orelse return false; 1327 if (row.children.count() < 2) return false; 1328 const remove_id = row.children.keys()[1]; 1329 switch (key) { 1330 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) try self.focusList(index, root_focus), 1331 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1332 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1333 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1334 .enter => if (row.getFocus().child_id == remove_id) try self.removeEvent(allocator, .attach, attachment_id), 1335 .mouse => |mouse| if (inp.leftClickOn(root_focus, remove_id, mouse)) try self.removeEvent(allocator, .attach, attachment_id), 1336 else => {}, 1337 } 1338 return true; 1339 } 1340 1341 // the tool row: enter or a click on the open/close button flips the 1342 // issue's status; the edit and resolve buttons are a: links the host 1343 // follows. arrows cross between the buttons and to the neighboring 1344 // widgets. 1345 fn toolRowInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1346 const row = self.toolRow(index); 1347 const cur = if (row.getFocus().child_id) |cid| row.children.getIndex(cid) orelse return else return; 1348 const on_status = if (self.status_button_id[index]) |id| row.getFocus().child_id == id else false; 1349 const on_remove = if (self.remove_button_id[index]) |id| row.getFocus().child_id == id else false; 1350 switch (key) { 1351 .arrow_left => if (cur > first_in_row_index) root_focus.setFocus(row.children.keys()[cur - 1]) else try self.focusList(index, root_focus), 1352 .arrow_right => if (cur + 1 < row.children.count()) root_focus.setFocus(row.children.keys()[cur + 1]), 1353 .arrow_up => self.focusHeader(root_focus), 1354 .arrow_down => { 1355 self.focusDetailEdge(index, root_focus, false); 1356 self.detailScroll(index).y = 0; 1357 }, 1358 .enter => if (on_remove) try self.removeThread(allocator, index) else if (on_status) try self.toggleStatus(allocator, index), 1359 .mouse => |mouse| if (self.remove_button_id[index]) |id| { 1360 if (inp.leftClickOn(root_focus, id, mouse)) return self.removeThread(allocator, index); 1361 if (self.status_button_id[index]) |status_id| if (inp.leftClickOn(root_focus, status_id, mouse)) try self.toggleStatus(allocator, index); 1362 } else if (self.status_button_id[index]) |id| { 1363 if (inp.leftClickOn(root_focus, id, mouse)) try self.toggleStatus(allocator, index); 1364 }, 1365 else => {}, 1366 } 1367 } 1368 1369 fn titleInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1370 switch (key) { 1371 .arrow_left => try self.focusList(index, root_focus), 1372 .arrow_up => self.focusToolRow(index, root_focus), 1373 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1374 else => {}, 1375 } 1376 } 1377 1378 // the author box's a: link (when it has one) is followed by the host on 1379 // enter / a click; arrows cross to the neighboring widgets. 1380 fn authorInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1381 switch (key) { 1382 .arrow_left => try self.focusList(index, root_focus), 1383 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1384 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1385 else => {}, 1386 } 1387 } 1388 1389 fn descriptionInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1390 const sc = self.detailScroll(index); 1391 switch (key) { 1392 .arrow_left => return self.focusList(index, root_focus), 1393 // the terminal moves through the description one line at a time; 1394 // the web lets focus changes drive its native scroll. 1395 .arrow_up => { 1396 if (!self.session.is_terminal) { 1397 _ = self.moveDetailVertical(index, root_focus, false); 1398 return; 1399 } 1400 if (self.focusAdjacentVisible(index, root_focus, false)) return; 1401 const before = sc.y; 1402 sc.y -= 1; 1403 sc.clampToContent(); 1404 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, false); 1405 return; 1406 }, 1407 .arrow_down => { 1408 if (!self.session.is_terminal) { 1409 _ = self.moveDetailVertical(index, root_focus, true); 1410 return; 1411 } 1412 if (self.focusAdjacentVisible(index, root_focus, true)) return; 1413 const before = sc.y; 1414 sc.y += 1; 1415 sc.clampToContent(); 1416 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, true); 1417 return; 1418 }, 1419 else => return, 1420 } 1421 sc.clampToContent(); 1422 } 1423 1424 fn commentInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1425 const item = self.focusedComment(index, root_focus); 1426 switch (key) { 1427 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) return self.focusList(index, root_focus), 1428 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1429 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1430 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1431 .enter => if (item) |comment| if (comment.removeFocused()) try self.removeComment(allocator, comment), 1432 .mouse => |mouse| if (item) |comment| if (comment.remove_button_id) |id| { 1433 if (inp.leftClickOn(root_focus, id, mouse)) try self.removeComment(allocator, comment); 1434 }, 1435 else => return, 1436 } 1437 } 1438 1439 fn focusedComment(self: *This, index: usize, root_focus: *Focus) ?*Comment.Item { 1440 const child_index = self.focusedDetailChild(index, root_focus) orelse return null; 1441 return switch (self.detailInner(index).children.values()[child_index].widget) { 1442 .repo_comment => |*comment| comment, 1443 else => null, 1444 }; 1445 } 1446 1447 // page the detail scroll, then focus the first or last visible widget. 1448 fn pageDetail(self: *This, index: usize, root_focus: *Focus, delta: isize) void { 1449 if (!self.session.is_terminal) return; 1450 const sc = self.detailScroll(index); 1451 sc.y += delta; 1452 sc.clampToContent(); 1453 self.focusVisible(index, root_focus, delta > 0); 1454 } 1455 1456 // jump to the detail's first or last focusable widget. the terminal pins 1457 // its synthetic scroll too; the browser scrolls to the new focus itself. 1458 fn jumpDetail(self: *This, index: usize, root_focus: *Focus, to_end: bool) void { 1459 if (self.session.is_terminal) { 1460 const sc = self.detailScroll(index); 1461 sc.y = if (to_end) std.math.maxInt(isize) else 0; 1462 sc.clampToContent(); 1463 self.focusVisible(index, root_focus, to_end); 1464 } else { 1465 self.focusDetailEdge(index, root_focus, to_end); 1466 } 1467 } 1468 1469 fn focusDetailEdge(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1470 const focus = self.detailInner(index).getFocus(); 1471 var chosen: ?usize = null; 1472 for (focus.children.keys(), focus.children.values()) |id, child| { 1473 if (!child.focus.focusable) continue; 1474 chosen = id; 1475 if (!last) break; 1476 } 1477 if (chosen) |id| root_focus.setFocus(id); 1478 } 1479 1480 fn detailRectVisible(self: *This, index: usize, rect: layout.IRect) bool { 1481 const sc = self.detailScroll(index); 1482 const viewport = sc.grid orelse return false; 1483 const top = sc.y; 1484 const bottom = top + @as(isize, @intCast(viewport.size.height - sc.bar_h)); 1485 return rect.y + @as(isize, @intCast(rect.size.height)) > top and rect.y < bottom; 1486 } 1487 1488 // focus the top- or bottom-most focusable widget intersecting the viewport. 1489 fn focusVisible(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1490 const inner = self.detailInner(index); 1491 var offset: usize = 0; 1492 while (offset < inner.children.count()) : (offset += 1) { 1493 const child_index = if (last) inner.children.count() - 1 - offset else offset; 1494 if (self.focusVisibleChild(index, child_index, root_focus, last)) return; 1495 } 1496 } 1497 1498 fn focusVisibleChild(self: *This, index: usize, child_index: usize, root_focus: *Focus, last: bool) bool { 1499 const child = &self.detailInner(index).children.values()[child_index]; 1500 const child_rect = child.rect orelse return false; 1501 var chosen: ?usize = null; 1502 if (child.widget.getFocus().focusable and self.detailRectVisible(index, child_rect)) { 1503 const id = self.detailInner(index).children.keys()[child_index]; 1504 chosen = id; 1505 if (!last) { 1506 root_focus.setFocus(id); 1507 return true; 1508 } 1509 } 1510 1511 for (child.widget.getFocus().children.keys(), child.widget.getFocus().children.values()) |id, focus_child| { 1512 if (!focus_child.focus.focusable) continue; 1513 const rect: layout.IRect = .{ 1514 .x = child_rect.x + @as(isize, @intCast(focus_child.rect.x)), 1515 .y = child_rect.y + @as(isize, @intCast(focus_child.rect.y)), 1516 .size = focus_child.rect.size, 1517 }; 1518 if (!self.detailRectVisible(index, rect)) continue; 1519 chosen = id; 1520 if (!last) break; 1521 } 1522 if (chosen) |id| { 1523 root_focus.setFocus(id); 1524 return true; 1525 } 1526 return false; 1527 } 1528 1529 fn focusAdjacentVisible(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 1530 const inner = self.detailInner(index); 1531 var next = self.focusedDetailChild(index, root_focus) orelse return false; 1532 while (true) { 1533 if (down) { 1534 next += 1; 1535 if (next >= inner.children.count()) return false; 1536 } else { 1537 if (next == 0) return false; 1538 next -= 1; 1539 } 1540 if (self.focusVisibleChild(index, next, root_focus, !down)) { 1541 self.focusDetailChild(index, next, !down, root_focus); 1542 return true; 1543 } 1544 } 1545 } 1546 1547 fn focusedDetailChild(self: *This, index: usize, root_focus: *Focus) ?usize { 1548 const inner = self.detailInner(index); 1549 var id = root_focus.grandchild_id orelse return null; 1550 while (inner.getFocus().children.get(id)) |child| { 1551 if (child.parent_id == inner.getFocus().id) return inner.children.getIndex(id); 1552 id = child.parent_id; 1553 } 1554 return null; 1555 } 1556 1557 fn detailChildFocusable(self: *This, index: usize, child_index: usize) bool { 1558 const child = &self.detailInner(index).children.values()[child_index].widget; 1559 const focus = child.getFocus(); 1560 if (focus.focusable) return true; 1561 for (focus.children.values()) |item| { 1562 if (item.focus.focusable) return true; 1563 } 1564 return false; 1565 } 1566 1567 fn focusDetailChild(self: *This, index: usize, child_index: usize, last: bool, root_focus: *Focus) void { 1568 const inner = self.detailInner(index); 1569 const child = &inner.children.values()[child_index]; 1570 var target = inner.children.keys()[child_index]; 1571 var rect = child.rect orelse return; 1572 switch (child.widget) { 1573 .repo_comment => |*comment| { 1574 if (comment.rowRect(last)) |row_rect| { 1575 rect.x += row_rect.x; 1576 rect.y += row_rect.y; 1577 rect.size = row_rect.size; 1578 } 1579 if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus); 1580 }, 1581 .box => |*box| if (box.children.count() > 0) { 1582 target = box.children.keys()[if (last) box.children.count() - 1 else 0]; 1583 root_focus.setFocus(target); 1584 }, 1585 else => root_focus.setFocus(target), 1586 } 1587 const sc = self.detailScroll(index); 1588 if (sc.grid) |viewport| { 1589 const viewport_height = viewport.size.height - sc.bar_h; 1590 if (rect.size.height > viewport_height) { 1591 const bottom_aligned = rect.y + @as(isize, @intCast(rect.size.height - viewport_height)); 1592 sc.y = std.math.clamp(sc.y, rect.y, bottom_aligned); 1593 sc.clampToContent(); 1594 return; 1595 } 1596 } 1597 sc.scrollToRect(rect); 1598 } 1599 1600 fn moveDetailVertical(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 1601 const inner = self.detailInner(index); 1602 const current = self.focusedDetailChild(index, root_focus) orelse return false; 1603 if (inner.children.values()[current].widget == .repo_comment) { 1604 const comment = &inner.children.values()[current].widget.repo_comment; 1605 if (down and !comment.bodyFocused()) { 1606 self.focusDetailChild(index, current, true, root_focus); 1607 return true; 1608 } 1609 if (!down and comment.bodyFocused()) { 1610 self.focusDetailChild(index, current, false, root_focus); 1611 return true; 1612 } 1613 } 1614 1615 var next = current; 1616 while (true) { 1617 if (down) { 1618 next += 1; 1619 if (next >= inner.children.count()) return false; 1620 } else { 1621 if (next == 0) return false; 1622 next -= 1; 1623 } 1624 if (!self.detailChildFocusable(index, next)) continue; 1625 self.focusDetailChild(index, next, !down, root_focus); 1626 return true; 1627 } 1628 } 1629 1630 fn moveCommentHorizontal(self: *This, index: usize, root_focus: *Focus, right: bool) bool { 1631 const inner = self.detailInner(index); 1632 const current = self.focusedDetailChild(index, root_focus) orelse return false; 1633 const child = &inner.children.values()[current]; 1634 if (child.widget == .repo_comment) { 1635 const moved = child.widget.repo_comment.moveHorizontal(root_focus, right); 1636 if (moved) if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 1637 return moved; 1638 } 1639 const row = switch (child.widget) { 1640 .box => |*box| box, 1641 else => return false, 1642 }; 1643 const selected = row.getFocus().child_id orelse return false; 1644 const selected_index = row.children.getIndex(selected) orelse return false; 1645 const target = if (right) selected_index + 1 else selected_index -| 1; 1646 if (target == selected_index or target >= row.children.count()) return false; 1647 root_focus.setFocus(row.children.keys()[target]); 1648 if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 1649 return true; 1650 } 1651 1652 // up/down (and tab/shift+tab) move between a form's fields; up from the 1653 // title crosses into the header tabs. the multiline description keeps 1654 // enter and any up/down that has a row to move to. 1655 fn formInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1656 const form = self.threadForm() orelse return; 1657 const cid = form.getFocus().child_id orelse return; 1658 const cur = form.children.getIndex(cid) orelse return; 1659 1660 if (cur == description_field_index) { 1661 const child = &form.children.values()[cur]; 1662 if (try multilineKeepsKey(&child.widget.text_input, allocator, key)) 1663 return child.widget.input(allocator, key, root_focus); 1664 } 1665 1666 switch (key) { 1667 .arrow_up, .back_tab => if (cur > 0) 1668 root_focus.setFocus(form.children.keys()[cur - 1]) 1669 else 1670 self.focusHeader(root_focus), 1671 .arrow_down, .tab => if (cur < submit_field_index) { 1672 root_focus.setFocus(form.children.keys()[cur + 1]); 1673 }, 1674 .enter => if (cur == submit_field_index) try self.submitForm(allocator), 1675 .mouse => |mouse| if (cur == submit_field_index) { 1676 const submit = &form.children.values()[cur].widget.submit_button; 1677 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitForm(allocator); 1678 }, 1679 else => try form.children.values()[cur].widget.input(allocator, key, root_focus), 1680 } 1681 } 1682 1683 fn commentFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1684 const form = self.threadForm() orelse return; 1685 const cid = form.getFocus().child_id orelse return; 1686 const cur = form.children.getIndex(cid) orelse return; 1687 const child = &form.children.values()[cur]; 1688 1689 if (cur == comment_body_field_index and try multilineKeepsKey(&child.widget.text_input, allocator, key)) 1690 return child.widget.input(allocator, key, root_focus); 1691 1692 switch (key) { 1693 .arrow_up, .back_tab => if (cur > comment_author_field_index) 1694 root_focus.setFocus(form.children.keys()[cur - 1]) 1695 else 1696 self.focusHeader(root_focus), 1697 .arrow_down, .tab => if (cur < comment_submit_field_index) 1698 root_focus.setFocus(form.children.keys()[cur + 1]), 1699 .enter => if (cur == comment_submit_field_index) try self.submitComment(allocator), 1700 .mouse => |mouse| if (cur == comment_submit_field_index) { 1701 const submit = &child.widget.submit_button; 1702 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitComment(allocator); 1703 }, 1704 else => try child.widget.input(allocator, key, root_focus), 1705 } 1706 } 1707 1708 // the multiline hunk inputs keep enter and any up/down that has a row to 1709 // move to; enter on a "use this" link is a navigation the host follows 1710 // before this runs. 1711 fn resolveInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1712 const form = self.threadForm() orelse return; 1713 const cid = form.getFocus().child_id orelse return; 1714 const cur = form.children.getIndex(cid) orelse return; 1715 const child = &form.children.values()[cur]; 1716 1717 if (child.widget == .text_input) { 1718 const ti = &child.widget.text_input; 1719 if (ti.options.multiline and try multilineKeepsKey(ti, allocator, key)) 1720 return child.widget.input(allocator, key, root_focus); 1721 } 1722 1723 const on_submit = child.widget == .submit_button; 1724 1725 switch (key) { 1726 .arrow_up, .back_tab => if (resolveStep(form, cur, false)) |i| self.focusResolveChild(form, i, root_focus) else self.focusHeader(root_focus), 1727 .arrow_down, .tab => if (resolveStep(form, cur, true)) |i| self.focusResolveChild(form, i, root_focus), 1728 .arrow_left, .arrow_right => switch (child.widget) { 1729 .box => |*row| { 1730 const row_cid = row.getFocus().child_id orelse return; 1731 const row_index = row.children.getIndex(row_cid) orelse return; 1732 if (key == .arrow_left) { 1733 if (row_index > 0) root_focus.setFocus(row.children.keys()[row_index - 1]); 1734 } else if (row_index + 1 < row.children.count()) { 1735 root_focus.setFocus(row.children.keys()[row_index + 1]); 1736 } 1737 }, 1738 else => try child.widget.input(allocator, key, root_focus), 1739 }, 1740 .enter => if (on_submit) try self.submitResolution(allocator), 1741 .mouse => |mouse| if (on_submit) { 1742 if (inp.leftClickOn(root_focus, child.widget.submit_button.buttonId(), mouse)) try self.submitResolution(allocator); 1743 }, 1744 else => try child.widget.input(allocator, key, root_focus), 1745 } 1746 } 1747 1748 // a multiline input keeps enter and any up/down that has a row to move to 1749 fn multilineKeepsKey(text_input: *wgt.TextInput(ui.Widget), allocator: std.mem.Allocator, key: Key) !bool { 1750 return switch (key) { 1751 .enter => true, 1752 .arrow_up => !try text_input.cursorOnFirstRow(allocator), 1753 .arrow_down => !try text_input.cursorOnLastRow(allocator), 1754 else => false, 1755 }; 1756 } 1757 1758 // the neighboring focusable resolve-form child, skipping the spacer and 1759 // the blank gap rows 1760 fn resolveStep(form: *wgt.Box(ui.Widget), cur: usize, down: bool) ?usize { 1761 var i = cur; 1762 while (true) { 1763 if (down) { 1764 i += 1; 1765 if (i >= form.children.count()) return null; 1766 } else { 1767 if (i == 0) return null; 1768 i -= 1; 1769 } 1770 switch (form.children.values()[i].widget) { 1771 .spacer, .text => continue, 1772 else => return i, 1773 } 1774 } 1775 } 1776 1777 // focus the resolve-form child at `i` (a version row focuses its selected 1778 // child) and keep it visible on the terminal 1779 fn focusResolveChild(self: *This, form: *wgt.Box(ui.Widget), i: usize, root_focus: *Focus) void { 1780 const child = &form.children.values()[i]; 1781 switch (child.widget) { 1782 .box => |*row| root_focus.setFocus(row.getFocus().child_id orelse form.children.keys()[i]), 1783 else => root_focus.setFocus(form.children.keys()[i]), 1784 } 1785 if (self.session.is_terminal) { 1786 if (self.resolveScroll()) |sc| { 1787 if (child.rect) |rect| sc.scrollToRect(rect); 1788 } 1789 } 1790 } 1791 1792 // re-emit the conflicted issue's event with the resolved content; any 1793 // event on the issue settles its conflict entry. this is the terminal 1794 // path; the web posts the form to the resolve route. 1795 fn submitResolution(self: *This, allocator: std.mem.Allocator) !void { 1796 if (comptime wasm) return; 1797 const io = self.session.io orelse return; 1798 const src = self.data.repo_source orelse return; 1799 const entry = self.data.selectedThread() orelse return; 1800 const author = (try self.session.eventAuthor()) orelse return; 1801 const form = self.threadForm() orelse return; 1802 1803 // gather the inputs by name; the d<n> hunk inputs appear in chunk order 1804 var title: ?[]u8 = null; 1805 var tags: ?[]u8 = null; 1806 var hunks: std.ArrayList([]const u8) = .empty; 1807 defer { 1808 if (title) |t| allocator.free(t); 1809 if (tags) |t| allocator.free(t); 1810 for (hunks.items) |h| allocator.free(h); 1811 hunks.deinit(allocator); 1812 } 1813 for (form.children.values()) |*child| switch (child.widget) { 1814 .text_input => |*ti| { 1815 const text = try ti.text(allocator); 1816 errdefer allocator.free(text); 1817 if (std.mem.eql(u8, ti.options.name, "title")) { 1818 title = text; 1819 } else if (std.mem.eql(u8, ti.options.name, "tags")) { 1820 tags = text; 1821 } else { 1822 try hunks.append(allocator, text); 1823 } 1824 }, 1825 else => {}, 1826 }; 1827 1828 var id_bytes: [evt.event_id_size]u8 = undefined; 1829 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 1830 1831 switch (src.repo_kind) { 1832 inline else => |repo_kind| { 1833 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1834 defer any_repo.deinit(io, allocator); 1835 switch (any_repo) { 1836 inline else => |*repo| Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .resolve = .{ 1837 .title = title, 1838 .tags = tags, 1839 .hunks = hunks.items, 1840 } }, author) catch |err| switch (err) { 1841 // leave the form up for correction 1842 error.InvalidFields => return, 1843 else => |e| return e, 1844 }, 1845 } 1846 }, 1847 } 1848 1849 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 1850 try self.session.navigate(route); 1851 } 1852 1853 // commit a new or edited comment and navigate to its permalink. this is the 1854 // terminal path; the web posts the form to its comment route. 1855 fn submitComment(self: *This, allocator: std.mem.Allocator) !void { 1856 if (comptime wasm) return; 1857 const io = self.session.io orelse return; 1858 const src = self.data.repo_source orelse return; 1859 const author = (try self.session.eventAuthor()) orelse return; 1860 const form = self.threadForm() orelse return; 1861 const body_input = &form.children.values()[comment_body_field_index].widget.text_input; 1862 const body = try body_input.text(allocator); 1863 defer allocator.free(body); 1864 if (!evt.Comment.fieldsValid(body)) return; 1865 1866 var thread_id: [evt.event_id_size * 2]u8 = undefined; 1867 @memcpy(&thread_id, self.data.selected_id); 1868 const parent_id = if (self.data.comment_id.len == 0) thread_id else blk: { 1869 var parent: [evt.event_id_size * 2]u8 = undefined; 1870 @memcpy(&parent, self.data.comment_id); 1871 break :blk parent; 1872 }; 1873 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; 1874 const editing = self.data.view == .edit_comment; 1875 switch (src.repo_kind) { 1876 inline else => |repo_kind| { 1877 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1878 defer any_repo.deinit(io, allocator); 1879 switch (any_repo) { 1880 inline else => |*repo| if (editing) { 1881 var comment_id: [evt.event_id_size]u8 = undefined; 1882 _ = std.fmt.hexToBytes(&comment_id, self.data.comment_id) catch return; 1883 try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author); 1884 event_id_hex = std.fmt.bytesToHex(comment_id, .lower); 1885 } else { 1886 event_id_hex = try evt.Comment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &parent_id, body, author); 1887 }, 1888 } 1889 }, 1890 } 1891 1892 body_input.clear(allocator); 1893 const route = commentRoute(self.data.identity, &thread_id, &event_id_hex, 0) orelse return; 1894 try self.session.navigate(route); 1895 } 1896 1897 // the terminal submit paths: the new form commits a new-issue event, the 1898 // edit form re-emits the selected issue's event with the form's content. 1899 // the web posts the forms to their routes instead. 1900 fn submitForm(self: *This, allocator: std.mem.Allocator) !void { 1901 if (self.data.view == .edit) { 1902 try self.submitEditedThread(allocator); 1903 } else { 1904 try self.submitNewThread(allocator); 1905 } 1906 } 1907 1908 // commit the new issue to the repo's events branch and navigate to it. 1909 // this is the terminal path; the web posts the form to the issue route, 1910 // so the wasm side never runs (or compiles) the repo access below. 1911 fn submitNewThread(self: *This, allocator: std.mem.Allocator) !void { 1912 if (comptime wasm) return; 1913 const io = self.session.io orelse return; 1914 const src = self.data.repo_source orelse return; 1915 const author = (try self.session.eventAuthor()) orelse return; 1916 1917 const form = self.threadForm() orelse return; 1918 const title_input = &form.children.values()[title_field_index].widget.text_input; 1919 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 1920 const description_input = &form.children.values()[description_field_index].widget.text_input; 1921 1922 const title = try title_input.text(allocator); 1923 defer allocator.free(title); 1924 const tags = try tags_input.text(allocator); 1925 defer allocator.free(tags); 1926 const description = try description_input.text(allocator); 1927 defer allocator.free(description); 1928 1929 if (!Event.fieldsValid(title, tags)) return; 1930 1931 var id_bytes: [evt.event_id_size]u8 = undefined; 1932 io.random(&id_bytes); 1933 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 1934 1935 const event = evt.EventWithId{ 1936 .id = event_id_hex, 1937 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 1938 .author = author, 1939 .event = @unionInit(evt.Event, @tagName(kind), Event{ 1940 .title = title, 1941 .description = description, 1942 .tags = tags, 1943 }), 1944 }; 1945 1946 switch (src.repo_kind) { 1947 inline else => |repo_kind| { 1948 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1949 defer any_repo.deinit(io, allocator); 1950 switch (any_repo) { 1951 inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), 1952 } 1953 }, 1954 } 1955 1956 // wipe the form so a return visit starts fresh 1957 title_input.clear(allocator); 1958 tags_input.clear(allocator); 1959 description_input.clear(allocator); 1960 1961 const route = listRoute(self.data.identity, @enumFromInt(0), "", &event_id_hex) orelse return; 1962 try self.session.navigate(route); 1963 } 1964 1965 // re-emit the selected issue's event with the form's content (status 1966 // preserved), then reload the issue's page. this is the terminal path; 1967 // the web posts the form to the edit route. 1968 fn submitEditedThread(self: *This, allocator: std.mem.Allocator) !void { 1969 if (comptime wasm) return; 1970 const io = self.session.io orelse return; 1971 const src = self.data.repo_source orelse return; 1972 const entry = self.data.selectedThread() orelse return; 1973 const author = (try self.session.eventAuthor()) orelse return; 1974 1975 const form = self.threadForm() orelse return; 1976 const title_input = &form.children.values()[title_field_index].widget.text_input; 1977 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 1978 const description_input = &form.children.values()[description_field_index].widget.text_input; 1979 1980 const title = try title_input.text(allocator); 1981 defer allocator.free(title); 1982 const tags = try tags_input.text(allocator); 1983 defer allocator.free(tags); 1984 const description = try description_input.text(allocator); 1985 defer allocator.free(description); 1986 1987 if (!Event.fieldsValid(title, tags)) return; 1988 1989 var id_bytes: [evt.event_id_size]u8 = undefined; 1990 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 1991 1992 switch (src.repo_kind) { 1993 inline else => |repo_kind| { 1994 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1995 defer any_repo.deinit(io, allocator); 1996 switch (any_repo) { 1997 inline else => |*repo| if (has_status) 1998 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ 1999 .title = title, 2000 .tags = tags, 2001 .description = description, 2002 } }, author) 2003 else 2004 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, title, tags, description, author), 2005 } 2006 }, 2007 } 2008 2009 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2010 try self.session.navigate(route); 2011 } 2012 2013 fn removeThread(self: *This, allocator: std.mem.Allocator, index: usize) !void { 2014 const sel = self.detailed_index[index] orelse return; 2015 const entry = self.window(index).items[sel]; 2016 2017 var id_bytes: [evt.event_id_size]u8 = undefined; 2018 _ = std.fmt.hexToBytes(&id_bytes, entry.id) catch return; 2019 try self.removeEvent(allocator, kind, id_bytes); 2020 } 2021 2022 fn removeComment(self: *This, allocator: std.mem.Allocator, comment: *Comment.Item) !void { 2023 try self.removeEvent(allocator, .comment, comment.id); 2024 } 2025 2026 fn removeEvent(self: *This, allocator: std.mem.Allocator, event_kind: evt.EventKind, id: [evt.event_id_size]u8) !void { 2027 if (comptime wasm) return; 2028 const io = self.session.io orelse return; 2029 const src = self.data.repo_source orelse return; 2030 const author = (try self.session.eventAuthor()) orelse return; 2031 2032 switch (src.repo_kind) { 2033 inline else => |repo_kind| { 2034 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2035 defer any_repo.deinit(io, allocator); 2036 switch (any_repo) { 2037 inline else => |*repo| try evt.remove(repo_kind, repo.self_repo_opts, io, allocator, repo, &id, event_kind, author), 2038 } 2039 }, 2040 } 2041 2042 const id_hex = std.fmt.bytesToHex(id, .lower); 2043 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; 2044 try self.session.navigate(route); 2045 } 2046 2047 // flip the shown issue's status by re-emitting its event, then reload the 2048 // page rooted at the issue so the view reflects the change. this is the 2049 // terminal path; the web posts the button's form to the status route. 2050 fn toggleStatus(self: *This, allocator: std.mem.Allocator, index: usize) !void { 2051 if (comptime !has_status) return; 2052 if (comptime wasm) return; 2053 const io = self.session.io orelse return; 2054 const src = self.data.repo_source orelse return; 2055 const sel = self.detailed_index[index] orelse return; 2056 const entry = self.window(index).items[sel]; 2057 const author = (try self.session.eventAuthor()) orelse return; 2058 2059 const status: Status = switch (entryStatus(entry)) { 2060 .open => .closed, 2061 .closed => .open, 2062 }; 2063 2064 var id_bytes: [evt.event_id_size]u8 = undefined; 2065 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 2066 2067 switch (src.repo_kind) { 2068 inline else => |repo_kind| { 2069 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2070 defer any_repo.deinit(io, allocator); 2071 switch (any_repo) { 2072 inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, author), 2073 } 2074 }, 2075 } 2076 2077 const route = listRoute(self.data.identity, status, "", entry.id) orelse return; 2078 try self.session.navigate(route); 2079 } 2080 2081 // arrow keys move the tag selection; at the flow's edges focus crosses to 2082 // the neighboring widgets. 2083 fn tagsInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 2084 const tf = self.tagFlow(index) orelse return; 2085 const cid = tf.focus.child_id orelse return; 2086 const cur = tf.indexOfFocusId(cid) orelse return; 2087 const count = tf.text_boxes.items.len; 2088 switch (key) { 2089 .arrow_left => if (cur > 0) self.focusTag(index, tf, root_focus, cur - 1) else try self.focusList(index, root_focus), 2090 .arrow_right => if (cur + 1 < count) self.focusTag(index, tf, root_focus, cur + 1), 2091 .arrow_up => if (tf.rowStep(cur, false)) |i| { 2092 self.focusTag(index, tf, root_focus, i); 2093 } else { 2094 _ = self.moveDetailVertical(index, root_focus, false); 2095 }, 2096 .arrow_down => if (tf.rowStep(cur, true)) |i| { 2097 self.focusTag(index, tf, root_focus, i); 2098 } else { 2099 _ = self.moveDetailVertical(index, root_focus, true); 2100 }, 2101 .home => self.focusTag(index, tf, root_focus, 0), 2102 .end => self.focusTag(index, tf, root_focus, count - 1), 2103 else => {}, 2104 } 2105 } 2106 2107 const title_child_index: usize = 0; 2108 const tags_child_index: usize = 2; 2109 2110 // the tool row's first child after the spacer that pushes it right 2111 const first_in_row_index: usize = 1; 2112 2113 fn tagFlow(self: *This, index: usize) ?*ui.TagFlow { 2114 const inner = self.detailInner(index); 2115 if (inner.children.count() <= tags_child_index) return null; 2116 return switch (inner.children.values()[tags_child_index].widget) { 2117 .tag_flow => |*tf| tf, 2118 else => null, 2119 }; 2120 } 2121 2122 fn tagsFocused(self: *This, index: usize) bool { 2123 const inner = self.detailInner(index); 2124 const cid = inner.getFocus().child_id orelse return false; 2125 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow(index) != null; 2126 } 2127 2128 fn toolRowFocused(self: *This, index: usize) bool { 2129 const outer = self.detailOuter(index); 2130 const cid = outer.getFocus().child_id orelse return false; 2131 return outer.children.getIndex(cid) == tool_row_index; 2132 } 2133 2134 fn focusTag(self: *This, index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 2135 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 2136 // keep the tag visible on the terminal: its rect offset by the flow's 2137 // position in the pane. 2138 if (self.session.is_terminal and item < tf.rects.items.len) { 2139 if (self.detailInner(index).children.values()[tags_child_index].rect) |flow_rect| { 2140 var rect = tf.rects.items[item]; 2141 rect.x += flow_rect.x; 2142 rect.y += flow_rect.y; 2143 self.detailScroll(index).scrollToRect(rect); 2144 } 2145 } 2146 } 2147 2148 fn titleFocused(self: *This, index: usize) bool { 2149 const inner = self.detailInner(index); 2150 const cid = inner.getFocus().child_id orelse return false; 2151 return inner.children.getIndex(cid) == title_child_index; 2152 } 2153 2154 fn authorFocused(self: *This, index: usize) bool { 2155 const inner = self.detailInner(index); 2156 const cid = inner.getFocus().child_id orelse return false; 2157 return cid == self.author_id[index]; 2158 } 2159 2160 fn descriptionFocused(self: *This, index: usize) bool { 2161 const cid = self.detailInner(index).getFocus().child_id orelse return false; 2162 return cid == self.description_id[index]; 2163 } 2164 2165 // return to the tool row's last-focused button (the header when the 2166 // description page shows no row). 2167 fn focusToolRow(self: *This, index: usize, root_focus: *Focus) void { 2168 const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); 2169 root_focus.setFocus(cid); 2170 } 2171 2172 // enter the detail pane. an empty pane (no issues) can't be entered. 2173 fn focusDetail(self: *This, index: usize, root_focus: *Focus) !void { 2174 if (self.detailInner(index).children.count() == 0) return; 2175 root_focus.setFocus(self.detailOuter(index).getFocus().id); 2176 } 2177 2178 // return to the list. 2179 fn focusList(self: *This, index: usize, root_focus: *Focus) !void { 2180 root_focus.setFocus(self.listScroll(index).getFocus().id); 2181 } 2182 2183 // cross to the header tabs above the stack. 2184 fn focusHeader(self: *This, root_focus: *Focus) void { 2185 root_focus.setFocus(self.box.children.keys()[header_index]); 2186 } 2187 2188 pub fn clearGrid(self: *This) void { 2189 self.box.clearGrid(); 2190 } 2191 2192 pub fn getGrid(self: This) ?Grid { 2193 return self.box.getGrid(); 2194 } 2195 2196 pub fn getFocus(self: *This) *Focus { 2197 return self.box.getFocus(); 2198 } 2199 2200 // for the parent's "scroll up at the top jumps to the header" check: at the 2201 // top only while the header tabs hold focus, so up from the split 2202 // crosses into the tabs first. 2203 pub fn getSelectedIndex(self: *This) ?usize { 2204 return if (self.headerActive()) 0 else 1; 2205 } 2206 }; 2207 } 2208