rv-bible-cli 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -134,7 +134,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
134
134
 
135
135
  Deep-dive into footnotes and cross-references. Press `d` while reading any chapter.
136
136
 
137
- - A cursor highlights the current verse
137
+ - A cursor highlights the current verse — the chapter view auto-scrolls to keep it vertically centered as you move
138
138
  - The bottom panel shows the **full** footnote text for that verse — header line with marker + word + numbered cross-references, then the wrapped commentary body underneath
139
139
  - **Type a number** to follow a cross-reference — you'll jump to that chapter and land on the exact verse
140
140
  - Press `[` to go back to where you were
package/dist/format.d.ts CHANGED
@@ -8,6 +8,7 @@ export declare function getZoom(): number;
8
8
  export declare function zoomIn(): void;
9
9
  export declare function zoomOut(): void;
10
10
  export declare function zoomReset(): void;
11
+ export declare function getMargin(): number;
11
12
  export declare function wrapWords(text: string, maxWidth: number, ansi?: boolean): string[];
12
13
  export declare function renderFootnoteBlock(footnotes: Footnote[]): string;
13
14
  export declare function renderNoteDisplayAll(verse: Verse, footnotes: Footnote[], bookName: string): string;
package/dist/format.js CHANGED
@@ -109,6 +109,9 @@ function contentWidth() {
109
109
  function margin() {
110
110
  return ' '.repeat(ZOOM_LEVELS[currentZoom].margin);
111
111
  }
112
+ export function getMargin() {
113
+ return ZOOM_LEVELS[currentZoom].margin;
114
+ }
112
115
  // ── Visible-length helper ────────────────────────────────────────────────────
113
116
  // Strip ANSI escape codes before measuring — needed for word-wrap width checks
114
117
  // on text that may contain chalk styling (e.g. highlighted markers in notes mode).
package/dist/index.js CHANGED
@@ -397,9 +397,7 @@ program
397
397
  const bookName = getBookInfo(v.book)?.full_name ?? v.book;
398
398
  const ref = `${bookName} ${v.chapter}:${v.verse}`;
399
399
  const cleanText = stripMarkers(v.text);
400
- // Truncate to ~150 chars for scanability
401
- const truncated = cleanText.length > 150 ? cleanText.substring(0, 147) + '...' : cleanText;
402
- const highlighted = highlightTerms(truncated, terms);
400
+ const highlighted = highlightTerms(cleanText, terms);
403
401
  // Wrap the text at ~76 chars with 4-space indent
404
402
  const maxW = Math.min((process.stdout.columns ?? 80) - 4, 76);
405
403
  const words = highlighted.split(' ');
package/dist/ui/Pager.js CHANGED
@@ -4,7 +4,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
4
4
  import chalk from 'chalk';
5
5
  import clipboard from 'clipboardy';
6
6
  import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, } from '../db.js';
7
- import { renderVerses, stripMarkers, highlightTerms, toSuperscript, wrapWords, zoomIn, zoomOut, zoomReset, getZoom } from '../format.js';
7
+ import { renderVerses, stripMarkers, highlightTerms, toSuperscript, wrapWords, zoomIn, zoomOut, zoomReset, getZoom, getMargin } from '../format.js';
8
8
  import { saveLastRead, getLastRead } from '../state.js';
9
9
  import { resolveBook } from '../parser.js';
10
10
  import { getNextChapter, getPrevChapter } from './nav.js';
@@ -12,6 +12,20 @@ import { getNextChapter, getPrevChapter } from './nav.js';
12
12
  function stripAnsi(s) {
13
13
  return s.replace(/\x1b\[[0-9;]*m/g, '');
14
14
  }
15
+ // Footer keybind helpers: magenta for the key, dim for the description,
16
+ // dim middle dot as separator. Keeps "this is a key you can press" visually
17
+ // distinct from the prose around it. When `/` separates two keys in a
18
+ // compound (e.g. `[/]`, `g/G`, `Tab/⇧Tab`), the slash itself drops to dim so
19
+ // the keys stand out. A lone `/` (e.g. the find command) stays magenta.
20
+ function kb(key, desc) {
21
+ const parts = key.split('/');
22
+ const isCompound = parts.length > 1 && parts.every(p => p.length > 0);
23
+ const styledKey = isCompound
24
+ ? parts.map(k => chalk.magenta(k)).join(chalk.dim('/'))
25
+ : chalk.magenta(key);
26
+ return `${styledKey} ${chalk.dim(desc)}`;
27
+ }
28
+ const FOOTER_SEP = chalk.dim(' · ');
15
29
  // Detect verse-number lines by checking for chalk.dim() ANSI code (\x1b[2m) before
16
30
  // a superscript character. This distinguishes verse numbers (dim) from inline footnote
17
31
  // markers (cyan) that could appear at the start of word-wrapped continuation lines.
@@ -277,31 +291,42 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
277
291
  useEffect(() => {
278
292
  setPanelScrollOffset(0);
279
293
  }, [studyCursorIdx, book, chapter, mode]);
280
- // Clamp scroll when content or viewport changes
294
+ // Clamp scroll when content or viewport changes. Normal mode uses the strict
295
+ // maxScroll clamp (can't scroll past content). Study mode allows overscroll so
296
+ // the last verse can still center — the visible slice is padded with empty rows
297
+ // when scrollOffset > maxScroll, keeping the panel anchored to the bottom.
281
298
  const maxScroll = Math.max(0, lines.length - viewportH);
299
+ const overscrollLimit = Math.max(0, lines.length - 1);
282
300
  useEffect(() => {
283
- setScrollOffset(prev => Math.min(prev, maxScroll));
284
- }, [maxScroll]);
285
- // Keep the study-mode cursor verse fully visible above the footnote panel.
286
- // Triggers when cursor moves OR viewport shrinks (panel grew with longer notes).
301
+ if (mode !== 'study') {
302
+ setScrollOffset(prev => Math.min(prev, maxScroll));
303
+ }
304
+ else {
305
+ setScrollOffset(prev => Math.min(prev, overscrollLimit));
306
+ }
307
+ }, [maxScroll, overscrollLimit, mode]);
308
+ // Center the cursor verse in study mode. If the verse fits in the viewport,
309
+ // place its midpoint at viewportH/2. If the verse is taller than the viewport
310
+ // (rare — long verse with markers wrapping), pin its start to the top so the
311
+ // beginning is always visible. Triggers on cursor move and viewport resize.
287
312
  useEffect(() => {
288
313
  if (mode !== 'study' || verseMap.length === 0)
289
314
  return;
290
315
  const entry = verseMap[studyCursorIdx];
291
316
  if (!entry)
292
317
  return;
293
- setScrollOffset(prev => {
294
- // Cursor verse extends below the visible viewport — pull it in from the bottom
295
- if (entry.endLine >= prev + viewportH) {
296
- return Math.max(0, entry.endLine - viewportH + 1);
297
- }
298
- // Cursor verse starts above the visible viewport — pull it in from the top
299
- if (entry.startLine < prev) {
300
- return entry.startLine;
301
- }
302
- return prev;
303
- });
304
- }, [studyCursorIdx, viewportH, mode, verseMap]);
318
+ const verseH = entry.endLine - entry.startLine + 1;
319
+ let target;
320
+ if (verseH >= viewportH) {
321
+ target = entry.startLine;
322
+ }
323
+ else {
324
+ const verseMid = entry.startLine + Math.floor(verseH / 2);
325
+ target = verseMid - Math.floor(viewportH / 2);
326
+ }
327
+ target = Math.max(0, Math.min(target, overscrollLimit));
328
+ setScrollOffset(target);
329
+ }, [studyCursorIdx, viewportH, mode, verseMap, overscrollLimit]);
305
330
  // ── Search highlights ────────────────────────────────────────────────────
