12345678910111213141516const std = @import("std");17const builtin = @import("builtin");18const output = @import("output.zig");1920const PINK = "\x1b[38;2;170;106;82m"; 21const GOLD = "\x1b[38;2;173;138;80m"; 22const MINT = "\x1b[38;2;112;136;129m"; 23const LAV = "\x1b[38;2;122;129;98m"; 24const TEXT = "\x1b[38;2;102;93;85m"; 25const TEXT_DIM = "\x1b[38;2;136;124;113m"; 26const BG_HI = "\x1b[48;2;238;230;219m"; 27const BG_BAR = "\x1b[48;2;205;191;174m"; 28const RESET = "\x1b[0m";29const BOLD = "\x1b[1m";30const DIM = "\x1b[2m";31const INV = "\x1b[7m";32333435fn c(code: []const u8) []const u8 {36 return if (output.colorEnabled()) code else "";37}38394041const Block = union(enum) {42 chapter: []const u8, 43 title: []const u8, 44 badge: []const u8, 45 accent: Accent, 46 para: []const u8, 47 cmd_sig: []const u8, 48 cmd_block: []const u8, 49 flags_row: FlagsRow, 50 callout: Callout, 51 kaomoji_card: KaomojiCard,52 blank,5354 const Accent = enum { gold, mint, pink, lav };5556 const FlagsRow = struct {57 flag: []const u8,58 desc: []const u8,59 };6061 const Callout = struct {62 face: []const u8, 63 body: []const u8,64 color: Accent,65 };6667 const KaomojiCard = struct {68 face: []const u8,69 name: []const u8,70 desc: []const u8,71 when: []const u8,72 color: Accent,73 };74};7576const Section = struct {77 id: []const u8, 78 title: []const u8,79 accent: Block.Accent,80 blocks: []const Block,81};8283848586878889const MAN_PAGE = [_]Section{90 .{91 .id = "intro",92 .title = "what is nina?",93 .accent = .gold,94 .blocks = &[_]Block{95 .{ .para = "nina is a NixOS Intuitive Navigation Assistant — a gentle command-line tool that wraps the full NixOS workflow in one quiet, consistent voice." },96 .{ .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." },97 .{ .para = "written in zig. zero runtime dependencies. a single binary. runs the same on any NixOS machine." },98 .{ .para = "the goal is simple: NixOS should feel like home. nina is one step toward that." },99 .{100 .callout = .{101 .face = "(っ˘ω˘ς)",102 .color = .gold,103 .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.",104 },105 },106 },107 },108 .{109 .id = "install",110 .title = "installation",111 .accent = .gold,112 .blocks = &[_]Block{113 .{ .para = "nina is a nix flake. two ways to install:" },114 .{ .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):" },115 .{ .cmd_block = "experimental-features = nix-command flakes" },116 .{ .para = "or run `nina setup` after installing — she will add the line for you." },117 .{ .para = "direct url (simplest):" },118 .{ .cmd_block = "$ nix profile add 'https://kepr.uk/nina/archive/HEAD.tar.gz#nina'" },119 .{ .para = "or build from source with koh:" },120 .{ .cmd_block = "$ koh steal kepr.uk/nina && koh build" },121 .{122 .callout = .{123 .face = "(•̀ᴗ•́)و",124 .color = .mint,125 .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.",126 },127 },128 },129 },130 .{131 .id = "setup",132 .title = "nina setup",133 .accent = .mint,134 .blocks = &[_]Block{135 .{ .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." },136 .{ .cmd_block = "$ nina setup" },137 .{ .para = "on NixOS, nina offers to add this line to /etc/nixos/configuration.nix (keeping a `.nina-bak` backup):" },138 .{ .cmd_block = "nix.settings.experimental-features = [ \"nix-command\" \"flakes\" ];" },139 .{ .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:" },140 .{ .cmd_block = "$ sudo nixos-rebuild switch" },141 .{ .para = "everywhere else, nina writes `experimental-features = nix-command flakes` to ~/.config/nix/nix.conf. no sudo needed." },142 },143 },144 .{145 .id = "first-run",146 .title = "first run",147 .accent = .gold,148 .blocks = &[_]Block{149 .{ .para = "just type `nina help` at the prompt. she will show you every command she knows. (っ˘ω˘ς)" },150 .{ .para = "then try `nina hello` to see the machines nina has configured, or jump straight to `nina search` to find a package." },151 .{ .para = "from here, search for a package, apply a change, or wander through the tips section to see what is possible." },152 },153 },154 .{155 .id = "search",156 .title = "search & discovery",157 .accent = .mint,158 .blocks = &[_]Block{159 .{ .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." },160 .{ .para = "search nixpkgs for packages in an 18-line inline widget — no fullscreen takeover, no context switch." },161 .{ .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 ↓↑ navigate · i install · s system · t try · esc" },162 .{ .para = "search nixpkgs options the same way:" },163 .{ .cmd_block = "$ nina option logind\n:: search options logind N results" },164 .{165 .callout = .{166 .face = "( •̀ω•́ )✧",167 .color = .mint,168 .body = "keys: ↓↑ or j k move through results · i or Enter profile install · s system install · t try · c copy attr · Esc or q exit",169 },170 },171 },172 },173 .{174 .id = "packages",175 .title = "packages",176 .accent = .lav,177 .blocks = &[_]Block{178 .{ .para = "install packages into your profile with a single command. nina searches nixpkgs, shows you what it found, and asks how you want it." },179 .{ .para = "[i] profile install — runs `nix profile install nixpkgs#attr`. available immediately, no rebuild needed." },180 .{ .para = "[s] system install — opens configuration.nix in your editor at the systemPackages line. add the package, save, and nina asks whether to apply." },181 .{ .para = "[t] try now — drops you into a `nix shell` with the package available. gone when you exit." },182 .{ .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" },183 .{ .para = "explain why a package is installed:" },184 .{ .cmd_block = "$ nina why ripgrep\n:: why ripgrep kyoshi\n\n ripgrep is a direct profile package" },185 .{ .para = "pin or unpin a flake input to a specific commit. `nina unpin` runs `nix flake update` on that input to release the pin." },186 },187 },188 .{189 .id = "system",190 .title = "system changes",191 .accent = .pink,192 .blocks = &[_]Block{193 .{ .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." },194 .{ .para = "compare two generations using `nix store diff-closures`:" },195 .{ .cmd_block = "$ nina diff\n:: gen 348 -> gen 349 kyoshi\n\n + neovim 0.10.4\n ^ ripgrep 14.0.3 -> 14.1.1" },196 .{ .para = "validate your configuration with `nixos-rebuild build`. catches syntax errors and missing references before they reach a running system." },197 .{ .para = "rebuild and switch to the new generation:" },198 .{ .cmd_block = "$ nina apply\n:: rebuilding kyoshi...\n:: generation 349 [4.1s]" },199 .{ .para = "roll back one generation. to jump to a specific generation, use `nina go <n>`." },200 .{ .cmd_block = "$ nina back\n:: rolling back kyoshi gen 349 -> gen 348\n:: done gen 348" },201 .{202 .flags_row = .{ .flag = "--on <machine>", .desc = "rebuild a remote machine over SSH" },203 },204 .{205 .flags_row = .{ .flag = "--dry", .desc = "dry-activate only — build without switching" },206 },207 .{208 .flags_row = .{ .flag = "--check", .desc = "validate config only, same as `nina check`" },209 },210 .{211 .callout = .{212 .face = "ε-(´∀`; )",213 .color = .pink,214 .body = "generations are immutable snapshots. `nina back` always has somewhere to go, and `nina go <n>` can reach any point in your history.",215 },216 },217 },218 },219 .{220 .id = "flakes",221 .title = "flakes & dev shells",222 .accent = .mint,223 .blocks = &[_]Block{224 .{ .para = "inspect flake outputs, step into dev shells, build specific outputs, and manage the flake lifecycle — all with the same quiet vocabulary." },225 .{ .para = "show what the current directory's flake exposes — packages, apps, dev shells, nixos configurations." },226 .{ .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" },227 .{ .para = "run `nix flake update`. pass a specific input name to update only that one. prints a confirmation when `flake.lock` is written." },228 .{ .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." },229 .{ .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>`." },230 },231 },232 .{233 .id = "inspection",234 .title = "inspection & care",235 .accent = .lav,236 .blocks = &[_]Block{237 .{ .para = "check on the health of your system, follow service logs, inspect the store, and reclaim disk space — without jumping between different tools." },238 .{ .para = "show the current generation and reachability of the target machine:" },239 .{ .cmd_block = "$ nina status\n:: status\n\n kyoshi gen 349 1h ago 10.2 GB\n hm gen 2 2h ago" },240 .{ .para = "run a health diagnostic. checks the nix daemon, config syntax, channels, and home manager." },241 .{ .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" },242 .{ .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." },243 .{ .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." },244 .{ .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." },245 .{ .cmd_block = "$ nina clean\n:: cleaning kyoshi keeping 5 of 14\n continue? [Y/n] y\n:: freed 4.2 GB" },246 },247 },248 .{249 .id = "generations",250 .title = "generation history",251 .accent = .gold,252 .blocks = &[_]Block{253 .{ .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." },254 .{ .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" },255 .{ .para = "switch to any specific generation by number using `nix-env --switch-generation`. use this when `nina back` is not far enough." },256 .{ .para = "show nina's operation log from ~/.local/share/nina/nina.log. displays the last 50 entries by default. use `--last N` to show more or fewer." },257 .{258 .callout = .{259 .face = "(´▽`)",260 .color = .gold,261 .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.",262 },263 },264 },265 },266 .{267 .id = "home",268 .title = "home manager",269 .accent = .lav,270 .blocks = &[_]Block{271 .{ .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." },272 .{ .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." },273 .{ .cmd_block = "$ nina home init --switch\n(っ˘ω˘ς) :: setting up home manager ~/.config/home-manager\n -> will activate after init\n\n(˘ᵕ˘) :: home manager ready" },274 .{ .para = "apply your home manager configuration. runs `home-manager switch` under the hood. `--dry` builds without activating." },275 .{ .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`." },276 .{ .cmd_block = "$ nina home packages\n\n:: home packages kyoshi\n\n hello 2.12.1\n jq 1.7.1" },277 .{278 .callout = .{279 .face = "(っ˘ω˘ς)",280 .color = .lav,281 .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.",282 },283 },284 },285 },286 .{287 .id = "machine-config",288 .title = "machine config",289 .accent = .pink,290 .blocks = &[_]Block{291 .{ .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." },292 .{ .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." },293 .{ .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." },294 .{295 .callout = .{296 .face = "(ˊᗜˋ)",297 .color = .pink,298 .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.",299 },300 },301 },302 },303 .{304 .id = "remote",305 .title = "remote machines",306 .accent = .lav,307 .blocks = &[_]Block{308 .{ .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." },309 .{ .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" },310 .{ .para = "define remote machines in ~/.config/nina/config:" },311 .{ .cmd_block = "[machine]\nname = azula\nhost = june@azula\nssh_key = ~/.ssh/id_ed25519" },312 .{ .para = "fleet commands:" },313 .{314 .flags_row = .{ .flag = "nina hello", .desc = "list all configured machines and their kind (local / ssh)" },315 },316 .{317 .flags_row = .{ .flag = "nina status --on <m>", .desc = "show generation and uptime for a remote machine" },318 },319 .{320 .flags_row = .{ .flag = "nina doctor --on <m>", .desc = "run a full health diagnostic on any machine" },321 },322 .{323 .flags_row = .{ .flag = "nina apply --on <m>", .desc = "rebuild and switch a remote machine over SSH" },324 },325 .{326 .callout = .{327 .face = "(˶ᵔ ᵕ ᵔ˶)",328 .color = .lav,329 .body = "nina keeps remote work from turning into a whole new personality. same verbs, same little reassurances, same sense of where you are.",330 },331 },332 },333 },334 .{335 .id = "hooks",336 .title = "hooks",337 .accent = .mint,338 .blocks = &[_]Block{339 .{ .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." },340 .{ .para = "available hook files:" },341 .{ .cmd_block = "pre-apply post-apply\npre-back post-back\npre-home post-home\npre-upgrade post-upgrade" },342 .{ .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." },343 .{ .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." },344 .{ .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" },345 .{ .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." },346 .{ .para = "example: stop `nina apply` when your nixos config has uncommitted changes." },347 .{ .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" },348 .{ .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." },349 },350 },351 .{352 .id = "configuration",353 .title = "configuration",354 .accent = .gold,355 .blocks = &[_]Block{356 .{ .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. (´ω`)" },357 .{358 .callout = .{359 .face = "(っ˘ω˘ς)",360 .color = .gold,361 .body = "if you have an existing ~/.nina.conf from an earlier version, nina migrates it to ~/.config/nina/config automatically on first run.",362 },363 },364 .{ .para = "full example:" },365 .{ .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" },366 .{ .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." },367 .{ .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`." },368 },369 },370 .{371 .id = "expressions",372 .title = "expressions ♡",373 .accent = .pink,374 .blocks = &[_]Block{375 .{ .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." },376 .{ .para = "the rule is simple: calm most of the time, brighter when something lands, softer when something goes wrong." },377 .{378 .kaomoji_card = .{379 .face = "(˶ᵔ ᵕ ᵔ˶)",380 .name = "hello",381 .color = .lav,382 .desc = "a warm, quiet welcome. nina is ready to help and not making a fuss about it.",383 .when = "setup wizard, first-run greeting",384 },385 },386 .{387 .kaomoji_card = .{388 .face = "(っ˘ω˘ς)",389 .name = "starting",390 .color = .mint,391 .desc = "settling in and getting to work. something meaningful is beginning.",392 .when = "apply start, home apply start, entering a dev shell",393 },394 },395 .{396 .kaomoji_card = .{397 .face = "╰(*°▽°*)╯",398 .name = "relief",399 .color = .gold,400 .desc = "something that was broken is working again. a small celebration for persistence.",401 .when = "first success after a previously failed build",402 },403 },404 .{405 .kaomoji_card = .{406 .face = "( ˘ᵕ˘ )",407 .name = "done",408 .color = .lav,409 .desc = "calm satisfaction. a rollback landed cleanly and the system is in good hands.",410 .when = "back success",411 },412 },413 .{414 .kaomoji_card = .{415 .face = "( ´ ∀ ` )",416 .name = "clean",417 .color = .mint,418 .desc = "light and tidy. the work is done and things look better than before.",419 .when = "clean done",420 },421 },422 .{423 .kaomoji_card = .{424 .face = "( ´ ▽ ` )",425 .name = "all good",426 .color = .gold,427 .desc = "everything checked out. a quiet joy that the system is healthy.",428 .when = "doctor all clear, mood all good",429 },430 },431 .{432 .kaomoji_card = .{433 .face = "(˘ω˘ )",434 .name = "exiting",435 .color = .lav,436 .desc = "a gentle close. nothing went wrong — it's just time to step out.",437 .when = "try exit, leaving a dev shell",438 },439 },440 .{441 .kaomoji_card = .{442 .face = "(;ω;)",443 .name = "sorry",444 .color = .pink,445 .desc = "something went sideways, and nina is honest about it. details are right there with her.",446 .when = "network or SSH errors, search timeout",447 },448 },449 .{450 .callout = .{451 .face = "( ´•̥̥̥ω•̥̥̥` )",452 .color = .pink,453 .body = "if you prefer a quieter experience, set `color = false` in your config. nina will still show everything — just without the ansi palette.",454 },455 },456 },457 },458 .{459 .id = "tips",460 .title = "tips & recipes",461 .accent = .mint,462 .blocks = &[_]Block{463 .{ .para = "a few patterns that make daily nixos life a little smoother. (*˘︶˘*)" },464 .{ .para = "a quiet morning with nina:" },465 .{ .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" },466 .{ .para = "try a package without committing to it:" },467 .{ .cmd_block = "$ nina try bat\n:: trying bat exit when done\n → bat is available in this shell only, gone when you exit" },468 .{ .para = "push the same config change to multiple machines:" },469 .{ .cmd_block = "$ nina apply --on kyoshi\n:: generation 349 [4.1s]\n$ nina apply --on azula\n:: generation 193 [6.3s]" },470 .{ .para = "something broke and you want to find when:" },471 .{ .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" },472 .{ .para = "useful shell aliases:" },473 .{ .cmd_block = "alias ns=\"nina search\"\nalias no=\"nina option\"\nalias na=\"nina apply\"\nalias nd=\"nina diff\"\nalias nb=\"nina back\"" },474 .{475 .callout = .{476 .face = "(≧◡≦)",477 .color = .mint,478 .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.",479 },480 },481 },482 },483};484485486487const PAGE_ROWS: u16 = 22; 488const COL_WIDTH: u16 = 96; 489490pub fn run(io: std.Io, topic: []const u8) !void {491 492 493 if (!(std.Io.File.stdout().isTty(io) catch false)) {494 return runPlain(io, topic);495 }496497 498 var saved: std.posix.termios = undefined;499 try rawModeEnable(STDIN_FD, &saved);500 defer rawModeDisable(STDIN_FD, saved);501502 const start_section: usize = if (topic.len == 0) 0 else blk: {503 for (MAN_PAGE, 0..) |sec, i| {504 if (std.mem.eql(u8, sec.id, topic)) break :blk i;505 }506 output.p("{s}nina man: no such topic: {s}{s}\n", .{ c(PINK), topic, c(RESET) });507 output.flush();508 return;509 };510511 512 513 514 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);515 defer arena.deinit();516 const allocator = arena.allocator();517518 var rendered = std.ArrayList([]const u8).empty;519 defer rendered.deinit(allocator);520521 522 523 var section_starts = std.ArrayList(usize).empty;524 defer section_starts.deinit(allocator);525 var section_titles = std.ArrayList([]const u8).empty;526 defer section_titles.deinit(allocator);527528 try section_starts.append(allocator, 0);529 for (MAN_PAGE) |sec| {530 try section_titles.append(allocator, sec.title);531 try renderSection(allocator, &rendered, sec);532 try section_starts.append(allocator, rendered.items.len);533 }534 const total: usize = rendered.items.len;535536 var top: usize = 0;537 var cur_section: usize = start_section;538 top = section_starts.items[start_section];539540 drawHeader();541 drawBody(rendered.items, top, total, section_titles.items, cur_section);542 drawFooter(cur_section, MAN_PAGE.len, top, total);543 output.flush();544545 var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};546 while (true) {547 _ = std.posix.poll(&pfd, -1) catch break;548549 var buf: [8]u8 = undefined;550 const n = std.posix.read(STDIN_FD, &buf) catch break;551 if (n == 0) break;552553 const advance_section = struct {554 fn f(starts: []const usize, cur: usize, delta: i32) usize {555 var idx: i32 = @intCast(cur);556 idx += delta;557 if (idx < 0) idx = 0;558 if (idx >= @as(i32, @intCast(starts.len - 1))) idx = @intCast(starts.len - 2);559 return starts[@intCast(idx)];560 }561 }.f;562563 564 const sectionOf = struct {565 fn f(starts: []usize, t: usize) usize {566 var s: usize = 0;567 for (starts[1..], 1..) |start, i| {568 if (t < start) return i - 1;569 s = i;570 }571 return s;572 }573 }.f;574575 var redraw = true;576 if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {577 switch (buf[2]) {578 'A' => top = if (top > 0) top - 1 else 0, 579 'B' => top = if (top + 1 < total) top + 1 else top, 580 'C' => top = advance_section(section_starts.items, cur_section, 1), 581 'D' => top = advance_section(section_starts.items, cur_section, -1), 582 '5' => top = if (top >= PAGE_ROWS) top - PAGE_ROWS else 0, 583 '6' => top = if (top + PAGE_ROWS < total) top + PAGE_ROWS else total - 1, 584 'H' => top = 0, 585 'F' => top = if (total > 0) total - 1 else 0, 586 else => redraw = false,587 }588 } else switch (buf[0]) {589 'q', 'Q', 0x1b => break, 590 'j' => top = if (top + 1 < total) top + 1 else top,591 'k' => top = if (top > 0) top - 1 else 0,592 ' ' => top = if (top + PAGE_ROWS < total) top + PAGE_ROWS else total - 1,593 'b' => top = if (top >= PAGE_ROWS) top - PAGE_ROWS else 0,594 'g' => top = 0,595 'G' => top = if (total > 0) total - 1 else 0,596 'n' => top = advance_section(section_starts.items, cur_section, 1),597 'p' => top = advance_section(section_starts.items, cur_section, -1),598 'h' => top = 0,599 '?' => {600 showHelp();601 redraw = false;602 },603 else => redraw = false,604 }605606 if (redraw) {607 cur_section = sectionOf(section_starts.items, top);608 output.cursorUp(PAGE_ROWS + 2);609 drawBody(rendered.items, top, total, section_titles.items, cur_section);610 drawFooter(cur_section, MAN_PAGE.len, top, total);611 output.flush();612 }613 }614615 616 617 output.cursorUp(PAGE_ROWS + 2);618 for (0..PAGE_ROWS + 2) |_| {619 output.raw("\x1b[K\n");620 }621 output.cursorUp(PAGE_ROWS + 2);622 output.flush();623}624625fn runPlain(_: std.Io, topic: []const u8) !void {626 const start_section: usize = if (topic.len == 0) 0 else blk: {627 for (MAN_PAGE, 0..) |sec, i| {628 if (std.mem.eql(u8, sec.id, topic)) break :blk i;629 }630 output.p("{s}nina man: no such topic: {s}{s}\n", .{ c(PINK), topic, c(RESET) });631 output.flush();632 return;633 };634635 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);636 defer arena.deinit();637 const allocator = arena.allocator();638639 var rendered = std.ArrayList([]const u8).empty;640 defer rendered.deinit(allocator);641642 var i: usize = start_section;643 while (i < MAN_PAGE.len) : (i += 1) {644 try renderSection(allocator, &rendered, MAN_PAGE[i]);645 }646 for (rendered.items) |line| output.raw(line);647 output.raw("\n");648 output.flush();649}650651const STDIN_FD = std.posix.STDIN_FILENO;652653fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {654 var tio = try std.posix.tcgetattr(fd);655 saved.* = tio;656 if (comptime builtin.os.tag == .linux) {657 tio.lflag.ECHO = false;658 tio.lflag.ICANON = false;659 tio.lflag.ISIG = false;660 tio.lflag.IEXTEN = false;661 tio.iflag.ICRNL = false;662 tio.iflag.IXON = false;663 tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;664 tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;665 }666 try std.posix.tcsetattr(fd, .FLUSH, tio);667}668669fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {670 std.posix.tcsetattr(fd, .FLUSH, saved) catch {};671}672673674675const AccentColor = struct {676 fg: []const u8,677 bar: []const u8,678};679680fn accentColors(a: Block.Accent) AccentColor {681 return switch (a) {682 .gold => .{ .fg = GOLD, .bar = GOLD },683 .mint => .{ .fg = MINT, .bar = MINT },684 .pink => .{ .fg = PINK, .bar = PINK },685 .lav => .{ .fg = LAV, .bar = LAV },686 };687}688689fn renderSection(allocator: std.mem.Allocator, out: *std.ArrayList([]const u8), sec: Section) !void {690 691 var idx_buf: [16]u8 = undefined;692 const idx_str = std.fmt.bufPrint(&idx_buf, "{d:0>2}", .{sectionIndexOf(sec.id) + 1}) catch "00";693694 const accent = accentColors(sec.accent);695 const divider = try std.fmt.allocPrint(allocator, "{s}── {s}{s}{s} {s}{s}{s}\n", .{696 c(TEXT_DIM),697 c(accent.fg), sec.title, c(RESET),698 c(TEXT_DIM), idx_str, c(RESET),699 });700 try out.append(allocator, divider);701702 for (sec.blocks) |block| {703 try renderBlock(allocator, out, block);704 }705706 707 try out.append(allocator, "\n");708}709710fn sectionIndexOf(id: []const u8) usize {711 for (MAN_PAGE, 0..) |sec, i| {712 if (std.mem.eql(u8, sec.id, id)) return i;713 }714 return 0;715}716717fn renderBlock(allocator: std.mem.Allocator, out: *std.ArrayList([]const u8), block: Block) !void {718 switch (block) {719 .chapter => try out.append(allocator, try std.fmt.allocPrint(allocator, "\n{s}{s} {s}{s}\n\n", .{ c(TEXT_DIM), block.chapter, c(RESET), "" })),720 .title => {721 const accent = accentColors(block.accent);722 const padded_title = try std.fmt.allocPrint(allocator, " {s}◆{s} {s}{s}{s} ", .{ c(accent.bar), c(RESET), c(BOLD), block.title, c(RESET) });723 try out.append(allocator, try std.fmt.allocPrint(allocator, "\n{s}{s}\n", .{ padded_title, "" }));724 if (block.badge.len > 0) {725 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) }));726 }727 },728 .badge => {}, 729 .accent => {}, 730 .para => {731 const wrapped = try wrap(allocator, block.para, COL_WIDTH);732 for (wrapped) |line| {733 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}{s}{s}\n", .{ c(TEXT), line, c(RESET) }));734 }735 },736 .cmd_sig => {737 const accent = accentColors(block.accent);738 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) }));739 },740 .cmd_block => {741 const wrapped = try wrap(allocator, block.cmd_block, COL_WIDTH);742 for (wrapped) |line| {743 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}{s}{s}\n", .{ c(MINT), line, c(RESET) }));744 }745 try out.append(allocator, "\n");746 },747 .flags_row => {748 const accent = accentColors(.gold);749 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) }));750 },751 .callout => {752 const accent = accentColors(block.callout.color);753 const wrapped = try wrap(allocator, block.callout.body, COL_WIDTH - 6);754 var first = true;755 for (wrapped) |line| {756 if (first) {757 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) }));758 first = false;759 } else {760 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) }));761 }762 }763 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}└─{s}\n", .{ c(accent.bar), c(RESET) }));764 },765 .kaomoji_card => {766 const accent = accentColors(block.kaomoji_card.color);767 768 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) }));769 const desc_lines = try wrap(allocator, block.kaomoji_card.desc, COL_WIDTH - 10);770 for (desc_lines) |line| {771 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) }));772 }773 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) }));774 try out.append(allocator, try std.fmt.allocPrint(allocator, " {s}└──{s}\n", .{ c(accent.bar), c(RESET) }));775 },776 .blank => try out.append(allocator, "\n"),777 }778}779780781fn wrap(allocator: std.mem.Allocator, text: []const u8, width: u16) ![]const []const u8 {782 var lines = std.ArrayList([]const u8).empty;783 errdefer lines.deinit(allocator);784785 var it = std.mem.splitScalar(u8, text, '\n');786 while (it.next()) |para| {787 if (para.len == 0) {788 try lines.append(allocator, "");789 continue;790 }791 var start: usize = 0;792 while (start < para.len) {793 var end = @min(start + @as(usize, width), para.len);794 if (end < para.len) {795 var back: usize = end;796 while (back > start and para[back - 1] != ' ') : (back -= 1) {}797 if (back > start) end = back;798 }799 try lines.append(allocator, std.mem.trim(u8, para[start..end], " \t"));800 start = end;801 while (start < para.len and para[start] == ' ') : (start += 1) {}802 }803 }804 return lines.toOwnedSlice(allocator);805}806807808809fn drawHeader() void {810 811 output.raw("\x1b[K");812 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) });813 output.raw("\x1b[K");814 output.p("{s} {s}(˶ᵔ ᵕ ᵔ˶){s} ready when you are{s}\n", .{ c(BG_HI), c(PINK), c(RESET), c(RESET) });815 output.raw("\x1b[K");816 output.p("{s}{s}{s}\n", .{ c(TEXT_DIM), "─" ** 80, c(RESET) });817}818819fn drawBody(lines: []const []const u8, top: usize, total: usize, titles: []const []const u8, cur_section: usize) void {820 _ = total;821 _ = titles;822 var i: usize = 0;823 while (i < PAGE_ROWS) : (i += 1) {824 output.raw("\x1b[K");825 const idx = top + i;826 if (idx < lines.len) {827 const line = lines[idx];828 const is_chapter = line.len > 0 and std.mem.indexOf(u8, line, "── ") != null and std.mem.indexOf(u8, line, "◆") == null;829 if (is_chapter and i > 0) {830 831 output.raw("");832 } else {833 output.raw(line);834 }835 } else {836 output.raw("\n");837 }838 _ = cur_section;839 }840}841842fn drawFooter(cur_section: usize, total_sections: usize, top: usize, total: usize) void {843 const pct: usize = if (total == 0) 0 else (top * 100) / total;844 output.raw("\x1b[K");845 var sec_buf: [16]u8 = undefined;846 const sec_str = std.fmt.bufPrint(&sec_buf, "{d}", .{cur_section + 1}) catch "?";847 var tot_buf: [16]u8 = undefined;848 const tot_str = std.fmt.bufPrint(&tot_buf, "{d}", .{total_sections}) catch "?";849 var pct_buf: [16]u8 = undefined;850 const pct_str = std.fmt.bufPrint(&pct_buf, "{d}", .{pct}) catch "?";851 output.p("{s}{s}─── {s}{s:>2}/{s}{s} {s}{s:>3}%{s} {s}· ↑↓ scroll · n/p next/prev section · q quit{s}\n", .{852 c(TEXT_DIM), c(BG_HI),853 c(MINT), sec_str, tot_str, c(RESET),854 c(GOLD), pct_str, c(RESET),855 c(TEXT_DIM), c(RESET),856 });857}858859fn showHelp() void {860 output.cursorUp(PAGE_ROWS + 2);861 for (0..PAGE_ROWS + 2) |_| output.raw("\x1b[K\n");862 output.cursorUp(PAGE_ROWS + 2);863 drawHeader();864 drawHelpBody();865 drawFooter(0, MAN_PAGE.len, 0, 1);866 output.flush();867868 869 var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};870 _ = std.posix.poll(&pfd, -1) catch {};871 var buf: [8]u8 = undefined;872 _ = std.posix.read(STDIN_FD, &buf) catch {};873}874875fn drawHelpBody() void {876 const pairs = [_]struct { key: []const u8, desc: []const u8 }{877 .{ .key = "j / ↓", .desc = "scroll down one line" },878 .{ .key = "k / ↑", .desc = "scroll up one line" },879 .{ .key = "space", .desc = "page down" },880 .{ .key = "b / pgup", .desc = "page up" },881 .{ .key = "g / home", .desc = "jump to top" },882 .{ .key = "G / end", .desc = "jump to bottom" },883 .{ .key = "n / →", .desc = "next section" },884 .{ .key = "p / ←", .desc = "previous section" },885 .{ .key = "h", .desc = "back to top (table of contents)" },886 .{ .key = "?", .desc = "this help" },887 .{ .key = "q / esc", .desc = "leave the manpage" },888 };889 var i: usize = 0;890 while (i < PAGE_ROWS) : (i += 1) {891 output.raw("\x1b[K");892 if (i == 0) {893 output.p(" {s}{s}nina man · keys{s}\n\n", .{ c(BOLD), c(PINK), c(RESET) });894 } else if (i - 1 < pairs.len) {895 const p = pairs[i - 1];896 output.p(" {s}{s:<14}{s} {s}{s}{s}\n", .{ c(GOLD), p.key, c(RESET), c(TEXT), p.desc, c(RESET) });897 } else {898 output.raw("\n");899 }900 }901}