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 +1 -1
- package/dist/format.d.ts +1 -0
- package/dist/format.js +3 -0
- package/dist/index.js +1 -3
- package/dist/ui/Pager.js +117 -37
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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 = ` ${
|
|
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
|
|
1011
|
-
|
|
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
|
-
|
|
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)} ${
|
|
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('_')} ${
|
|
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
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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} ${
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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(
|
|
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.
|
|
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",
|