306
331
  const displayLines = useMemo(() => {
307
332
  if (!searchSubmitted || searchMatches.length === 0)
@@ -1003,18 +1028,34 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1003
1028
  const homeContent = homeLines.join('\n');
1004
1029
  let homeFooter;
1005
1030
  if (mode === 'home-chapter') {
1006
- homeFooter = ` ${chalk.dim('←/→ or type number · Enter go · Esc back')}`;
1031
+ homeFooter = ` ${[
1032
+ kb('←/→', 'ch'),
1033
+ kb('0-9', 'jump'),
1034
+ kb('Enter', 'go'),
1035
+ kb('Esc', 'back'),
1036
+ ].join(FOOTER_SEP)}`;
1007
1037
  }
1008
1038
  else {
1009
1039
  const lastRead = getLastRead();
1010
- const continueHint = lastRead ? 'Space continue · ' : '';
1011
- homeFooter = ` ${chalk.dim(`↑↓←→ navigate · Enter select · ${continueHint}type letter to jump · q quit`)}`;
1040
+ const parts = [
1041
+ kb('↑↓←→', 'navigate'),
1042
+ kb('Enter', 'select'),
1043
+ ];
1044
+ if (lastRead)
1045
+ parts.push(kb('Space', 'continue'));
1046
+ parts.push(kb('a-z', 'jump'));
1047
+ parts.push(kb('q', 'quit'));
1048
+ homeFooter = ` ${parts.join(FOOTER_SEP)}`;
1012
1049
  }
1013
1050
  const homeSep = chalk.dim('─'.repeat(cols));
1014
1051
  return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { children: chalk.bold(' rv') }), _jsx(Text, { children: homeSep }), _jsx(Box, { flexGrow: 1, overflow: "hidden", children: _jsx(Text, { children: homeContent }) }), _jsx(Text, { children: homeSep }), _jsx(Text, { children: homeFooter })] }));
1015
1052
  }
1016
1053
  // ── Chapter view: build visible content ──────────────────────────────────
1017
1054
  const visibleSlice = displayLines.slice(scrollOffset, scrollOffset + viewportH);
1055
+ // Pad to exactly viewportH rows so the panel stays anchored to the bottom even
1056
+ // when scrollOffset > maxScroll (study-mode overscroll for centering).
1057
+ while (visibleSlice.length < viewportH)
1058
+ visibleSlice.push('');
1018
1059
  // Apply cursor highlight for copy mode and study mode
1019
1060
  const contentLines = (mode === 'copy' || mode === 'study') ? visibleSlice.map((line, viewIdx) => {
1020
1061
  const absLine = scrollOffset + viewIdx;
@@ -1032,9 +1073,12 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1032
1073
  ? chalk.inverse(`› ${stripAnsi(line)}`)
1033
1074
  : chalk.inverse(` ${stripAnsi(line)}`);
1034
1075
  }
1035
- // Study mode: subtle — just a › marker on first line, no bg change
1076
+ // Study mode: subtle — just a › marker on first line, no bg change.
1077
+ // Place cursor 3 columns left of the verse number so the gap stays
1078
+ // constant regardless of margin (zoom level).
1036
1079
  if (absLine === entry.startLine) {
1037
- return `${chalk.cyan('›')}${line.substring(1)}`;
1080
+ const cursorCol = Math.max(0, getMargin() - 3);
1081
+ return `${line.slice(0, cursorCol)}${chalk.cyan('›')}${line.slice(cursorCol + 1)}`;
1038
1082
  }
1039
1083
  return line;
1040
1084
  }
@@ -1063,6 +1107,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1063
1107
  const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
1064
1108
  const header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
1065
1109
  // ── Footer (two lines for normal/study, one line for modal modes) ──────
1110
+ // Toggle indicator: cyan when active (state cue), magenta key + dim desc when off.
1111
+ const toggleHint = (on, key, desc) => on ? chalk.cyan(`${key} ${desc}`) : kb(key, desc);
1066
1112
  let footer1;
1067
1113
  let footer2 = null;
1068
1114
  if (flash) {
@@ -1073,33 +1119,67 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1073
1119
  const countLabel = searchMatches.length === 0
1074
1120
  ? 'no matches'
1075
1121
  : `match ${matchIdx + 1}/${searchMatches.length}`;
1076
- footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${chalk.dim('n/N cycle · Esc clear')}`;
1122
+ footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
1077
1123
  }
1078
1124
  else {
1079
- footer1 = ` / ${searchQuery}${chalk.dim('_')} ${chalk.dim('Enter search · Esc cancel')}`;
1125
+ footer1 = ` / ${searchQuery}${chalk.dim('_')} ${[kb('Enter', 'search'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1080
1126
  }
1081
1127
  }
1082
1128
  else if (mode === 'goto') {
1083
- footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8 · Enter go · Esc cancel')}`;
1129
+ footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8')}${FOOTER_SEP}${[kb('Enter', 'go'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1084
1130
  }
1085
1131
  else if (mode === 'study') {
1086
1132
  const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
1087
1133
  const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
1088
- const sNoteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1089
- const sOutlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1090
- footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${chalk.dim('# ref · v range · c copy ·')} ${sNoteFlag} ${chalk.dim('·')} ${sOutlineFlag} ${chalk.dim('· / find')}`;
1091
- const panelHint = panelOverflow ? chalk.dim(' · ,/. note') : '';
1092
- footer2 = ` ${chalk.dim('↑↓ verse · Tab/⇧Tab quarter · g/G top/bot · ←→ ch · : goto · [/] back/fwd · d exit')}${panelHint}`;
1134
+ footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${[
1135
+ kb('#', 'ref'),
1136
+ kb('v', 'range'),
1137
+ kb('c', 'copy'),
1138
+ toggleHint(notes, 'f', 'notes'),
1139
+ toggleHint(outline, 'o', 'outline'),
1140
+ kb('/', 'find'),
1141
+ ].join(FOOTER_SEP)}`;
1142
+ const f2Parts = [
1143
+ kb('↑↓', 'verse'),
1144
+ kb('Tab/⇧Tab', 'quarter'),
1145
+ kb('g/G', 'top/bot'),
1146
+ kb('←→', 'ch'),
1147
+ kb(':', 'goto'),
1148
+ kb('[/]', 'back/fwd'),
1149
+ kb('d', 'exit'),
1150
+ ];
1151
+ if (panelOverflow)
1152
+ f2Parts.push(kb(',/.', 'note'));
1153
+ footer2 = ` ${f2Parts.join(FOOTER_SEP)}`;
1093
1154
  }
1094
1155
  else if (mode === 'copy') {
1095
1156
  const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
1096
- footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${chalk.dim('↑↓ move · v range · Enter copy · Esc cancel')}`;
1157
+ footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${[
1158
+ kb('↑↓', 'move'),
1159
+ kb('v', 'range'),
1160
+ kb('Enter', 'copy'),
1161
+ kb('Esc', 'cancel'),
1162
+ ].join(FOOTER_SEP)}`;
1097
1163
  }
1098
1164
  else {
1099
- const noteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1100
- const outlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1101
- footer1 = ` ${noteFlag} ${chalk.dim('·')} ${outlineFlag} ${chalk.dim('· d study · c copy · / find')}`;
1102
- footer2 = ` ${chalk.dim('↑↓ scroll · Tab/⇧Tab quarter · g/G top/bot · ←→ ch · +/- width · : goto · H home · [/] back/fwd · q quit')}`;
1165
+ footer1 = ` ${[
1166
+ toggleHint(notes, 'f', 'notes'),
1167
+ toggleHint(outline, 'o', 'outline'),
1168
+ kb('d', 'study'),
1169
+ kb('c', 'copy'),
1170
+ kb('/', 'find'),
1171
+ ].join(FOOTER_SEP)}`;
1172
+ footer2 = ` ${[
1173
+ kb('↑↓', 'scroll'),
1174
+ kb('Tab/⇧Tab', 'quarter'),
1175
+ kb('g/G', 'top/bot'),
1176
+ kb('←→', 'ch'),
1177
+ kb('+/-', 'width'),
1178
+ kb(':', 'goto'),
1179
+ kb('H', 'home'),
1180
+ kb('[/]', 'back/fwd'),
1181
+ kb('q', 'quit'),
1182
+ ].join(FOOTER_SEP)}`;
1103
1183
  }
1104
1184
  const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
1105
1185
  // ── Render ───────────────────────────────────────────────────────────────
@@ -1110,7 +1190,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1110
1190
  const lo = panelScrollClamped + 1;
1111
1191
  const hi = panelScrollClamped + panelVisibleLines.length;
1112
1192
  const total = panelLinesAll.length;
1113
- parts.push(chalk.dim(` ── ${lo}–${hi} of ${total} · ,/. scroll ──`));
1193
+ parts.push(` ${chalk.dim(`── ${lo}–${hi} of ${total} ·`)} ${kb(',/.', 'scroll')} ${chalk.dim('──')}`);
1114
1194
  }
1115
1195
  studyPanelContent = parts.join('\n');
1116
1196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rv-bible-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Fast, offline terminal Bible reader for the Recovery Version. Footnotes, cross-references, concordance search, interactive pager.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",