rv-bible-cli 0.1.5 → 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 +8 -3
- package/dist/format.d.ts +2 -0
- package/dist/format.js +4 -1
- package/dist/index.js +6 -4
- package/dist/ui/Pager.js +230 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,6 +114,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
|
|
|
114
114
|
|-----|--------|
|
|
115
115
|
| `j` / `k` / arrows | scroll up/down |
|
|
116
116
|
| `Space` / `b` | page down/up |
|
|
117
|
+
| `Tab` / `Shift+Tab` | scroll down / up by a quarter of the chapter |
|
|
117
118
|
| `g` / `G` | jump to top / bottom |
|
|
118
119
|
| left / right | prev / next chapter (even across books) |
|
|
119
120
|
| `f` | toggle footnotes on/off |
|
|
@@ -133,19 +134,23 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
|
|
|
133
134
|
|
|
134
135
|
Deep-dive into footnotes and cross-references. Press `d` while reading any chapter.
|
|
135
136
|
|
|
136
|
-
- A cursor highlights the current verse
|
|
137
|
-
- The bottom panel shows
|
|
137
|
+
- A cursor highlights the current verse — the chapter view auto-scrolls to keep it vertically centered as you move
|
|
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
|
|
138
139
|
- **Type a number** to follow a cross-reference — you'll jump to that chapter and land on the exact verse
|
|
139
140
|
- Press `[` to go back to where you were
|
|
140
141
|
- Study mode stays active as you navigate — it's designed for chaining through references
|
|
142
|
+
- If a verse has very long notes, the panel caps at half your terminal height — use `,` / `.` to scroll within it
|
|
141
143
|
|
|
142
144
|
| Key | Action |
|
|
143
145
|
|-----|--------|
|
|
144
|
-
| up / down | move
|
|
146
|
+
| up / down | move cursor up / down one verse |
|
|
145
147
|
| left / right | prev / next chapter |
|
|
146
148
|
| type number | follow a cross-reference |
|
|
147
149
|
| `v` then up/down then `c` | select a range and copy |
|
|
148
150
|
| `c` | copy current verse |
|
|
151
|
+
| `Tab` / `Shift+Tab` | jump cursor by a quarter of the chapter |
|
|
152
|
+
| `g` / `G` | cursor to first / last verse |
|
|
153
|
+
| `,` / `.` | scroll the footnote panel up / down (when it overflows) |
|
|
149
154
|
| `f` / `o` | toggle footnotes / outline |
|
|
150
155
|
| `/` | find text |
|
|
151
156
|
| `:` | jump to a book |
|
package/dist/format.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ 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;
|
|
12
|
+
export declare function wrapWords(text: string, maxWidth: number, ansi?: boolean): string[];
|
|
11
13
|
export declare function renderFootnoteBlock(footnotes: Footnote[]): string;
|
|
12
14
|
export declare function renderNoteDisplayAll(verse: Verse, footnotes: Footnote[], bookName: string): string;
|
|
13
15
|
export declare function renderNoteDisplay(verse: Verse, fn: 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).
|
|
@@ -118,7 +121,7 @@ function visibleLength(s) {
|
|
|
118
121
|
// ── Word-wrap helper ─────────────────────────────────────────────────────────
|
|
119
122
|
// Wraps plain `text` to `maxWidth` columns. Returns array of line strings.
|
|
120
123
|
// Use visibleLength=true when words may contain ANSI escape codes.
|
|
121
|
-
function wrapWords(text, maxWidth, ansi = false) {
|
|
124
|
+
export function wrapWords(text, maxWidth, ansi = false) {
|
|
122
125
|
if (maxWidth < 20)
|
|
123
126
|
return [text];
|
|
124
127
|
const words = text.split(' ');
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { program } from 'commander';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import clipboard from 'clipboardy';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
5
7
|
import { parseRefList, resolveBook } from './parser.js';
|
|
6
8
|
import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getFootnote, getVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from './db.js';
|
|
7
9
|
import { renderVerses, renderVerseInline, renderFootnoteBlock, renderNoteDisplay, renderNoteDisplayAll, stripMarkers, highlightTerms, } from './format.js';
|
|
@@ -10,10 +12,12 @@ import { getLastRead, saveLastRead } from './state.js';
|
|
|
10
12
|
function stripAnsi(s) {
|
|
11
13
|
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
12
14
|
}
|
|
15
|
+
// Read version from package.json (single source of truth — avoids drift)
|
|
16
|
+
const pkg = JSON.parse(readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf-8'));
|
|
13
17
|
program
|
|
14
18
|
.name('rv')
|
|
15
19
|
.description('Recovery Version Bible CLI')
|
|
16
|
-
.version(
|
|
20
|
+
.version(pkg.version)
|
|
17
21
|
.enablePositionalOptions();
|
|
18
22
|
// Formats a ParsedRef + book name into a display label: "John 3", "John 3:16", "John 3:16–18"
|
|
19
23
|
function formatRefLabel(ref, bookName) {
|
|
@@ -393,9 +397,7 @@ program
|
|
|
393
397
|
const bookName = getBookInfo(v.book)?.full_name ?? v.book;
|
|
394
398
|
const ref = `${bookName} ${v.chapter}:${v.verse}`;
|
|
395
399
|
const cleanText = stripMarkers(v.text);
|
|
396
|
-
|
|
397
|
-
const truncated = cleanText.length > 150 ? cleanText.substring(0, 147) + '...' : cleanText;
|
|
398
|
-
const highlighted = highlightTerms(truncated, terms);
|
|
400
|
+
const highlighted = highlightTerms(cleanText, terms);
|
|
399
401
|
// Wrap the text at ~76 chars with 4-space indent
|
|
400
402
|
const maxW = Math.min((process.stdout.columns ?? 80) - 4, 76);
|
|
401
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, 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.
|
|
@@ -144,6 +158,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
144
158
|
const [studyCursorIdx, setStudyCursorIdx] = useState(0);
|
|
145
159
|
const [studySelStart, setStudySelStart] = useState(null);
|
|
146
160
|
const [studyRefInput, setStudyRefInput] = useState(''); // multi-digit ref number
|
|
161
|
+
const [panelScrollOffset, setPanelScrollOffset] = useState(0); // scroll within study panel when notes overflow cap
|
|
147
162
|
const studyActiveRef = useRef(false); // persists across navigate so study mode survives ref follows
|
|
148
163
|
const pendingVerseRef = useRef(null); // target verse to jump to after navigate
|
|
149
164
|
// Flash message
|
|
@@ -215,10 +230,13 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
215
230
|
const panelLines = [];
|
|
216
231
|
const verseLabel = `${bookName} ${chapter}:${v.verse}`;
|
|
217
232
|
panelLines.push(` ${chalk.bold(verseLabel)} ${chalk.dim(`— ${fns.length} note${fns.length === 1 ? '' : 's'}`)}`);
|
|
233
|
+
const BODY_INDENT = ' '; // 5 spaces — visually under the word, after marker
|
|
234
|
+
const bodyWidth = Math.max(20, cols - BODY_INDENT.length);
|
|
218
235
|
for (const fn of fns) {
|
|
219
236
|
// Extract the word the marker attaches to (first word after "Book ch:v word" in footnote text)
|
|
220
|
-
const fnTextParts = fn.text.split(/\s+/);
|
|
237
|
+
const fnTextParts = fn.text.trim().split(/\s+/);
|
|
221
238
|
const word = fnTextParts[2] ?? ''; // "Joh 3:16 loved ..." → "loved"
|
|
239
|
+
const noteBody = fnTextParts.slice(3).join(' ').trim(); // everything after "Book ch:v word"
|
|
222
240
|
const markerDisp = toSuperscript(fn.marker);
|
|
223
241
|
const refs = refsByMarker.get(fn.marker) ?? [];
|
|
224
242
|
// Deduplicate refs by target
|
|
@@ -230,42 +248,85 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
230
248
|
seen.add(key);
|
|
231
249
|
return true;
|
|
232
250
|
});
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
251
|
+
// Build numbered ref labels (mutates studyRefs)
|
|
252
|
+
const refLabels = [];
|
|
253
|
+
for (const r of uniqueRefs) {
|
|
254
|
+
const refNum = studyRefs.length + 1;
|
|
255
|
+
const tgtBookName = getBookInfo(r.tgt_book)?.abbr ?? r.tgt_book;
|
|
256
|
+
const label = `${tgtBookName} ${r.tgt_chapter}:${r.tgt_verse}`;
|
|
257
|
+
studyRefs.push({
|
|
258
|
+
num: refNum, marker: fn.marker,
|
|
259
|
+
tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
|
|
260
|
+
});
|
|
261
|
+
refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
|
|
237
262
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
num: refNum, marker: fn.marker,
|
|
247
|
-
tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
|
|
248
|
-
});
|
|
249
|
-
refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
|
|
263
|
+
// Header line: marker + word + refs (refs may be empty)
|
|
264
|
+
const refsPart = refLabels.length > 0 ? ` ${refLabels.join(' ')}` : '';
|
|
265
|
+
panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${refsPart}`);
|
|
266
|
+
// Body: wrap full note text under hanging indent, dim style. No truncation.
|
|
267
|
+
if (noteBody) {
|
|
268
|
+
const wrapped = wrapWords(noteBody, bodyWidth);
|
|
269
|
+
for (const wl of wrapped) {
|
|
270
|
+
panelLines.push(`${BODY_INDENT}${chalk.dim(wl)}`);
|
|
250
271
|
}
|
|
251
|
-
// Compact: marker + word + refs on one line (wrap if needed)
|
|
252
|
-
const hasStudyNote = fn.text.length > 80;
|
|
253
|
-
const noteHint = hasStudyNote ? chalk.dim(' (note)') : '';
|
|
254
|
-
panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${noteHint} ${refLabels.join(' ')}`);
|
|
255
272
|
}
|
|
256
273
|
}
|
|
257
274
|
return { lines: panelLines, refs: studyRefs };
|
|
258
|
-
}, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName]);
|
|
275
|
+
}, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName, cols]);
|
|
276
|
+
// ── Study panel sizing & scroll (cap at 50% of screen, scrollable on overflow) ──
|
|
277
|
+
const panelLinesAll = studyPanel.lines;
|
|
278
|
+
const panelMaxH = Math.max(3, Math.floor(rows * 0.5));
|
|
279
|
+
const panelOverflow = panelLinesAll.length > panelMaxH;
|
|
280
|
+
const panelContentSlots = panelOverflow ? panelMaxH - 1 : panelLinesAll.length; // reserve 1 line for indicator
|
|
281
|
+
const maxPanelScroll = Math.max(0, panelLinesAll.length - panelContentSlots);
|
|
282
|
+
const panelScrollClamped = Math.max(0, Math.min(panelScrollOffset, maxPanelScroll));
|
|
283
|
+
const panelVisibleLines = panelLinesAll.slice(panelScrollClamped, panelScrollClamped + panelContentSlots);
|
|
284
|
+
const panelDisplayH = panelLinesAll.length > 0 ? panelVisibleLines.length + (panelOverflow ? 1 : 0) : 0;
|
|
259
285
|
// ── Viewport height (shrinks when study panel is visible) ────────────
|
|
260
|
-
const studyPanelH =
|
|
286
|
+
const studyPanelH = panelDisplayH > 0 ? panelDisplayH + 1 : 0; // +1 for separator above panel
|
|
261
287
|
const hasFooter2 = mode === 'normal' || mode === 'study';
|
|
262
288
|
const chromeH = 3 + (hasFooter2 ? 2 : 1) + studyPanelH; // header + sep + footer(s) + sep + panel
|
|
263
289
|
const viewportH = Math.max(1, rows - chromeH);
|
|
264
|
-
//
|
|
290
|
+
// Reset panel scroll when the verse cursor changes or when leaving study mode
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
setPanelScrollOffset(0);
|
|
293
|
+
}, [studyCursorIdx, book, chapter, mode]);
|
|
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.
|
|
265
298
|
const maxScroll = Math.max(0, lines.length - viewportH);
|
|
299
|
+
const overscrollLimit = Math.max(0, lines.length - 1);
|
|
266
300
|
useEffect(() => {
|
|
267
|
-
|
|
268
|
-
|
|
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.
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (mode !== 'study' || verseMap.length === 0)
|
|
314
|
+
return;
|
|
315
|
+
const entry = verseMap[studyCursorIdx];
|
|
316
|
+
if (!entry)
|
|
317
|
+
return;
|
|
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]);
|
|
269
330
|
// ── Search highlights ────────────────────────────────────────────────────
|
|
270
331
|
const displayLines = useMemo(() => {
|
|
271
332
|
if (!searchSubmitted || searchMatches.length === 0)
|
|
@@ -288,6 +349,20 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
288
349
|
setStudyCursorIdx(0);
|
|
289
350
|
}, []);
|
|
290
351
|
const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
|
|
352
|
+
const sameChapter = newBook === bookRef.current && newChapter === chapterRef.current;
|
|
353
|
+
// Same-chapter cross-ref follow: jump cursor directly. Don't reset state via
|
|
354
|
+
// restoreMode (would clobber cursor to 0), don't push to history (cursor isn't tracked there).
|
|
355
|
+
if (sameChapter && pendingVerseRef.current) {
|
|
356
|
+
const target = pendingVerseRef.current;
|
|
357
|
+
pendingVerseRef.current = null;
|
|
358
|
+
let idx = verseMap.findIndex(e => e.verse === target);
|
|
359
|
+
if (idx < 0)
|
|
360
|
+
idx = verseMap.findIndex(e => e.verse.startsWith(target));
|
|
361
|
+
if (idx >= 0)
|
|
362
|
+
setStudyCursorIdx(idx);
|
|
363
|
+
saveLastRead(newBook, newChapter);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
291
366
|
if (pushHistory) {
|
|
292
367
|
historyRef.current = [...historyRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
|
|
293
368
|
forwardRef.current = [];
|
|
@@ -297,7 +372,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
297
372
|
setScrollOffset(0);
|
|
298
373
|
restoreMode();
|
|
299
374
|
saveLastRead(newBook, newChapter);
|
|
300
|
-
}, [restoreMode]);
|
|
375
|
+
}, [restoreMode, verseMap]);
|
|
301
376
|
const goBack = useCallback(() => {
|
|
302
377
|
const hist = historyRef.current;
|
|
303
378
|
if (hist.length === 0)
|
|
@@ -342,6 +417,11 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
342
417
|
const scrollUp = useCallback(() => scrollTo(scrollOffset - 1), [scrollOffset, scrollTo]);
|
|
343
418
|
const pageDown = useCallback(() => scrollTo(scrollOffset + viewportH - 2), [scrollOffset, viewportH, scrollTo]);
|
|
344
419
|
const pageUp = useCallback(() => scrollTo(scrollOffset - viewportH + 2), [scrollOffset, viewportH, scrollTo]);
|
|
420
|
+
// Quarter-of-chapter slider — 4 presses spans top to bottom regardless of length
|
|
421
|
+
const scrollQuarter = useCallback((dir) => {
|
|
422
|
+
const q = Math.max(1, Math.floor(lines.length * 0.25));
|
|
423
|
+
scrollTo(scrollOffset + dir * q);
|
|
424
|
+
}, [lines.length, scrollOffset, scrollTo]);
|
|
345
425
|
// ── Copy helpers ─────────────────────────────────────────────────────────
|
|
346
426
|
const doCopy = useCallback(async () => {
|
|
347
427
|
const lo = selStart !== null ? Math.min(selStart, cursorIdx) : cursorIdx;
|
|
@@ -598,26 +678,23 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
598
678
|
return;
|
|
599
679
|
}
|
|
600
680
|
if (input === 'j' || key.downArrow) {
|
|
601
|
-
setStudyCursorIdx(prev =>
|
|
602
|
-
const next = Math.min(prev + 1, verseMap.length - 1);
|
|
603
|
-
const entry = verseMap[next];
|
|
604
|
-
if (entry && entry.startLine >= scrollOffset + viewportH) {
|
|
605
|
-
scrollTo(entry.startLine - viewportH + 3);
|
|
606
|
-
}
|
|
607
|
-
return next;
|
|
608
|
-
});
|
|
681
|
+
setStudyCursorIdx(prev => Math.min(prev + 1, verseMap.length - 1));
|
|
609
682
|
setStudyRefInput('');
|
|
610
683
|
return;
|
|
611
684
|
}
|
|
612
685
|
if (input === 'k' || key.upArrow) {
|
|
613
|
-
setStudyCursorIdx(prev =>
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
686
|
+
setStudyCursorIdx(prev => Math.max(prev - 1, 0));
|
|
687
|
+
setStudyRefInput('');
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// Cursor to first / last verse (cursor-visible effect handles scroll)
|
|
691
|
+
if (input === 'g') {
|
|
692
|
+
setStudyCursorIdx(0);
|
|
693
|
+
setStudyRefInput('');
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (input === 'G') {
|
|
697
|
+
setStudyCursorIdx(Math.max(0, verseMap.length - 1));
|
|
621
698
|
setStudyRefInput('');
|
|
622
699
|
return;
|
|
623
700
|
}
|
|
@@ -632,6 +709,23 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
632
709
|
setZoomState(getZoom());
|
|
633
710
|
return;
|
|
634
711
|
}
|
|
712
|
+
// Quarter-of-chapter cursor jump (cursor-visible effect handles scroll)
|
|
713
|
+
if (key.tab) {
|
|
714
|
+
const step = Math.max(1, Math.floor(verseMap.length / 4));
|
|
715
|
+
const dir = key.shift ? -1 : 1;
|
|
716
|
+
setStudyCursorIdx(prev => Math.max(0, Math.min(verseMap.length - 1, prev + dir * step)));
|
|
717
|
+
setStudyRefInput('');
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// Panel scroll (only meaningful when notes panel overflows)
|
|
721
|
+
if (input === ',') {
|
|
722
|
+
setPanelScrollOffset(prev => Math.max(0, prev - 1));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (input === '.') {
|
|
726
|
+
setPanelScrollOffset(prev => Math.min(maxPanelScroll, prev + 1));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
635
729
|
// Number keys: build ref number, then follow on Enter or after short delay
|
|
636
730
|
if (input && /^[0-9]$/.test(input)) {
|
|
637
731
|
const newInput = studyRefInput + input;
|
|
@@ -786,6 +880,14 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
786
880
|
scrollTo(maxScroll);
|
|
787
881
|
return;
|
|
788
882
|
}
|
|
883
|
+
if (key.tab && key.shift) {
|
|
884
|
+
scrollQuarter(-1);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (key.tab) {
|
|
888
|
+
scrollQuarter(1);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
789
891
|
if (input === 'n' || key.rightArrow) {
|
|
790
892
|
if (searchSubmitted && searchMatches.length > 0) {
|
|
791
893
|
nextMatch();
|
|
@@ -926,18 +1028,34 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
926
1028
|
const homeContent = homeLines.join('\n');
|
|
927
1029
|
let homeFooter;
|
|
928
1030
|
if (mode === 'home-chapter') {
|
|
929
|
-
homeFooter = ` ${
|
|
1031
|
+
homeFooter = ` ${[
|
|
1032
|
+
kb('←/→', 'ch'),
|
|
1033
|
+
kb('0-9', 'jump'),
|
|
1034
|
+
kb('Enter', 'go'),
|
|
1035
|
+
kb('Esc', 'back'),
|
|
1036
|
+
].join(FOOTER_SEP)}`;
|
|
930
1037
|
}
|
|
931
1038
|
else {
|
|
932
1039
|
const lastRead = getLastRead();
|
|
933
|
-
const
|
|
934
|
-
|
|
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)}`;
|
|
935
1049
|
}
|
|
936
1050
|
const homeSep = chalk.dim('─'.repeat(cols));
|
|
937
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 })] }));
|
|
938
1052
|
}
|
|
939
1053
|
// ── Chapter view: build visible content ──────────────────────────────────
|
|
940
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('');
|
|
941
1059
|
// Apply cursor highlight for copy mode and study mode
|
|
942
1060
|
const contentLines = (mode === 'copy' || mode === 'study') ? visibleSlice.map((line, viewIdx) => {
|
|
943
1061
|
const absLine = scrollOffset + viewIdx;
|
|
@@ -955,9 +1073,12 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
955
1073
|
? chalk.inverse(`› ${stripAnsi(line)}`)
|
|
956
1074
|
: chalk.inverse(` ${stripAnsi(line)}`);
|
|
957
1075
|
}
|
|
958
|
-
// 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).
|
|
959
1079
|
if (absLine === entry.startLine) {
|
|
960
|
-
|
|
1080
|
+
const cursorCol = Math.max(0, getMargin() - 3);
|
|
1081
|
+
return `${line.slice(0, cursorCol)}${chalk.cyan('›')}${line.slice(cursorCol + 1)}`;
|
|
961
1082
|
}
|
|
962
1083
|
return line;
|
|
963
1084
|
}
|
|
@@ -986,6 +1107,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
986
1107
|
const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
|
|
987
1108
|
const header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
|
|
988
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);
|
|
989
1112
|
let footer1;
|
|
990
1113
|
let footer2 = null;
|
|
991
1114
|
if (flash) {
|
|
@@ -996,38 +1119,81 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
996
1119
|
const countLabel = searchMatches.length === 0
|
|
997
1120
|
? 'no matches'
|
|
998
1121
|
: `match ${matchIdx + 1}/${searchMatches.length}`;
|
|
999
|
-
footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${
|
|
1122
|
+
footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
|
|
1000
1123
|
}
|
|
1001
1124
|
else {
|
|
1002
|
-
footer1 = ` / ${searchQuery}${chalk.dim('_')} ${
|
|
1125
|
+
footer1 = ` / ${searchQuery}${chalk.dim('_')} ${[kb('Enter', 'search'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
|
|
1003
1126
|
}
|
|
1004
1127
|
}
|
|
1005
1128
|
else if (mode === 'goto') {
|
|
1006
|
-
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)}`;
|
|
1007
1130
|
}
|
|
1008
1131
|
else if (mode === 'study') {
|
|
1009
1132
|
const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
|
|
1010
1133
|
const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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)}`;
|
|
1015
1154
|
}
|
|
1016
1155
|
else if (mode === 'copy') {
|
|
1017
1156
|
const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
|
|
1018
|
-
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)}`;
|
|
1019
1163
|
}
|
|
1020
1164
|
else {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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)}`;
|
|
1025
1183
|
}
|
|
1026
1184
|
const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
|
|
1027
1185
|
// ── Render ───────────────────────────────────────────────────────────────
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1186
|
+
let studyPanelContent = null;
|
|
1187
|
+
if (panelLinesAll.length > 0) {
|
|
1188
|
+
const parts = [panelVisibleLines.join('\n')];
|
|
1189
|
+
if (panelOverflow) {
|
|
1190
|
+
const lo = panelScrollClamped + 1;
|
|
1191
|
+
const hi = panelScrollClamped + panelVisibleLines.length;
|
|
1192
|
+
const total = panelLinesAll.length;
|
|
1193
|
+
parts.push(` ${chalk.dim(`── ${lo}–${hi} of ${total} ·`)} ${kb(',/.', 'scroll')} ${chalk.dim('──')}`);
|
|
1194
|
+
}
|
|
1195
|
+
studyPanelContent = parts.join('\n');
|
|
1196
|
+
}
|
|
1031
1197
|
return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { children: header }), _jsx(Text, { children: separator }), _jsx(Box, { flexGrow: 1, overflow: "hidden", children: _jsx(Text, { children: content }) }), _jsx(Text, { children: separator }), studyPanelContent && _jsx(Text, { children: studyPanelContent }), studyPanelContent && _jsx(Text, { children: separator }), _jsx(Text, { children: footer1 }), footer2 && _jsx(Text, { children: footer2 })] }));
|
|
1032
1198
|
}
|
|
1033
1199
|
// ── Launcher ─────────────────────────────────────────────────────────────────
|
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",
|