6554688b6256
colour koh man and page it in-process like Koh 2: blue ◆ headings, amber commands and flags, Tab jumps between sections (3.0.70)
- time
- 2026-09-26 13:45 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- koh man
- parent
- d93d3d534fe8
↓ koh-6554688b.tar.gzatlas at this save
files changed9 files
VERSION+1 −1src/cli/cmd_man.v+57 −19src/fmt/fmt.v+10 −1src/view/manual.v+119 −0src/view/term.v+7 −1src/view/term_test.v+1 −1src/view/view_test.v+23 −0tests/cli_test.v+1 −0v.mod+1 −1src/cli/cmd_man.v+57 −19
@@ -3,12 +3,13 @@33 //! The page lives in `docs/koh3-man.txt` and is embedded at build time, so a44 //! `koh` copied to another machine still carries its own documentation. With a55 //! command name it prints just that command's part; otherwise the whole page,6-//! through the pager on a terminal.6+//! through the built-in pager on a terminal.77 88 module cli99 1010 import os1111 import src.fmt12+import src.view1213 1314 const manual = $embed_file('../../docs/koh3-man.txt').to_string()1415 @@ -55,26 +56,63 @@5556 return lines[start..end].join('\n')5657 }5758 58-// page hands the text to the pager on a terminal, and prints it plainly when59-// the output is going somewhere else. The pager gets a temporary file because60-// that is the one interface every pager understands.59+// page shows the manual in Koh 2's colours, through the built-in pager when it60+// is longer than the terminal, and plainly when the output is going somewhere61+// else.6162 fn page(text string) {62- pager := os.getenv('PAGER')63- if os.is_atty(1) != 0 && pager != '' {64- path := os.join_path(os.temp_dir(), 'koh-man-${os.getpid()}.txt')65- os.write_file(path, text) or {66- print(text)67- return68- }69- // `system`, not `execute`: the pager must write to the terminal, not70- // into a captured string. $PAGER itself may carry flags, so only the71- // path is quoted.72- os.system('${pager} ${os.quoted_path(path)}')73- os.rm(path) or {}63+ lines := text.trim_right('\n').split_into_lines().map(manual_line(it))64+ rows, _ := view.terminal_size()65+ if os.is_atty(1) != 0 && os.is_atty(0) != 0 && lines.len >= rows {66+ view.page_manual(lines)7467 return7568 }76- print(text)77- if !text.ends_with('\n') {78- println('')69+ for line in lines {70+ println(line.text)7971 }72+}73+74+// manual_line colours one line as Koh 2's `colorManLine` does: headings blue75+// behind a ◆, command lines amber, lead-ins ending in a colon orange, and body76+// text light with its `--flags` picked out.77+fn manual_line(line string) view.ManualLine {78+ trimmed := line.trim_left(' ')79+ if line != '' && !line.starts_with(' ') && line == line.to_upper() {80+ return view.ManualLine{81+ text: if fmt.colour_enabled() { fmt.blue('◆ ${line}') } else { line }82+ section: line83+ }84+ }85+ if trimmed.starts_with('koh ') {86+ return view.ManualLine{87+ text: fmt.gold(line)88+ }89+ }90+ if trimmed != line && trimmed.ends_with(':') {91+ return view.ManualLine{92+ text: fmt.warning(line)93+ }94+ }95+ return view.ManualLine{96+ text: line[..line.len - trimmed.len] + body_text(trimmed)97+ }98+}99+100+// body_text paints body text light, with each `--flag` in it amber.101+fn body_text(text string) string {102+ mut out := ''103+ mut rest := text104+ for {105+ at := rest.index('--') or { break }106+ mut end := at + 2107+ for end < rest.len && rest[end] !in [` `, `,`, `)`, `.`, `\``] {108+ end++109+ }110+ if end == at + 2 {111+ out += fmt.light(rest[..end])112+ } else {113+ out += fmt.light(rest[..at]) + fmt.gold(rest[at..end])114+ }115+ rest = rest[end..]116+ }117+ return out + fmt.light(rest)80118 }src/fmt/fmt.v+10 −1
@@ -20,7 +20,7 @@2020 }2121 2222 fn paint(text string, code string) string {23- if !colour_enabled() {23+ if text == '' || !colour_enabled() {2424 return text2525 }2626 return '\x1b[${code}m${text}\x1b[0m'@@ -62,6 +62,15 @@6262 6363 pub fn blue(text string) string {6464 return paint(text, '38;5;110')65+}66+67+// The manual keeps the rest of Koh 2's man-page palette.68+pub fn light(text string) string {69+ return paint(text, '38;5;223')70+}71+72+pub fn warning(text string) string {73+ return paint(text, '38;5;173')6574 }6675 6776 // strip_ansi removes the colour codes, for measuring how wide a line really is.src/view/manual.v+119 −0
@@ -0,0 +1,119 @@1+//! The manual's pager, Koh 2's `runMan` (`src/pager.zig`): a page at a time,2+//! Tab and Shift-Tab jump between sections, and the status bar names the3+//! section at the top of the page.4+5+module view6+7+import fmt8+9+// ManualLine is one already-coloured line of the manual. `section` is the10+// heading's name on a section heading, and empty everywhere else.11+pub struct ManualLine {12+pub:13+ text string14+ section string15+}16+17+// page_manual shows the lines until the user quits.18+pub fn page_manual(lines []ManualLine) {19+ enter_screen()20+ defer {21+ leave_screen()22+ }23+ mut page := 024+ for {25+ rows, cols := terminal_size()26+ size := if rows > 2 { rows - 1 } else { 1 }27+ last := last_page(lines.len, size)28+ if page > last {29+ page = last30+ }31+ paint(render(manual_frame(lines, page, size, cols)))32+ key := read_key()33+ match key.kind {34+ .interrupt, .eof, .escape, .enter {35+ return36+ }37+ .down, .page_down {38+ page = if page < last { page + 1 } else { page }39+ }40+ .up, .page_up {41+ page = if page > 0 { page - 1 } else { 0 }42+ }43+ .home {44+ page = 045+ }46+ .end {47+ page = last48+ }49+ .tab {50+ page = section_page(lines, page, size, true)51+ }52+ .backtab {53+ page = section_page(lines, page, size, false)54+ }55+ .rune {56+ match key.text {57+ 'q' { return }58+ 'j', ' ' { page = if page < last { page + 1 } else { page } }59+ 'k' { page = if page > 0 { page - 1 } else { 0 } }60+ 'g' { page = 0 }61+ 'G' { page = last }62+ else {}63+ }64+ }65+ else {}66+ }67+ }68+}69+70+fn last_page(count int, size int) int {71+ return if count == 0 { 0 } else { (count - 1) / size }72+}73+74+// manual_frame is one page padded to the screen's height, then the status bar.75+pub fn manual_frame(lines []ManualLine, page int, size int, cols int) []string {76+ start := page * size77+ end := if start + size < lines.len { start + size } else { lines.len }78+ mut frame := []string{}79+ for line in lines[start..end] {80+ frame << fmt.fit(line.text, cols)81+ }82+ for frame.len < size {83+ frame << ''84+ }85+ bar := '${fmt.blue(section_at(lines, start))} · ' +86+ fmt.muted('page ${page + 1} of ${last_page(lines.len, size) + 1} · tab: next section · ↑↓ j/k · q quit')87+ frame << fmt.fit(bar, cols)88+ return frame89+}90+91+// section_at names the section a line belongs to: the nearest heading at or92+// above it, as in Koh 2, which counts a heading on the top line as current.93+fn section_at(lines []ManualLine, index int) string {94+ for i := index; i >= 0; i-- {95+ if i < lines.len && lines[i].section != '' {96+ return lines[i].section97+ }98+ }99+ return 'KOH'100+}101+102+// section_page is the page holding the next heading after this page, or the103+// nearest one above its top line; the page stays put when there is none.104+pub fn section_page(lines []ManualLine, page int, size int, forward bool) int {105+ if forward {106+ for i := (page + 1) * size; i < lines.len; i++ {107+ if lines[i].section != '' {108+ return i / size109+ }110+ }111+ return page112+ }113+ for i := page * size - 1; i >= 0; i-- {114+ if lines[i].section != '' {115+ return i / size116+ }117+ }118+ return page119+}src/view/term.v+7 −1
@@ -27,6 +27,7 @@2727 enter2828 escape2929 tab30+ backtab3031 backspace3132 delete3233 home@@ -167,7 +168,12 @@167168 kind: .page_down168169 }169170 }170- // Shift-tab, function keys, focus reports: not Esc, which would171+ '[Z' {172+ Key{173+ kind: .backtab174+ }175+ }176+ // Function keys, focus reports: not Esc, which would171177 // leave the view.172178 else {173179 Key{src/view/term_test.v+1 −1
@@ -31,7 +31,7 @@3131 // Now that Esc leaves the view, only a lone Esc may count as one: shift-tab,3232 // function keys and focus reports must not close it.3333 fn test_unknown_escape_sequences_are_not_esc() {34- assert decode([u8(0x1b), u8(`[`), u8(`Z`)]).kind == .unknown34+ assert decode([u8(0x1b), u8(`[`), u8(`Z`)]).kind == .backtab3535 assert decode([u8(0x1b), u8(`O`), u8(`P`)]).kind == .unknown3636 assert decode([u8(0x1b), u8(`[`), u8(`I`)]).kind == .unknown3737 assert decode([u8(0x1b), u8(`x`)]).kind == .unknownsrc/view/view_test.v+23 −0
@@ -343,3 +343,26 @@343343 handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)344344 assert session.mode == .list345345 }346+347+fn test_manual_pages_jump_between_sections_and_name_the_one_on_top() {348+ mut lines := []ManualLine{len: 10, init: ManualLine{349+ text: 'body'350+ }}351+ lines[2] = ManualLine{352+ text: 'NAME'353+ section: 'NAME'354+ }355+ lines[7] = ManualLine{356+ text: 'FILES'357+ section: 'FILES'358+ }359+ // Pages of three: [0,1,2] [3,4,5] [6,7,8] [9].360+ assert section_page(lines, 0, 3, true) == 2361+ assert section_page(lines, 2, 3, true) == 2362+ assert section_page(lines, 3, 3, false) == 2363+ assert section_page(lines, 2, 3, false) == 0364+ frame := manual_frame(lines, 3, 3, 80)365+ assert frame.len == 4366+ assert frame[0] == 'body' && frame[1] == ''367+ assert fmt.strip_ansi(frame[3]).starts_with('FILES · page 4 of 4')368+}tests/cli_test.v+1 −0
@@ -892,6 +892,7 @@892892 assert whole.code == 0893893 assert whole.out.contains('NAME')894894 assert whole.out.contains('COMMANDS')895+ assert !whole.out.contains('\x1b') && !whole.out.contains('◆')895896 strip := run_home(dir, home, 'man', 'strip')896897 assert strip.code == 0897898 assert strip.out.contains('strip')v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.69'3+ version: '3.0.70'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }