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
9 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.691+3.0.70

view file

src/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 }

view file

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.

view file

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+}

view file

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{

view file

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 == .unknown

view file

src/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+}

view file

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')

view file

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 }

view file