12345678910111213141516const std = @import("std");17const output = @import("output.zig");18const rawterm = @import("rawterm.zig");19const build_options = @import("build_options");2021const PINK = "\x1b[38;2;170;106;82m"; 22const GOLD = "\x1b[38;2;173;138;80m"; 23const MINT = "\x1b[38;2;112;136;129m"; 24const LAV = "\x1b[38;2;122;129;98m"; 2526272829const TEXT = "\x1b[38;2;217;207;195m"; 30const TEXT_DIM = "\x1b[38;2;136;124;113m"; 31const BG_HI = "\x1b[48;2;238;230;219m"; 32const BG_BAR = "\x1b[48;2;205;191;174m"; 33const RESET = "\x1b[0m";34const BOLD = "\x1b[1m";35const DIM = "\x1b[2m";36const INV = "\x1b[7m";37383940fn c(code: []const u8) []const u8 {41 return if (output.colorEnabled()) code else "";42}43444546const Block = union(enum) {47 chapter: []const u8, 48 title: []const u8, 49 badge: []const u8, 50 accent: Accent, 51 para: []const u8, 52 cmd_sig: []const u8, 53 cmd_block: []const u8, 54 flags_row: FlagsRow, 55 callout: Callout, 56 kaomoji_card: KaomojiCard,57 blank,5859 const Accent = enum { gold, mint, pink, lav };6061 const FlagsRow = struct {62 flag: []const u8,63 desc: []const u8,64 };6566 const Callout = struct {67 face: []const u8, 68 body: []const u8,69 color: Accent,70 };7172 const KaomojiCard = struct {73 face: []const u8,74 name: []const u8,75 desc: []const u8,76 when: []const u8,77 color: Accent,78 };79};8081const Section = struct {82 id: []const u8, 83 title: []const u8,84 accent: Block.Accent,85 blocks: []const Block,86};8788899091929394const MAN_PAGE = [_]Section{95 .{96 .id = "intro",97 .title = "what is nina?",98 .accent = .gold,99 .blocks = &[_]Block{100 .{ .para = "nina is a NixOS Intuitive Navigation Assistant — a gentle command-line tool that wraps the full NixOS workflow in one quiet, consistent voice." },101 .{ .para = "she handles package search, rebuilds, flakes, services, generation history, and remote machines — all without dropping you into a different screen, switching tools, or making you memorise long nixos-rebuild incantations." },102 .{ .para = "written in zig. zero runtime dependencies. a single binary. runs the same on any NixOS machine." },103 .{ .para = "the goal is simple: NixOS should feel like home. nina is one step toward that." },104 .{105 .callout = .{106 .face = "(っ˘ω˘ς)",107 .color = .gold,108 .body = "nina stays inline at your prompt. search results, install confirmations, diff previews — everything comes to you. you never leave the shell you're already in.",109 },110 },111 },112 },113 .{114 .id = "install",115 .title = "installation",116 .accent = .gold,117 .blocks = &[_]Block{118 .{ .para = "nina is a nix flake. two ways to install:" },119 .{ .para = "both methods require nix-command and flakes to be enabled. add this to ~/.config/nix/nix.conf (or /etc/nix/nix.conf on NixOS):" },120 .{ .cmd_block = "experimental-features = nix-command flakes" },121 .{ .para = "or run `nina setup` after installing — she will add the line for you." },122 .{ .para = "direct url (simplest):" },123 .{ .cmd_block = "$ nix profile add 'https://kepr.uk/nina/archive/HEAD.tar.gz#nina'" },124 .{ .para = "or build from source with koh:" },125 .{ .cmd_block = "$ koh steal kepr.uk/nina && koh build" },126 .{127 .callout = .{128 .face = "(•̀ᴗ•́)و",129 .color = .mint,130 .body = "after installing, run `nina setup` — it makes sure the nix features nina needs are turned on, and on NixOS walks you through the one config line.",131 },132 },133 },134 },135 .{136 .id = "setup",137 .title = "nina setup",138 .accent = .mint,139 .blocks = &[_]Block{140 .{ .para = "`nina setup` is your onboarding step. it makes sure nix's experimental features are enabled, which is all nina needs to search and install packages. if they're already on, it prints `all set` and exits." },141 .{ .cmd_block = "$ nina setup" },142 .{ .para = "on NixOS, nina offers to add this line to /etc/nixos/configuration.nix (keeping a `.nina-bak` backup):" },143 .{ .cmd_block = "nix.settings.experimental-features = [ \"nix-command\" \"flakes\" ];" },144 .{ .para = "press y and nina appends it for you. press n and nina shows you the line to paste. either way it ends with one step:" },145 .{ .cmd_block = "$ sudo nixos-rebuild switch" },146 .{ .para = "everywhere else, nina writes `experimental-features = nix-command flakes` to ~/.config/nix/nix.conf. no sudo needed." },147 },148 },149 .{150 .id = "first-run",151 .title = "first run",152 .accent = .gold,153 .blocks = &[_]Block{154 .{ .para = "just type `nina help` at the prompt. she will show you every command she knows. (っ˘ω˘ς)" },155 .{ .para = "then try `nina hello` to see the machines nina has configured, or jump straight to `nina search` to find a package." },156 .{ .para = "from here, search for a package, apply a change, or wander through the tips section to see what is possible." },157 },158 },159 .{160 .id = "search",161 .title = "search & discovery",162 .accent = .mint,163 .blocks = &[_]Block{164 .{ .para = "inline search that never takes over your screen. results appear right below the prompt, and you navigate them with the keyboard. press esc any time to go back to what you were doing." },165 .{ .para = "search nixpkgs for packages in an 18-line inline widget — no fullscreen takeover, no context switch." },166 .{ .cmd_block = "$ nina search ripgrep\n:: search nixpkgs ripgrep 3 results\n\n ripgrep 14.1.0 recursively search directories\n ripgrep-all 0.10.0 ripgrep, but also search in PDFs\n ripgrep-fixed 0.1.0 ripgrep with fixed-string matching\n\n enter install · tab info · [up/down] nav · esc cancel" },167 .{ .para = "search nixpkgs options the same way:" },168 .{ .cmd_block = "$ nina option logind\n:: search options logind N results" },169 .{170 .callout = .{171 .face = "( •̀ω•́ )✧",172 .color = .mint,173 .body = "keys: [up/down] move through results · enter install the highlighted package · tab toggle package info · esc cancel",174 },175 },176 },177 },178 .{179 .id = "packages",180 .title = "packages",181 .accent = .lav,182 .blocks = &[_]Block{183 .{ .para = "install packages into your profile with a single command. nina searches nixpkgs, shows you what it found, and asks how you want it." },184 .{ .para = "[i] profile install — runs `nix profile install nixpkgs#attr`. available immediately, no rebuild needed." },185 .{ .para = "[s] system install — opens configuration.nix in your editor at the systemPackages line. add the package, save, and nina asks whether to apply." },186 .{ .para = "[t] try now — drops you into a `nix shell` with the package available. gone when you exit." },187 .{ .cmd_block = "$ nina install neovim\n:: neovim 0.10.4\n\n [i] profile install instant no rebuild\n [s] system install opens editor requires apply\n [t] try now exits when done\n\n > i\n:: installing neovim profile\n -> nix profile install nixpkgs#neovim\n:: neovim installed available now" },188 .{ .para = "explain why a package is installed:" },189 .{ .cmd_block = "$ nina why ripgrep\n:: why ripgrep kyoshi\n\n ripgrep is a direct profile package" },190 .{ .para = "`nina unpin <input>` releases a pinned flake input by running `nix flake update` on it." },191 },192 },193 .{194 .id = "system",195 .title = "system changes",196 .accent = .pink,197 .blocks = &[_]Block{198 .{ .para = "preview, apply, and roll back changes to your nixos system. nina shows you exactly what will change before it happens, and arms a rollback the moment things go sideways." },199 .{ .para = "compare two generations using `nix store diff-closures`:" },200 .{ .cmd_block = "$ nina diff\n:: gen 348 -> gen 349 kyoshi\n\n + neovim 0.10.4\n ^ ripgrep 14.0.3 -> 14.1.1" },201 .{ .para = "validate your configuration with `nixos-rebuild build`. catches syntax errors and missing references before they reach a running system." },202 .{ .para = "rebuild and switch to the new generation:" },203 .{ .cmd_block = "$ nina apply\n:: rebuilding kyoshi...\n:: generation 349 [4.1s]" },204 .{ .para = "roll back one generation. to jump to a specific generation, use `nina go <n>`." },205 .{ .cmd_block = "$ nina back\n:: rolling back kyoshi gen 349 -> gen 348\n:: done gen 348" },206 .{207 .flags_row = .{ .flag = "--on <machine>", .desc = "rebuild a remote machine over SSH" },208 },209 .{210 .flags_row = .{ .flag = "--dry", .desc = "dry-activate only — build without switching" },211 },212 .{213 .flags_row = .{ .flag = "--check", .desc = "validate config only, same as `nina check`" },214 },215 .{216 .callout = .{217 .face = "ε-(´∀`; )",218 .color = .pink,219 .body = "generations are immutable snapshots. `nina back` always has somewhere to go, and `nina go <n>` can reach any point in your history.",220 },221 },222 },223 },224 .{225 .id = "flakes",226 .title = "flakes & dev shells",227 .accent = .mint,228 .blocks = &[_]Block{229 .{ .para = "inspect flake outputs, step into dev shells, build specific outputs, and manage the flake lifecycle — all with the same quiet vocabulary." },230 .{ .para = "show what the current directory's flake exposes — packages, apps, dev shells, nixos configurations." },231 .{ .cmd_block = "$ nina flake show\n:: .\n\n packages x86_64-linux nina\n apps x86_64-linux nina\n devShells x86_64-linux default" },232 .{ .para = "run `nix flake update`. pass a specific input name to update only that one. prints a confirmation when `flake.lock` is written." },233 .{ .para = "enter the flake's dev shell with `nix develop`. type `exit` to return. `develop run <cmd>` runs a single command without an interactive shell." },234 .{ .para = "build a flake output with `nix build`. result is linked as `./result`. run a package without installing it — bare names are resolved as `nixpkgs#<pkg>`." },235 },236 },237 .{238 .id = "inspection",239 .title = "inspection & care",240 .accent = .lav,241 .blocks = &[_]Block{242 .{ .para = "check on the health of your system, follow service logs, inspect the store, and reclaim disk space — without jumping between different tools." },243 .{ .para = "show the current generation and reachability of the target machine:" },244 .{ .cmd_block = "$ nina status\n:: status\n\n kyoshi gen 349 1h ago 10.2 GB\n hm gen 2 2h ago" },245 .{ .para = "run a health diagnostic. checks the nix daemon, config syntax, channels, and home manager." },246 .{ .cmd_block = "$ nina doctor\n:: diagnosing kyoshi\n\n nix daemon ok\n config syntax ok\n channel ok\n home manager standalone gen 2\n\n:: all good" },247 .{ .para = "manage systemd services. `service logs <name> -f` follows the journal live. `stop` and `disable` prompt for confirmation when `confirm = true`. all service commands accept `--user` to target user-managed services." },248 .{ .para = "inspect and maintain the nix store. `store info` shows total size, live paths, and reclaimable space. `store gc` runs garbage collection. `store path <attr>` evaluates a store path for a nixpkgs attribute." },249 .{ .para = "remove old generations and run garbage collection. keeps the number of generations set by `generations` in your config (default: 5). `--all` removes all old generations regardless." },250 .{ .cmd_block = "$ nina clean\n:: cleaning kyoshi keeping 5 of 14\n continue? [Y/n] y\n:: freed 4.2 GB" },251 },252 },253 .{254 .id = "generations",255 .title = "generation history",256 .accent = .gold,257 .blocks = &[_]Block{258 .{ .para = "your system's whole story is saved in generations. browse them, compare them, travel back to any of them. nothing is gone until you clean it up." },259 .{ .cmd_block = "$ nina history\n:: generations kyoshi\n\n 349 2026-05-30 09:32:01 current\n 348 2026-05-29 14:22:44\n 347 2026-05-25 11:05:33" },260 .{ .para = "switch to any specific generation by number using `nix-env --switch-generation`. use this when `nina back` is not far enough." },261 .{ .para = "show nina's operation log from ~/.nina.log. displays the last 50 entries by default. use `--last N` to show more or fewer." },262 .{263 .callout = .{264 .face = "(´▽`)",265 .color = .gold,266 .body = "generations are your safety net. apply freely, knowing you can always go back. `nina history` shows the whole story. `nina go` takes you there.",267 },268 },269 },270 },271 .{272 .id = "home",273 .title = "home manager",274 .accent = .lav,275 .blocks = &[_]Block{276 .{ .para = "nina manages your home manager configuration with the same vocabulary as your system. all `nina home` commands work with standalone home manager and NixOS module setups. nina detects which mode you're using automatically." },277 .{ .para = "bootstrap home manager for the first time. runs `nix run home-manager/<branch> -- init`, which creates ~/.config/home-manager/home.nix with a starter configuration. passing `--switch` activates the configuration immediately." },278 .{ .cmd_block = "$ nina home init --switch\n(っ˘ω˘ς) :: setting up home manager ~/.config/home-manager\n -> will activate after init\n\n(˘ᵕ˘) :: home manager ready" },279 .{ .para = "apply your home manager configuration. runs `home-manager switch` under the hood. `--dry` builds without activating." },280 .{ .para = "roll back to the previous home manager generation. list home generations. show what changed between the last two with `home diff`. validate without applying with `home check`. open `home.nix` with `home edit`." },281 .{ .cmd_block = "$ nina home packages\n\n:: home packages kyoshi\n\n hello 2.12.1\n jq 1.7.1" },282 .{283 .callout = .{284 .face = "(っ˘ω˘ς)",285 .color = .lav,286 .body = "the same apply → diff → back pattern works for your home config, just as it does for your system. nina uses the same verbs so you do not have to learn a second vocabulary.",287 },288 },289 },290 },291 .{292 .id = "machine-config",293 .title = "machine config",294 .accent = .pink,295 .blocks = &[_]Block{296 .{ .para = "edit your configuration safely, inspect the running system, manage channels, and set boot defaults — all without needing to remember the right file path or nixos-rebuild flag." },297 .{ .para = "open configuration.nix in your configured editor (default: vim). pass `hardware` to open hardware-configuration.nix instead. after saving, run `nina check` to validate before applying." },298 .{ .para = "format configuration.nix with `nixpkgs-fmt`. `--check` reports whether formatting is needed without writing. show the nixos version, kernel version, and uptime of the target machine. list boot entries from the bootloader. launch `nix repl` with nixpkgs loaded." },299 .{300 .callout = .{301 .face = "(ˊᗜˋ)",302 .color = .pink,303 .body = "`nina edit` uses the `editor` value from ~/.config/nina/config (default: vim). set it to your editor of choice and nina will use it everywhere.",304 },305 },306 },307 },308 .{309 .id = "remote",310 .title = "remote machines",311 .accent = .lav,312 .blocks = &[_]Block{313 .{ .para = "the same voice, even over SSH. every nina command accepts `--on <machine>` and behaves identically on a remote host. no separate tooling, no context switching." },314 .{ .cmd_block = "$ nina apply --on azula\n ✦ rebuilding azula over ssh\n · building generation 193\n generation 193.\n\n$ nina service logs ollama -f --on azula\n ✦ following logs for ollama on azula\n [info] serving on 0.0.0.0:11434" },315 .{ .para = "define remote machines in ~/.config/nina/config:" },316 .{ .cmd_block = "[machine]\nname = azula\nhost = june@azula\nssh_key = ~/.ssh/id_ed25519" },317 .{ .para = "fleet commands:" },318 .{319 .flags_row = .{ .flag = "nina hello", .desc = "list all configured machines and their kind (local / ssh)" },320 },321 .{322 .flags_row = .{ .flag = "nina status --on <m>", .desc = "show generation and uptime for a remote machine" },323 },324 .{325 .flags_row = .{ .flag = "nina doctor --on <m>", .desc = "run a full health diagnostic on any machine" },326 },327 .{328 .flags_row = .{ .flag = "nina apply --on <m>", .desc = "rebuild and switch a remote machine over SSH" },329 },330 .{331 .callout = .{332 .face = "(˶ᵔ ᵕ ᵔ˶)",333 .color = .lav,334 .body = "nina keeps remote work from turning into a whole new personality. same verbs, same little reassurances, same sense of where you are.",335 },336 },337 },338 },339 .{340 .id = "hooks",341 .title = "hooks",342 .accent = .mint,343 .blocks = &[_]Block{344 .{ .para = "nina can run optional executable scripts before and after state-changing commands. hooks live in ~/.config/nina/hooks/. missing hooks are ignored, and files that are not executable are treated as absent." },345 .{ .para = "available hook files:" },346 .{ .cmd_block = "pre-apply post-apply\npre-back post-back\npre-home post-home\npre-upgrade post-upgrade" },347 .{ .para = "`pre-apply` runs before `nina apply`, `pre-back` before `nina back`, `pre-home` before `nina home apply`, and `pre-upgrade` before `nina upgrade`. the matching `post-*` hooks run only after the command succeeds." },348 .{ .para = "if a pre-hook exits non-zero, nina shows the exit code and the last five lines of hook output, then asks whether to continue. that prompt defaults to no." },349 .{ .cmd_block = ":: pre-apply hook exited with code 1\n\n uncommitted changes - commit before applying\n\n continue anyway? [y/N] n\n:: aborted" },350 .{ .para = "if a post-hook fails, nina prints a warning after the success line. the original command already succeeded, so the hook warning does not turn the operation into a failure." },351 .{ .para = "example: stop `nina apply` when your nixos config has uncommitted changes." },352 .{ .cmd_block = "#!/usr/bin/env sh\n# ~/.config/nina/hooks/pre-apply\nif git -C ~/nixos-config status --porcelain | grep -q .; then\n echo \"uncommitted changes - commit before applying\"\n exit 1\nfi" },353 .{ .para = "make hook scripts executable with `chmod +x ~/.config/nina/hooks/pre-apply`. nina creates the hooks directory automatically the first time it checks for hooks." },354 },355 },356 .{357 .id = "configuration",358 .title = "configuration",359 .accent = .gold,360 .blocks = &[_]Block{361 .{ .para = "nina's config file lives at ~/.config/nina/config (XDG). the format is plain key=value, one per line. comments start with #. all settings are optional — nina works out of the box with no config file. (´ω`)" },362 .{363 .callout = .{364 .face = "(っ˘ω˘ς)",365 .color = .gold,366 .body = "if you have an existing ~/.nina.conf from an earlier version, nina migrates it to ~/.config/nina/config automatically on first run.",367 },368 },369 .{ .para = "full example:" },370 .{ .cmd_block = "editor = nvim\ngenerations = 5\nconfirm = true\nteach = false\ncolor = true\n\n[machine]\nname = kyoshi\nconfig = /etc/nixos\nlocal = true\ndefault = true\n\n[machine]\nname = azula\nhost = june@azula\nssh_key = ~/.ssh/id_ed25519" },371 .{ .para = "if you have a [machine] host set, `local` automatically becomes false and `nina --on azula` runs everything over SSH. `default = true` on a machine block selects it when no `--on` is given." },372 .{ .para = "every config key accepts the values you'd expect. booleans read true / false / yes / no. integers are decimal. `~` in `ssh_key` expands to `$HOME`." },373 },374 },375 .{376 .id = "expressions",377 .title = "expressions ♡",378 .accent = .pink,379 .blocks = &[_]Block{380 .{ .para = "nina uses a few small kaomoji expressions instead of a mascot. they stay tucked into the UI, show a little feeling, and leave the work itself readable." },381 .{ .para = "the rule is simple: calm most of the time, brighter when something lands, softer when something goes wrong." },382 .{383 .kaomoji_card = .{384 .face = "(˶ᵔ ᵕ ᵔ˶)",385 .name = "hello",386 .color = .lav,387 .desc = "a warm, quiet welcome. nina is ready to help and not making a fuss about it.",388 .when = "setup wizard, first-run greeting",389 },390 },391 .{392 .kaomoji_card = .{393 .face = "(っ˘ω˘ς)",394 .name = "starting",395 .color = .mint,396 .desc = "settling in and getting to work. something meaningful is beginning.",397 .when = "apply start, home apply start, entering a dev shell",398 },399 },400 .{401 .kaomoji_card = .{402 .face = "╰(*°▽°*)╯",403 .name = "relief",404 .color = .gold,405 .desc = "something that was broken is working again. a small celebration for persistence.",406 .when = "first success after a previously failed build",407 },408 },409 .{410 .kaomoji_card = .{411 .face = "( ˘ᵕ˘ )",412 .name = "done",413 .color = .lav,414 .desc = "calm satisfaction. a rollback landed cleanly and the system is in good hands.",415 .when = "back success",416 },417 },418 .{419 .kaomoji_card = .{420 .face = "( ´ ∀ ` )",421 .name = "clean",422 .color = .mint,423 .desc = "light and tidy. the work is done and things look better than before.",424 .when = "clean done",425 },426 },427 .{428 .kaomoji_card = .{429 .face = "( ´ ▽ ` )",430 .name = "all good",431 .color = .gold,432 .desc = "everything checked out. a quiet joy that the system is healthy.",433 .when = "doctor all clear, mood all good",434 },435 },436 .{437 .kaomoji_card = .{438 .face = "(˘ω˘ )",439 .name = "exiting",440 .color = .lav,441 .desc = "a gentle close. nothing went wrong — it's just time to step out.",442 .when = "try exit, leaving a dev shell",443 },444 },445 .{446 .kaomoji_card = .{447 .face = "(;ω;)",448 .name = "sorry",449 .color = .pink,450 .desc = "something went sideways, and nina is honest about it. details are right there with her.",451 .when = "network or SSH errors, search timeout",452 },453 },454 .{455 .callout = .{456 .face = "( ´•̥̥̥ω•̥̥̥` )",457 .color = .pink,458 .body = "if you prefer a quieter experience, set `color = false` in your config. nina will still show everything — just without the ansi palette.",459 },460 },461 },462 },463 .{464 .id = "tips",465 .title = "tips & recipes",466 .accent = .mint,467 .blocks = &[_]Block{468 .{ .para = "a few patterns that make daily nixos life a little smoother. (*˘︶˘*)" },469 .{ .para = "a quiet morning with nina:" },470 .{ .cmd_block = "# update flake inputs and preview changes\n$ nina flake update\n$ nina diff\n\n# apply if the diff looks good\n$ nina apply" },471 .{ .para = "try a package without committing to it:" },472 .{ .cmd_block = "$ nina try bat\n:: trying bat exit when done\n → bat is available in this shell only, gone when you exit" },473 .{ .para = "push the same config change to multiple machines:" },474 .{ .cmd_block = "$ nina apply --on kyoshi\n:: generation 349 [4.1s]\n$ nina apply --on azula\n:: generation 193 [6.3s]" },475 .{ .para = "something broke and you want to find when:" },476 .{ .cmd_block = "$ nina history\n → find the last known-good generation number\n$ nina diff 344 349\n → see what changed across those generations\n$ nina go 344\n:: done gen 344" },477 .{ .para = "useful shell aliases:" },478 .{ .cmd_block = "alias ns=\"nina search\"\nalias no=\"nina option\"\nalias na=\"nina apply\"\nalias nd=\"nina diff\"\nalias nb=\"nina back\"" },479 .{480 .callout = .{481 .face = "(≧◡≦)",482 .color = .mint,483 .body = "when something goes wrong, `nina back` is always there. when you're not sure what to try next, `nina diff` shows you the picture. and when things go right, nina keeps the terminal feeling light.",484 },485 },486 },487 },488};489490491492493494495496var PAGE_ROWS: u16 = 22;497const MIN_PAGE_ROWS: u16 = 5;498const COL_WIDTH: u16 = 96; 499500501502const HEADER_ROWS: u16 = 3;503const FOOTER_ROWS: u16 = 1;504fn frameRows() u16 {505 return HEADER_ROWS + PAGE_ROWS + FOOTER_ROWS;506}507508509510511fn detectPageRows(io: std.Io) u16 {512 var ws: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 };513 const result = io.operate(.{ .device_io_control = .{514 .file = std.Io.File.stdout(),515 .code = std.posix.T.IOCGWINSZ,516 .arg = &ws,517 } }) catch return PAGE_ROWS;518 if (result.device_io_control < 0) return PAGE_ROWS;519 if (ws.row <= HEADER_ROWS + FOOTER_ROWS + MIN_PAGE_ROWS) return PAGE_ROWS;520 return ws.row - HEADER_ROWS - FOOTER_ROWS;521}522523pub fn run(io: std.Io, topic: []const u8) !void {524 525 526 if (!(std.Io.File.stdout().isTty(io) catch false)) {527 return runPlain(io, topic);528 }529530 531 var saved: std.posix.termios = undefined;532 try rawterm.rawModeEnable(STDIN_FD, &saved);533 defer rawterm.rawModeDisable(STDIN_FD, saved);534535 PAGE_ROWS = detectPageRows(io);536537 const start_section: usize = if (topic.len == 0) 0 else blk: {538 for (MAN_PAGE, 0..) |sec, i| {539 if (std.mem.eql(u8, sec.id, topic)) break :blk i;540 }541 output.p("{s}nina man: no such topic: {s}{s}\n", .{ c(PINK), topic, c(RESET) });542 output.flush();543 return;544 };545546 547 548 549 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);550 defer arena.deinit();551 const allocator = arena.allocator();552553 var rendered = std.ArrayList([]const u8).empty;554 defer rendered.deinit(allocator);555556 557 558 var section_starts = std.ArrayList(usize).empty;559 defer section_starts.deinit(allocator);560 var section_titles = std.ArrayList([]const u8).empty;561 defer section_titles.deinit(allocator);562563 try section_starts.append(allocator, 0);564 for (MAN_PAGE) |sec| {565 try section_titles.append(allocator, sec.title);566 try renderSection(allocator, &rendered, sec);567 try section_starts.append(allocator, rendered.items.len);568 }569 const total: usize = rendered.items.len;570571 var top: usize = 0;572 var cur_section: usize = start_section;573 top = section_starts.items[start_section];574575 drawHeader();576 drawBody(rendered.items, top, total, section_titles.items, cur_section);577 drawFooter(cur_section, MAN_PAGE.len, top, total);578 output.flush();579580 var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};581 while (true) {582 _ = std.posix.poll(&pfd, -1) catch break;583584 var buf: [8]u8 = undefined;585 const n = std.posix.read(STDIN_FD, &buf) catch break;586 if (n == 0) break;587588 const advance_section = struct {589 fn f(starts: []const usize, cur: usize, delta: i32) usize {590 var idx: i32 = @intCast(cur);591 idx += delta;592 if (idx < 0) idx = 0;593 if (idx >= @as(i32, @intCast(starts.len - 1))) idx = @intCast(starts.len - 2);594 return starts[@intCast(idx)];595 }596 }.f;597598 599 const sectionOf = struct {600 fn f(starts: []usize, t: usize) usize {601 var s: usize = 0;602 for (starts[1..], 1..) |start, i| {603 if (t < start) return i - 1;604 s = i;605 }606 return s;607 }608 }.f;609610 var redraw = true;611 if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {612 switch (buf[2]) {613 'A' => top = if (top > 0) top - 1 else 0, 614 'B' => top = if (top + 1 < total) top + 1 else top, 615 'C' => top = advance_section(section_starts.items, cur_section, 1), 616 'D' => top = advance_section(section_starts.items, cur_section, -1), 617 '5' => top = if (top >= PAGE_ROWS) top - PAGE_ROWS else 0, 618 '6' => top = if (top + PAGE_ROWS < total) top + PAGE_ROWS else total - 1, 619 'H' => top = 0, 620 'F' => top = if (total > 0) total - 1 else 0, 621 else => redraw = false,622 }623 } else switch (buf[0]) {624 'q', 'Q', 0x1b => break, 625 'j' => top = if (top + 1 < total) top + 1 else top,626 'k' => top = if (top > 0) top - 1 else 0,627 ' ' => top = if (top + PAGE_ROWS < total) top + PAGE_ROWS else total - 1,628 'b' => top = if (top >= PAGE_ROWS) top - PAGE_ROWS else 0,629 'g' => top = 0,630 'G' => top = if (total > 0) total - 1 else 0,631 'n' => top = advance_section(section_starts.items, cur_section, 1),632 'p' => top = advance_section(section_starts.items, cur_section, -1),633 'h' => top = 0,634 '?' => {635 showHelp();636 637 638 639 640 redraw = true;641 },642 else => redraw = false,643 }644645 if (redraw) {646 cur_section = sectionOf(section_starts.items, top);647 648 649 650 651 output.cursorUp(PAGE_ROWS + FOOTER_ROWS);652 drawBody(rendered.items, top, total, section_titles.items, cur_section);653 drawFooter(cur_section, MAN_PAGE.len, top, total);654 output.flush();655 }656 }657658 659 660 661 output.cursorUp(frameRows());662 for (0..frameRows()) |_| {663 output.raw("\x1b[K\n");664 }665 output.cursorUp(frameRows());666 output.flush();667}668669fn runPlain(_: std.Io, topic: []const u8) !void {670 const start_section: usize = if (topic.len == 0) 0 else blk: {671 for (MAN_PAGE, 0..) |sec, i| {672 if (std.mem.eql(u8, sec.id, topic)) break :blk i;673 }674 output.p("{s}nina man: no such topic: {s}{s}\n", .{ c(PINK), topic, c(RESET) });675 output.flush();676 return;677 };678679 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);680 defer arena.deinit();681 const allocator = arena.allocator();682683 var rendered = std.ArrayList([]const u8).empty;684 defer rendered.deinit(allocator);685686 var i: usize = start_section;687 while (i < MAN_PAGE.len) : (i += 1) {688 try renderSection(allocator, &rendered, MAN_PAGE[i]);689 }690 for (rendered.items) |line| output.raw(line);691 output.raw("\n");692 output.flush();693}694695const STDIN_FD = rawterm.STDIN_FD;696697698699700701702703const AccentColor = struct {704 fg: []const u8,705 bar: []const u8,706};707708fn accentColors(a: Block.Accent) AccentColor {709 return switch (a) {710 .gold => .{ .fg = GOLD, .bar = GOLD },711 .mint => .{ .fg = MINT, .bar = MINT },712 .pink => .{ .fg = PINK, .bar = PINK },713 .lav => .{ .fg = LAV, .bar = LAV },714 };715}716717fn renderSection(allocator: std.mem.Allocator, out: *std.ArrayList([]const u8), sec: Section) !void {718 719 var idx_buf: [16]u8 = undefined;720 const idx_str = std.fmt.bufPrint(&idx_buf, "{d:0>2}", .{sectionIndexOf(sec.id) + 1}) catch "00";721722 const accent = accentColors(sec.accent);723 const divider = try std.fmt.allocPrint(allocator, "{s}── {s}{s}{s} {s}{s}{s}\n", .{724 c(TEXT_DIM),725 c(accent.fg), sec.title, c(RESET),726 c(TEXT_DIM), idx_str, c(RESET),727 });728 try out.append(allocator, divider);729730 for (sec.blocks) |block| {731 try renderBlock(allocator, out, block);732 }733734 735 try out.append(allocator, "\n");736}737738fn sectionIndexOf(id: []const u8) usize {739 for (MAN_PAGE, 0..) |sec, i| {740 if (std.mem.eql(u8, sec.id, id)) return i;741 }742 return 0;743}744745fn renderBlock(allocator: std.mem.Allocator, out: *std.ArrayList([]const u8), block: Block) !void {746 switch (block) {747 .chapter => try out.append(allocator, try std.fmt.allocPrint(allocator, "\n{s}{s} {s}{s}\n\n", .{ c(TEXT_DIM), block.chapter, c(RESET), "" })),748 .title => {749 const accent = accentColors(block.accent);750 const padded_title = try std.fmt.allocPrint(allocator, " {s}◆{s} {s}{s}{s} ", .{ c(accent.bar), c(RESET), c(BOLD), block.title, c(RESET) });751 try out.append(allocator, try std.fmt.allocPrint(allocator, "\n{s}{s}\n", .{ padded_title, "" }));752 if (block.badge.len > 0) {753 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}[{s}{s}{s}{s}]{s}\n", .{ c(TEXT_DIM), c(accent.fg), block.badge, c(TEXT_DIM), c(RESET), c(RESET) }));754 }755 },756 .badge => {}, 757 .accent => {}, 758 .para => {759 const wrapped = try wrap(allocator, block.para, COL_WIDTH);760 for (wrapped) |line| {761 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}{s}{s}\n", .{ c(TEXT), line, c(RESET) }));762 }763 },764 .cmd_sig => {765 const accent = accentColors(block.accent);766 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}│{s} {s}{s}{s}\n", .{ c(accent.fg), c(RESET), c(accent.fg), block.cmd_sig, c(RESET) }));767 },768 .cmd_block => {769 const wrapped = try wrap(allocator, block.cmd_block, COL_WIDTH);770 for (wrapped) |line| {771 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}{s}{s}\n", .{ c(MINT), line, c(RESET) }));772 }773 try out.append(allocator, "\n");774 },775 .flags_row => {776 const accent = accentColors(.gold);777 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}│{s} {s}{s:<22}{s} {s}{s}{s}\n", .{ c(accent.fg), c(RESET), c(accent.fg), block.flags_row.flag, c(RESET), c(TEXT), block.flags_row.desc, c(RESET) }));778 },779 .callout => {780 const accent = accentColors(block.callout.color);781 const wrapped = try wrap(allocator, block.callout.body, COL_WIDTH - 6);782 var first = true;783 for (wrapped) |line| {784 if (first) {785 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}┌─ {s}{s} {s}{s}{s}\n", .{ c(accent.bar), c(accent.fg), block.callout.face, c(TEXT), line, c(RESET) }));786 first = false;787 } else {788 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}│{s} {s}{s}{s}\n", .{ c(accent.bar), c(RESET), c(TEXT), line, c(RESET) }));789 }790 }791 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}└─{s}\n", .{ c(accent.bar), c(RESET) }));792 },793 .kaomoji_card => {794 const accent = accentColors(block.kaomoji_card.color);795 796 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}┌── {s}{s} {s}{s}{s}\n", .{ c(accent.bar), c(accent.fg), block.kaomoji_card.face, c(BOLD), block.kaomoji_card.name, c(RESET) }));797 const desc_lines = try wrap(allocator, block.kaomoji_card.desc, COL_WIDTH - 10);798 for (desc_lines) |line| {799 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}│{s} {s}{s}{s}\n", .{ c(accent.bar), c(RESET), c(TEXT), line, c(RESET) }));800 }801 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}│{s} {s}when:{s} {s}{s}{s}\n", .{ c(accent.bar), c(RESET), c(TEXT_DIM), c(RESET), c(TEXT_DIM), block.kaomoji_card.when, c(RESET) }));802 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}└──{s}\n", .{ c(accent.bar), c(RESET) }));803 },804 .blank => try out.append(allocator, "\n"),805 }806}807808809fn wrap(allocator: std.mem.Allocator, text: []const u8, width: u16) ![]const []const u8 {810 var lines = std.ArrayList([]const u8).empty;811 errdefer lines.deinit(allocator);812813 var it = std.mem.splitScalar(u8, text, '\n');814 while (it.next()) |para| {815 if (para.len == 0) {816 try lines.append(allocator, "");817 continue;818 }819 var start: usize = 0;820 while (start < para.len) {821 var end = @min(start + @as(usize, width), para.len);822 if (end < para.len) {823 var back: usize = end;824 while (back > start and para[back - 1] != ' ') : (back -= 1) {}825 if (back > start) end = back;826 }827 try lines.append(allocator, std.mem.trim(u8, para[start..end], " \t"));828 start = end;829 while (start < para.len and para[start] == ' ') : (start += 1) {}830 }831 }832 return lines.toOwnedSlice(allocator);833}834835836837fn drawHeader() void {838 839 output.raw("\x1b[K");840 output.p("{s}{s} nina man {s}the whole picture · press ? for keys · q to leave{s}\n", .{ c(BG_BAR), c(PINK), c(TEXT), c(RESET) });841 output.raw("\x1b[K");842 output.p("{s} {s}(˶ᵔ ᵕ ᵔ˶){s} ready when you are{s}\n", .{ c(BG_HI), c(PINK), c(RESET), c(RESET) });843 output.raw("\x1b[K");844 output.p("{s}{s}{s}\n", .{ c(TEXT_DIM), "─" ** 80, c(RESET) });845}846847fn drawBody(lines: []const []const u8, top: usize, total: usize, titles: []const []const u8, cur_section: usize) void {848 _ = total;849 _ = titles;850 _ = cur_section;851 852 853 854 855 856 var i: usize = 0;857 while (i < PAGE_ROWS) : (i += 1) {858 output.raw("\x1b[K");859 const idx = top + i;860 if (idx < lines.len) {861 output.raw(lines[idx]);862 } else {863 output.raw("\n");864 }865 }866}867868fn drawFooter(cur_section: usize, total_sections: usize, top: usize, total: usize) void {869 const pct: usize = if (total == 0) 0 else (top * 100) / total;870 output.raw("\x1b[K");871 var sec_buf: [16]u8 = undefined;872 const sec_str = std.fmt.bufPrint(&sec_buf, "{d}", .{cur_section + 1}) catch "?";873 var tot_buf: [16]u8 = undefined;874 const tot_str = std.fmt.bufPrint(&tot_buf, "{d}", .{total_sections}) catch "?";875 var pct_buf: [16]u8 = undefined;876 const pct_str = std.fmt.bufPrint(&pct_buf, "{d}", .{pct}) catch "?";877 output.p("{s}{s}─── {s}{s:>2}/{s}{s} {s}{s:>3}%{s} {s}· ↑↓ scroll · n/p next/prev section · q quit{s}\n", .{878 c(TEXT_DIM), c(BG_HI),879 c(MINT), sec_str, tot_str, c(RESET),880 c(GOLD), pct_str, c(RESET),881 c(TEXT_DIM), c(RESET),882 });883}884885fn showHelp() void {886 output.cursorUp(frameRows());887 for (0..frameRows()) |_| output.raw("\x1b[K\n");888 output.cursorUp(frameRows());889 drawHeader();890 drawHelpBody();891 drawFooter(0, MAN_PAGE.len, 0, 1);892 output.flush();893894 895 var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};896 _ = std.posix.poll(&pfd, -1) catch {};897 var buf: [8]u8 = undefined;898 _ = std.posix.read(STDIN_FD, &buf) catch {};899}900901fn drawHelpBody() void {902 const pairs = [_]struct { key: []const u8, desc: []const u8 }{903 .{ .key = "j / ↓", .desc = "scroll down one line" },904 .{ .key = "k / ↑", .desc = "scroll up one line" },905 .{ .key = "space", .desc = "page down" },906 .{ .key = "b / pgup", .desc = "page up" },907 .{ .key = "g / home", .desc = "jump to top" },908 .{ .key = "G / end", .desc = "jump to bottom" },909 .{ .key = "n / →", .desc = "next section" },910 .{ .key = "p / ←", .desc = "previous section" },911 .{ .key = "h", .desc = "back to top (table of contents)" },912 .{ .key = "?", .desc = "this help" },913 .{ .key = "q / esc", .desc = "leave the manpage" },914 };915 916 917 918 919 920 921 922 var i: usize = 0;923 while (i < PAGE_ROWS) : (i += 1) {924 output.raw("\x1b[K");925 if (i == 0) {926 output.p(" {s}{s}nina man · keys{s}\n", .{ c(BOLD), c(PINK), c(RESET) });927 } else if (i - 1 < pairs.len) {928 const p = pairs[i - 1];929 output.p(" {s}{s:<14}{s} {s}{s}{s}\n", .{ c(GOLD), p.key, c(RESET), c(TEXT), p.desc, c(RESET) });930 } else {931 output.raw("\n");932 }933 }934}935936937938939940941942943944945946fn newlineCount(bytes: []const u8) usize {947 return std.mem.count(u8, bytes, "\n");948}949950test "drawHelpBody emits exactly PAGE_ROWS rows regardless of PAGE_ROWS' value" {951 const saved_page_rows = PAGE_ROWS;952 defer PAGE_ROWS = saved_page_rows;953954 var writer: std.Io.Writer.Allocating = .init(std.testing.allocator);955 defer writer.deinit();956 var env: std.process.Environ.Map = .init(std.testing.allocator);957 defer env.deinit();958 output.init(&writer.writer, std.testing.io, &env, false);959960 for ([_]u16{ 5, 12, MIN_PAGE_ROWS }) |rows| {961 PAGE_ROWS = rows;962 writer.clearRetainingCapacity();963 drawHelpBody();964 try std.testing.expectEqual(@as(usize, rows), newlineCount(writer.written()));965 }966}967968test "drawBody emits exactly PAGE_ROWS rows" {969 const saved_page_rows = PAGE_ROWS;970 defer PAGE_ROWS = saved_page_rows;971 PAGE_ROWS = 6;972973 var writer: std.Io.Writer.Allocating = .init(std.testing.allocator);974 defer writer.deinit();975 var env: std.process.Environ.Map = .init(std.testing.allocator);976 defer env.deinit();977 output.init(&writer.writer, std.testing.io, &env, false);978979 const lines = [_][]const u8{ "one\n", "two\n", "three\n" };980 drawBody(&lines, 0, lines.len, &.{}, 0);981982 try std.testing.expectEqual(@as(usize, PAGE_ROWS), newlineCount(writer.written()));983}984985986987988989990991992993994995fn collectManPageText(gpa: std.mem.Allocator) ![]u8 {996 var buf: std.ArrayList(u8) = .empty;997 errdefer buf.deinit(gpa);998 for (MAN_PAGE) |section| {999 try buf.appendSlice(gpa, section.title);1000 try buf.append(gpa, '\n');1001 for (section.blocks) |block| {1002 switch (block) {1003 .chapter, .title, .badge, .para, .cmd_sig, .cmd_block => |s| {1004 try buf.appendSlice(gpa, s);1005 try buf.append(gpa, '\n');1006 },1007 .flags_row => |r| {1008 try buf.appendSlice(gpa, r.flag);1009 try buf.append(gpa, '\n');1010 try buf.appendSlice(gpa, r.desc);1011 try buf.append(gpa, '\n');1012 },1013 .callout => |co| {1014 try buf.appendSlice(gpa, co.body);1015 try buf.append(gpa, '\n');1016 },1017 .kaomoji_card => |k| {1018 try buf.appendSlice(gpa, k.name);1019 try buf.append(gpa, '\n');1020 try buf.appendSlice(gpa, k.desc);1021 try buf.append(gpa, '\n');1022 try buf.appendSlice(gpa, k.when);1023 try buf.append(gpa, '\n');1024 },1025 .accent, .blank => {},1026 }1027 }1028 }1029 return buf.toOwnedSlice(gpa);1030}10311032test "MAN_PAGE and README stay free of retired config/search-key/log-path strings" {1033 const gpa = std.testing.allocator;1034 const text = try collectManPageText(gpa);1035 defer gpa.free(text);10361037 try std.testing.expect(std.mem.count(u8, text, "~/.nina.conf") <= 1);1038 try std.testing.expect(std.mem.count(u8, text, "c copy attr") == 0);1039 try std.testing.expect(std.mem.count(u8, text, "~/.local/share/nina/nina.log") == 0);10401041 1042 1043 1044 1045 const readme = try std.Io.Dir.readFileAlloc(.cwd(), std.testing.io, build_options.readme_path, gpa, .unlimited);1046 defer gpa.free(readme);1047 try std.testing.expect(std.mem.count(u8, readme, "~/.nina.conf") == 0);1048 try std.testing.expect(std.mem.count(u8, readme, "c copy attr") == 0);1049 try std.testing.expect(std.mem.count(u8, readme, "~/.local/share/nina/nina.log") == 0);1050}