rv-bible-cli 0.1.6 → 0.1.8
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 +2 -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 +518 -108
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,6 +123,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
|
|
|
123
123
|
| `d` | enter study mode |
|
|
124
124
|
| `c` | enter copy mode |
|
|
125
125
|
| `/` | find text in current chapter |
|
|
126
|
+
| `?` | search the whole Bible — type a query, browse results, Enter opens |
|
|
126
127
|
| `:` | jump to any book — type `rom 8`, `jn 3`, etc. |
|
|
127
128
|
| `[` / `]` | go back / forward (like browser history) |
|
|
128
129
|
| `H` | go to home screen |
|
|
@@ -134,7 +135,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
|
|
|
134
135
|
|
|
135
136
|
Deep-dive into footnotes and cross-references. Press `d` while reading any chapter.
|
|
136
137
|
|
|
137
|
-
- A cursor highlights the current verse
|
|
138
|
+
- A cursor highlights the current verse — the chapter view auto-scrolls to keep it vertically centered as you move
|
|
138
139
|
- 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
140
|
- **Type a number** to follow a cross-reference — you'll jump to that chapter and land on the exact verse
|
|
140
141
|
- 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
|
@@ -3,8 +3,8 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
|
3
3
|
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import clipboard from 'clipboardy';
|
|
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';
|
|
6
|
+
import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from '../db.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.
|
|
@@ -125,11 +139,17 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
125
139
|
// Copy mode state
|
|
126
140
|
const [cursorIdx, setCursorIdx] = useState(0);
|
|
127
141
|
const [selStart, setSelStart] = useState(null);
|
|
128
|
-
//
|
|
129
|
-
const [
|
|
130
|
-
const [
|
|
131
|
-
const [
|
|
132
|
-
const [
|
|
142
|
+
// Find-in-chapter mode state (the `/` modal)
|
|
143
|
+
const [findQuery, setFindQuery] = useState('');
|
|
144
|
+
const [findSubmitted, setFindSubmitted] = useState(false);
|
|
145
|
+
const [findMatches, setFindMatches] = useState([]);
|
|
146
|
+
const [findIdx, setFindIdx] = useState(0);
|
|
147
|
+
// Global search session state (the `?` modal — full Bible search)
|
|
148
|
+
const [gsearchInput, setGsearchInput] = useState('');
|
|
149
|
+
const [gsearchSession, setGsearchSession] = useState(null);
|
|
150
|
+
// Holds the search terms to highlight in the next-rendered chapter (consumed by
|
|
151
|
+
// a useEffect once `lines` rebuilds). Array so multi-term queries highlight all words.
|
|
152
|
+
const pendingFindTermsRef = useRef(null);
|
|
133
153
|
// Goto mode state
|
|
134
154
|
const [gotoInput, setGotoInput] = useState('');
|
|
135
155
|
// Navigation history: back/forward stacks (refs so useInput always sees latest)
|
|
@@ -277,64 +297,106 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
277
297
|
useEffect(() => {
|
|
278
298
|
setPanelScrollOffset(0);
|
|
279
299
|
}, [studyCursorIdx, book, chapter, mode]);
|
|
280
|
-
// Clamp scroll when content or viewport changes
|
|
300
|
+
// Clamp scroll when content or viewport changes. Normal mode uses the strict
|
|
301
|
+
// maxScroll clamp (can't scroll past content). Study mode allows overscroll so
|
|
302
|
+
// the last verse can still center — the visible slice is padded with empty rows
|
|
303
|
+
// when scrollOffset > maxScroll, keeping the panel anchored to the bottom.
|
|
281
304
|
const maxScroll = Math.max(0, lines.length - viewportH);
|
|
305
|
+
const overscrollLimit = Math.max(0, lines.length - 1);
|
|
282
306
|
useEffect(() => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
307
|
+
if (mode !== 'study') {
|
|
308
|
+
setScrollOffset(prev => Math.min(prev, maxScroll));
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
setScrollOffset(prev => Math.min(prev, overscrollLimit));
|
|
312
|
+
}
|
|
313
|
+
}, [maxScroll, overscrollLimit, mode]);
|
|
314
|
+
// Center the cursor verse in study mode. If the verse fits in the viewport,
|
|
315
|
+
// place its midpoint at viewportH/2. If the verse is taller than the viewport
|
|
316
|
+
// (rare — long verse with markers wrapping), pin its start to the top so the
|
|
317
|
+
// beginning is always visible. Triggers on cursor move and viewport resize.
|
|
287
318
|
useEffect(() => {
|
|
288
319
|
if (mode !== 'study' || verseMap.length === 0)
|
|
289
320
|
return;
|
|
290
321
|
const entry = verseMap[studyCursorIdx];
|
|
291
322
|
if (!entry)
|
|
292
323
|
return;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}, [studyCursorIdx, viewportH, mode, verseMap]);
|
|
324
|
+
const verseH = entry.endLine - entry.startLine + 1;
|
|
325
|
+
let target;
|
|
326
|
+
if (verseH >= viewportH) {
|
|
327
|
+
target = entry.startLine;
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
const verseMid = entry.startLine + Math.floor(verseH / 2);
|
|
331
|
+
target = verseMid - Math.floor(viewportH / 2);
|
|
332
|
+
}
|
|
333
|
+
target = Math.max(0, Math.min(target, overscrollLimit));
|
|
334
|
+
setScrollOffset(target);
|
|
335
|
+
}, [studyCursorIdx, viewportH, mode, verseMap, overscrollLimit]);
|
|
305
336
|
// ── Search highlights ────────────────────────────────────────────────────
|
|
337
|
+
// Highlights every space-separated token in findQuery (so multi-term gsearch
|
|
338
|
+
// results like "only begotten" light up both words). For pure single-word find,
|
|
339
|
+
// this collapses to the original single-term behavior.
|
|
306
340
|
const displayLines = useMemo(() => {
|
|
307
|
-
if (!
|
|
341
|
+
if (!findSubmitted || findMatches.length === 0)
|
|
308
342
|
return lines;
|
|
309
|
-
const
|
|
343
|
+
const queryTerms = findQuery.split(/\s+/).filter(Boolean);
|
|
344
|
+
if (queryTerms.length === 0)
|
|
345
|
+
return lines;
|
|
346
|
+
const lowers = queryTerms.map(t => t.toLowerCase());
|
|
310
347
|
return lines.map((line) => {
|
|
311
348
|
const stripped = stripAnsi(line);
|
|
312
|
-
|
|
349
|
+
const lower = stripped.toLowerCase();
|
|
350
|
+
if (!lowers.some(l => lower.includes(l)))
|
|
313
351
|
return line;
|
|
314
|
-
return highlightTerms(stripped,
|
|
352
|
+
return highlightTerms(stripped, queryTerms);
|
|
315
353
|
});
|
|
316
|
-
}, [lines,
|
|
354
|
+
}, [lines, findSubmitted, findMatches, findQuery]);
|
|
317
355
|
// ── Navigation helpers ───────────────────────────────────────────────────
|
|
318
356
|
const restoreMode = useCallback(() => {
|
|
319
357
|
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
358
|
+
setFindSubmitted(false);
|
|
359
|
+
setFindQuery('');
|
|
360
|
+
setFindMatches([]);
|
|
323
361
|
setStudyRefInput('');
|
|
324
362
|
setStudyCursorIdx(0);
|
|
325
363
|
}, []);
|
|
326
364
|
const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
|
|
327
365
|
const sameChapter = newBook === bookRef.current && newChapter === chapterRef.current;
|
|
328
|
-
// Same-chapter
|
|
329
|
-
//
|
|
366
|
+
// Same-chapter target: jump cursor (study) or scroll (normal) directly without
|
|
367
|
+
// re-rendering the chapter. Exits any modal mode (gsearch-list, etc.) via restoreMode.
|
|
330
368
|
if (sameChapter && pendingVerseRef.current) {
|
|
331
369
|
const target = pendingVerseRef.current;
|
|
332
370
|
pendingVerseRef.current = null;
|
|
333
371
|
let idx = verseMap.findIndex(e => e.verse === target);
|
|
334
372
|
if (idx < 0)
|
|
335
373
|
idx = verseMap.findIndex(e => e.verse.startsWith(target));
|
|
336
|
-
if (idx >= 0)
|
|
337
|
-
|
|
374
|
+
if (idx >= 0) {
|
|
375
|
+
const entry = verseMap[idx];
|
|
376
|
+
if (studyActiveRef.current) {
|
|
377
|
+
setStudyCursorIdx(idx);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
setScrollOffset(Math.max(0, entry.startLine - 2));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Highlight pending terms in current chapter (no new `lines` to trigger the effect).
|
|
384
|
+
const terms = pendingFindTermsRef.current;
|
|
385
|
+
if (terms && terms.length > 0) {
|
|
386
|
+
pendingFindTermsRef.current = null;
|
|
387
|
+
const lowers = terms.map(t => t.toLowerCase());
|
|
388
|
+
const matches = [];
|
|
389
|
+
for (let i = 0; i < lines.length; i++) {
|
|
390
|
+
const lower = stripAnsi(lines[i]).toLowerCase();
|
|
391
|
+
if (lowers.some(l => lower.includes(l)))
|
|
392
|
+
matches.push(i);
|
|
393
|
+
}
|
|
394
|
+
setFindQuery(terms.join(' '));
|
|
395
|
+
setFindMatches(matches);
|
|
396
|
+
setFindSubmitted(true);
|
|
397
|
+
setFindIdx(0);
|
|
398
|
+
}
|
|
399
|
+
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
338
400
|
saveLastRead(newBook, newChapter);
|
|
339
401
|
return;
|
|
340
402
|
}
|
|
@@ -347,7 +409,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
347
409
|
setScrollOffset(0);
|
|
348
410
|
restoreMode();
|
|
349
411
|
saveLastRead(newBook, newChapter);
|
|
350
|
-
}, [restoreMode, verseMap]);
|
|
412
|
+
}, [restoreMode, verseMap, lines]);
|
|
351
413
|
const goBack = useCallback(() => {
|
|
352
414
|
const hist = historyRef.current;
|
|
353
415
|
if (hist.length === 0)
|
|
@@ -415,35 +477,149 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
415
477
|
setSelStart(null);
|
|
416
478
|
}, [selStart, cursorIdx, verses, bookName, chapter]);
|
|
417
479
|
// ── Search helpers ───────────────────────────────────────────────────────
|
|
418
|
-
const
|
|
419
|
-
if (!
|
|
480
|
+
const submitFind = useCallback(() => {
|
|
481
|
+
if (!findQuery)
|
|
482
|
+
return;
|
|
483
|
+
const queryTerms = findQuery.split(/\s+/).filter(Boolean);
|
|
484
|
+
if (queryTerms.length === 0)
|
|
420
485
|
return;
|
|
421
|
-
const
|
|
486
|
+
const lowers = queryTerms.map(t => t.toLowerCase());
|
|
422
487
|
const matches = [];
|
|
423
488
|
for (let i = 0; i < lines.length; i++) {
|
|
424
|
-
|
|
489
|
+
const lower = stripAnsi(lines[i]).toLowerCase();
|
|
490
|
+
if (lowers.some(l => lower.includes(l)))
|
|
425
491
|
matches.push(i);
|
|
426
492
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
493
|
+
setFindMatches(matches);
|
|
494
|
+
setFindSubmitted(true);
|
|
495
|
+
setFindIdx(0);
|
|
430
496
|
if (matches.length > 0)
|
|
431
497
|
scrollTo(matches[0]);
|
|
432
|
-
}, [
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
498
|
+
}, [findQuery, lines, scrollTo]);
|
|
499
|
+
const nextFindMatch = useCallback(() => {
|
|
500
|
+
if (findMatches.length === 0)
|
|
501
|
+
return;
|
|
502
|
+
const next = (findIdx + 1) % findMatches.length;
|
|
503
|
+
setFindIdx(next);
|
|
504
|
+
scrollTo(findMatches[next]);
|
|
505
|
+
}, [findIdx, findMatches, scrollTo]);
|
|
506
|
+
const prevFindMatch = useCallback(() => {
|
|
507
|
+
if (findMatches.length === 0)
|
|
435
508
|
return;
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
scrollTo(
|
|
439
|
-
}, [
|
|
440
|
-
|
|
441
|
-
|
|
509
|
+
const prev = (findIdx - 1 + findMatches.length) % findMatches.length;
|
|
510
|
+
setFindIdx(prev);
|
|
511
|
+
scrollTo(findMatches[prev]);
|
|
512
|
+
}, [findIdx, findMatches, scrollTo]);
|
|
513
|
+
// ── Global search helpers (the `?` modal — full Bible search) ─────────────
|
|
514
|
+
// Page size for paginating results in the gsearch-list view (←/→ to page).
|
|
515
|
+
const GSEARCH_PAGE_SIZE = 100;
|
|
516
|
+
// Parse "in <book>" scope from tokens (mirrors CLI parseScope behavior)
|
|
517
|
+
const parseGsearchScope = useCallback((tokens) => {
|
|
518
|
+
for (let nameLen = 3; nameLen >= 1; nameLen--) {
|
|
519
|
+
const inIdx = tokens.length - nameLen - 1;
|
|
520
|
+
if (inIdx < 1)
|
|
521
|
+
continue;
|
|
522
|
+
if (tokens[inIdx]?.toLowerCase() !== 'in')
|
|
523
|
+
continue;
|
|
524
|
+
const resolved = resolveBook(tokens.slice(inIdx + 1, inIdx + 1 + nameLen).join(' '));
|
|
525
|
+
if (resolved)
|
|
526
|
+
return { queryTokens: tokens.slice(0, inIdx), book: resolved };
|
|
527
|
+
}
|
|
528
|
+
return { queryTokens: tokens, book: undefined };
|
|
529
|
+
}, []);
|
|
530
|
+
// Run the search. Single known concordance word → concordance, else FTS LIKE.
|
|
531
|
+
// Returns ALL results — pagination happens at render time (GSEARCH_PAGE_SIZE per page).
|
|
532
|
+
const executeGsearch = useCallback((rawInput) => {
|
|
533
|
+
const trimmed = rawInput.trim();
|
|
534
|
+
if (!trimmed)
|
|
535
|
+
return null;
|
|
536
|
+
const tokens = trimmed.split(/\s+/);
|
|
537
|
+
const { queryTokens, book: scopeBook } = parseGsearchScope(tokens);
|
|
538
|
+
if (queryTokens.length === 0)
|
|
539
|
+
return null;
|
|
540
|
+
const word = queryTokens.join(' ');
|
|
541
|
+
let results;
|
|
542
|
+
let terms;
|
|
543
|
+
if (queryTokens.length === 1 && isInConcordance(word)) {
|
|
544
|
+
results = getTopicVerses(word, scopeBook);
|
|
545
|
+
terms = [word];
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
const ftsQuery = queryTokens.length === 1
|
|
549
|
+
? `"${queryTokens[0].replace(/"/g, '')}"`
|
|
550
|
+
: queryTokens.map(t => `"${t.replace(/"/g, '')}"`).join(' AND ');
|
|
551
|
+
results = searchFTS(ftsQuery, scopeBook);
|
|
552
|
+
terms = queryTokens.map(t => t.replace(/"/g, '').trim()).filter(Boolean);
|
|
553
|
+
}
|
|
554
|
+
return { query: trimmed, results, terms, cursor: 0, pageIdx: 0 };
|
|
555
|
+
}, [parseGsearchScope]);
|
|
556
|
+
// Build the rendered result list lines + per-result line ranges (for cursor scroll).
|
|
557
|
+
// Renders only the current page (GSEARCH_PAGE_SIZE results); the cursor index in
|
|
558
|
+
// the session is page-local, so itemMap aligns directly.
|
|
559
|
+
const gsearchListData = useMemo(() => {
|
|
560
|
+
if (!gsearchSession)
|
|
561
|
+
return null;
|
|
562
|
+
const { results, terms, cursor, pageIdx } = gsearchSession;
|
|
563
|
+
const start = pageIdx * GSEARCH_PAGE_SIZE;
|
|
564
|
+
const pageResults = results.slice(start, start + GSEARCH_PAGE_SIZE);
|
|
565
|
+
const renderLines = [];
|
|
566
|
+
const itemMap = [];
|
|
567
|
+
const wrapWidth = Math.max(40, Math.min(cols - 10, 90));
|
|
568
|
+
pageResults.forEach((v, i) => {
|
|
569
|
+
const startLine = renderLines.length;
|
|
570
|
+
const bn = getBookInfo(v.book)?.full_name ?? v.book;
|
|
571
|
+
const ref = `${bn} ${v.chapter}:${v.verse}`;
|
|
572
|
+
const cleanText = stripMarkers(v.text);
|
|
573
|
+
const highlighted = highlightTerms(cleanText, terms);
|
|
574
|
+
const wrapped = wrapWords(highlighted, wrapWidth, true);
|
|
575
|
+
const mark = i === cursor ? chalk.magenta('›') : ' ';
|
|
576
|
+
renderLines.push(` ${mark} ${chalk.bold(ref)}`);
|
|
577
|
+
for (const wl of wrapped)
|
|
578
|
+
renderLines.push(` ${wl}`);
|
|
579
|
+
const endLine = renderLines.length - 1;
|
|
580
|
+
renderLines.push(''); // blank separator
|
|
581
|
+
itemMap.push({ startLine, endLine });
|
|
582
|
+
});
|
|
583
|
+
return { lines: renderLines, itemMap, pageResults };
|
|
584
|
+
}, [gsearchSession, cols]);
|
|
585
|
+
// Center the cursor result in the list viewport (same pattern as study mode).
|
|
586
|
+
const gsearchScroll = useMemo(() => {
|
|
587
|
+
if (mode !== 'gsearch-list' || !gsearchSession || !gsearchListData)
|
|
588
|
+
return 0;
|
|
589
|
+
const item = gsearchListData.itemMap[gsearchSession.cursor];
|
|
590
|
+
if (!item)
|
|
591
|
+
return 0;
|
|
592
|
+
const itemH = item.endLine - item.startLine + 1;
|
|
593
|
+
let target;
|
|
594
|
+
if (itemH >= viewportH) {
|
|
595
|
+
target = item.startLine;
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
const itemMid = item.startLine + Math.floor(itemH / 2);
|
|
599
|
+
target = itemMid - Math.floor(viewportH / 2);
|
|
600
|
+
}
|
|
601
|
+
return Math.max(0, Math.min(target, Math.max(0, gsearchListData.lines.length - 1)));
|
|
602
|
+
}, [mode, gsearchSession, gsearchListData, viewportH]);
|
|
603
|
+
// After navigate from a search result, highlight ALL search terms in the new chapter
|
|
604
|
+
// (piggybacks on the chapter-find code path so n/N cycling works too). Doesn't
|
|
605
|
+
// scroll — the pendingVerseRef effect already positioned the cursor on the picked verse.
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
const terms = pendingFindTermsRef.current;
|
|
608
|
+
if (!terms || terms.length === 0 || lines.length === 0)
|
|
442
609
|
return;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
610
|
+
pendingFindTermsRef.current = null;
|
|
611
|
+
const lowers = terms.map(t => t.toLowerCase());
|
|
612
|
+
const matches = [];
|
|
613
|
+
for (let i = 0; i < lines.length; i++) {
|
|
614
|
+
const lower = stripAnsi(lines[i]).toLowerCase();
|
|
615
|
+
if (lowers.some(l => lower.includes(l)))
|
|
616
|
+
matches.push(i);
|
|
617
|
+
}
|
|
618
|
+
setFindQuery(terms.join(' '));
|
|
619
|
+
setFindMatches(matches);
|
|
620
|
+
setFindSubmitted(true);
|
|
621
|
+
setFindIdx(0);
|
|
622
|
+
}, [lines]);
|
|
447
623
|
// ── Goto helpers ─────────────────────────────────────────────────────────
|
|
448
624
|
const submitGoto = useCallback(() => {
|
|
449
625
|
const trimmed = gotoInput.trim();
|
|
@@ -532,8 +708,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
532
708
|
if (matches.length > 0) {
|
|
533
709
|
if (lower === lastLetterRef.current) {
|
|
534
710
|
// Same letter again: cycle to next match after current position
|
|
535
|
-
const
|
|
536
|
-
setHomeIdx(
|
|
711
|
+
const nextLetterMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
|
|
712
|
+
setHomeIdx(nextLetterMatch.i);
|
|
537
713
|
}
|
|
538
714
|
else {
|
|
539
715
|
setHomeIdx(matches[0].i);
|
|
@@ -583,35 +759,145 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
583
759
|
}
|
|
584
760
|
return;
|
|
585
761
|
}
|
|
586
|
-
// ──
|
|
587
|
-
if (mode === '
|
|
762
|
+
// ── Find-in-chapter mode (`/` modal) ────────────────────────────────
|
|
763
|
+
if (mode === 'find') {
|
|
588
764
|
const returnMode = studyActiveRef.current ? 'study' : 'normal';
|
|
589
765
|
if (key.escape) {
|
|
590
766
|
setMode(returnMode);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
767
|
+
setFindQuery('');
|
|
768
|
+
setFindSubmitted(false);
|
|
769
|
+
setFindMatches([]);
|
|
594
770
|
return;
|
|
595
771
|
}
|
|
596
772
|
if (key.return) {
|
|
597
|
-
|
|
773
|
+
submitFind();
|
|
598
774
|
setMode(returnMode);
|
|
599
775
|
return;
|
|
600
776
|
}
|
|
601
777
|
if (key.backspace || key.delete) {
|
|
602
|
-
|
|
778
|
+
setFindQuery(prev => prev.slice(0, -1));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (findSubmitted && input === 'n') {
|
|
782
|
+
nextFindMatch();
|
|
603
783
|
return;
|
|
604
784
|
}
|
|
605
|
-
if (
|
|
606
|
-
|
|
785
|
+
if (findSubmitted && input === 'N') {
|
|
786
|
+
prevFindMatch();
|
|
607
787
|
return;
|
|
608
788
|
}
|
|
609
|
-
if (
|
|
610
|
-
|
|
789
|
+
if (input && !key.ctrl && !key.meta) {
|
|
790
|
+
setFindQuery(prev => prev + input);
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// ── Global-search input mode (`?` modal — type query) ───────────────
|
|
795
|
+
if (mode === 'gsearch-input') {
|
|
796
|
+
const returnMode = studyActiveRef.current ? 'study' : 'normal';
|
|
797
|
+
if (key.escape) {
|
|
798
|
+
setMode(returnMode);
|
|
799
|
+
setGsearchInput('');
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (key.return) {
|
|
803
|
+
const trimmed = gsearchInput.trim();
|
|
804
|
+
if (!trimmed)
|
|
805
|
+
return;
|
|
806
|
+
// Same query as the live session → restore the cached list at last cursor.
|
|
807
|
+
if (gsearchSession && gsearchSession.query === trimmed) {
|
|
808
|
+
setMode('gsearch-list');
|
|
809
|
+
setGsearchInput('');
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const session = executeGsearch(gsearchInput);
|
|
813
|
+
if (session) {
|
|
814
|
+
setGsearchSession(session);
|
|
815
|
+
setMode('gsearch-list');
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
// Empty results case still gets a session so the list shows "0 results".
|
|
819
|
+
setGsearchSession({ query: trimmed, results: [], terms: trimmed.split(/\s+/), cursor: 0, pageIdx: 0 });
|
|
820
|
+
setMode('gsearch-list');
|
|
821
|
+
}
|
|
822
|
+
setGsearchInput('');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (key.backspace || key.delete) {
|
|
826
|
+
setGsearchInput(prev => prev.slice(0, -1));
|
|
611
827
|
return;
|
|
612
828
|
}
|
|
613
829
|
if (input && !key.ctrl && !key.meta) {
|
|
614
|
-
|
|
830
|
+
setGsearchInput(prev => prev + input);
|
|
831
|
+
}
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// ── Global-search list mode (`?` modal — browse results) ────────────
|
|
835
|
+
if (mode === 'gsearch-list') {
|
|
836
|
+
if (!gsearchSession) {
|
|
837
|
+
setMode('normal');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const totalResults = gsearchSession.results.length;
|
|
841
|
+
const pageCount = Math.max(1, Math.ceil(totalResults / GSEARCH_PAGE_SIZE));
|
|
842
|
+
const pageStart = gsearchSession.pageIdx * GSEARCH_PAGE_SIZE;
|
|
843
|
+
const pageEnd = Math.min(pageStart + GSEARCH_PAGE_SIZE, totalResults);
|
|
844
|
+
const pageLen = pageEnd - pageStart;
|
|
845
|
+
const returnMode = studyActiveRef.current ? 'study' : 'normal';
|
|
846
|
+
if (key.escape || input === 'q') {
|
|
847
|
+
setMode(returnMode);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (input === '?') {
|
|
851
|
+
setMode('gsearch-input');
|
|
852
|
+
setGsearchInput(gsearchSession.query);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (key.return) {
|
|
856
|
+
const v = gsearchSession.results[pageStart + gsearchSession.cursor];
|
|
857
|
+
if (v) {
|
|
858
|
+
pendingVerseRef.current = v.verse;
|
|
859
|
+
pendingFindTermsRef.current = gsearchSession.terms.length > 0 ? gsearchSession.terms : null;
|
|
860
|
+
// navigate() calls restoreMode() which puts us back in normal/study.
|
|
861
|
+
navigate(v.book, v.chapter);
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (totalResults === 0)
|
|
866
|
+
return;
|
|
867
|
+
// Page navigation: ← prev page, → next page (cursor resets to top of new page).
|
|
868
|
+
if (key.leftArrow || input === 'h') {
|
|
869
|
+
if (gsearchSession.pageIdx > 0) {
|
|
870
|
+
setGsearchSession(s => s ? { ...s, pageIdx: s.pageIdx - 1, cursor: 0 } : s);
|
|
871
|
+
}
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (key.rightArrow || input === 'l') {
|
|
875
|
+
if (gsearchSession.pageIdx < pageCount - 1) {
|
|
876
|
+
setGsearchSession(s => s ? { ...s, pageIdx: s.pageIdx + 1, cursor: 0 } : s);
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (key.upArrow || input === 'k') {
|
|
881
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.max(0, s.cursor - 1) } : s);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (key.downArrow || input === 'j') {
|
|
885
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.min(pageLen - 1, s.cursor + 1) } : s);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (input === 'g') {
|
|
889
|
+
setGsearchSession(s => s ? { ...s, cursor: 0 } : s);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (input === 'G') {
|
|
893
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.max(0, pageLen - 1) } : s);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (key.tab) {
|
|
897
|
+
const step = Math.max(1, Math.floor(pageLen / 4));
|
|
898
|
+
const dir = key.shift ? -1 : 1;
|
|
899
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.max(0, Math.min(pageLen - 1, s.cursor + dir * step)) } : s);
|
|
900
|
+
return;
|
|
615
901
|
}
|
|
616
902
|
return;
|
|
617
903
|
}
|
|
@@ -777,12 +1063,18 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
777
1063
|
setGotoInput('');
|
|
778
1064
|
return;
|
|
779
1065
|
}
|
|
780
|
-
//
|
|
1066
|
+
// Find within chapter
|
|
781
1067
|
if (input === '/') {
|
|
782
|
-
setMode('
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1068
|
+
setMode('find');
|
|
1069
|
+
setFindQuery('');
|
|
1070
|
+
setFindSubmitted(false);
|
|
1071
|
+
setFindMatches([]);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
// Global search (full Bible)
|
|
1075
|
+
if (input === '?') {
|
|
1076
|
+
setMode('gsearch-input');
|
|
1077
|
+
setGsearchInput(gsearchSession?.query ?? '');
|
|
786
1078
|
return;
|
|
787
1079
|
}
|
|
788
1080
|
return;
|
|
@@ -864,8 +1156,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
864
1156
|
return;
|
|
865
1157
|
}
|
|
866
1158
|
if (input === 'n' || key.rightArrow) {
|
|
867
|
-
if (
|
|
868
|
-
|
|
1159
|
+
if (findSubmitted && findMatches.length > 0) {
|
|
1160
|
+
nextFindMatch();
|
|
869
1161
|
}
|
|
870
1162
|
else {
|
|
871
1163
|
goNext();
|
|
@@ -873,7 +1165,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
873
1165
|
return;
|
|
874
1166
|
}
|
|
875
1167
|
if (input === 'N') {
|
|
876
|
-
|
|
1168
|
+
prevFindMatch();
|
|
877
1169
|
return;
|
|
878
1170
|
}
|
|
879
1171
|
if (input === 'p' || key.leftArrow) {
|
|
@@ -889,10 +1181,15 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
889
1181
|
return;
|
|
890
1182
|
}
|
|
891
1183
|
if (input === '/') {
|
|
892
|
-
setMode('
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1184
|
+
setMode('find');
|
|
1185
|
+
setFindQuery('');
|
|
1186
|
+
setFindSubmitted(false);
|
|
1187
|
+
setFindMatches([]);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (input === '?') {
|
|
1191
|
+
setMode('gsearch-input');
|
|
1192
|
+
setGsearchInput(gsearchSession?.query ?? '');
|
|
896
1193
|
return;
|
|
897
1194
|
}
|
|
898
1195
|
if (input === 'c') {
|
|
@@ -1003,18 +1300,34 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1003
1300
|
const homeContent = homeLines.join('\n');
|
|
1004
1301
|
let homeFooter;
|
|
1005
1302
|
if (mode === 'home-chapter') {
|
|
1006
|
-
homeFooter = ` ${
|
|
1303
|
+
homeFooter = ` ${[
|
|
1304
|
+
kb('←/→', 'ch'),
|
|
1305
|
+
kb('0-9', 'jump'),
|
|
1306
|
+
kb('Enter', 'go'),
|
|
1307
|
+
kb('Esc', 'back'),
|
|
1308
|
+
].join(FOOTER_SEP)}`;
|
|
1007
1309
|
}
|
|
1008
1310
|
else {
|
|
1009
1311
|
const lastRead = getLastRead();
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1312
|
+
const parts = [
|
|
1313
|
+
kb('↑↓←→', 'navigate'),
|
|
1314
|
+
kb('Enter', 'select'),
|
|
1315
|
+
];
|
|
1316
|
+
if (lastRead)
|
|
1317
|
+
parts.push(kb('Space', 'continue'));
|
|
1318
|
+
parts.push(kb('a-z', 'jump'));
|
|
1319
|
+
parts.push(kb('q', 'quit'));
|
|
1320
|
+
homeFooter = ` ${parts.join(FOOTER_SEP)}`;
|
|
1012
1321
|
}
|
|
1013
1322
|
const homeSep = chalk.dim('─'.repeat(cols));
|
|
1014
1323
|
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
1324
|
}
|
|
1016
1325
|
// ── Chapter view: build visible content ──────────────────────────────────
|
|
1017
1326
|
const visibleSlice = displayLines.slice(scrollOffset, scrollOffset + viewportH);
|
|
1327
|
+
// Pad to exactly viewportH rows so the panel stays anchored to the bottom even
|
|
1328
|
+
// when scrollOffset > maxScroll (study-mode overscroll for centering).
|
|
1329
|
+
while (visibleSlice.length < viewportH)
|
|
1330
|
+
visibleSlice.push('');
|
|
1018
1331
|
// Apply cursor highlight for copy mode and study mode
|
|
1019
1332
|
const contentLines = (mode === 'copy' || mode === 'study') ? visibleSlice.map((line, viewIdx) => {
|
|
1020
1333
|
const absLine = scrollOffset + viewIdx;
|
|
@@ -1032,16 +1345,43 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1032
1345
|
? chalk.inverse(`› ${stripAnsi(line)}`)
|
|
1033
1346
|
: chalk.inverse(` ${stripAnsi(line)}`);
|
|
1034
1347
|
}
|
|
1035
|
-
// Study mode: subtle — just a › marker on first line, no bg change
|
|
1348
|
+
// Study mode: subtle — just a › marker on first line, no bg change.
|
|
1349
|
+
// Place cursor 3 columns left of the verse number so the gap stays
|
|
1350
|
+
// constant regardless of margin (zoom level).
|
|
1036
1351
|
if (absLine === entry.startLine) {
|
|
1037
|
-
|
|
1352
|
+
const cursorCol = Math.max(0, getMargin() - 3);
|
|
1353
|
+
return `${line.slice(0, cursorCol)}${chalk.cyan('›')}${line.slice(cursorCol + 1)}`;
|
|
1038
1354
|
}
|
|
1039
1355
|
return line;
|
|
1040
1356
|
}
|
|
1041
1357
|
}
|
|
1042
1358
|
return line;
|
|
1043
1359
|
}) : visibleSlice;
|
|
1044
|
-
|
|
1360
|
+
// Global-search list view replaces the chapter content slot.
|
|
1361
|
+
let content;
|
|
1362
|
+
if (mode === 'gsearch-list' && gsearchSession && gsearchListData) {
|
|
1363
|
+
const slice = gsearchListData.lines.slice(gsearchScroll, gsearchScroll + viewportH);
|
|
1364
|
+
while (slice.length < viewportH)
|
|
1365
|
+
slice.push('');
|
|
1366
|
+
if (gsearchSession.results.length === 0) {
|
|
1367
|
+
// Empty-state: center "no results" text in the viewport
|
|
1368
|
+
const msg = chalk.dim(` No results for "${gsearchSession.query}"`);
|
|
1369
|
+
const padTop = Math.max(0, Math.floor(viewportH / 2) - 1);
|
|
1370
|
+
const out = [];
|
|
1371
|
+
for (let i = 0; i < padTop; i++)
|
|
1372
|
+
out.push('');
|
|
1373
|
+
out.push(msg);
|
|
1374
|
+
while (out.length < viewportH)
|
|
1375
|
+
out.push('');
|
|
1376
|
+
content = out.join('\n');
|
|
1377
|
+
}
|
|
1378
|
+
else {
|
|
1379
|
+
content = slice.join('\n');
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
content = contentLines.join('\n');
|
|
1384
|
+
}
|
|
1045
1385
|
// ── Header ───────────────────────────────────────────────────────────────
|
|
1046
1386
|
const CATEGORY_LABELS = {
|
|
1047
1387
|
Moses: 'Books of Moses', Israel: 'History of Israel', Poetry: 'Poetry & Wisdom',
|
|
@@ -1061,45 +1401,115 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1061
1401
|
? ` ${title} ${chalk.dim('·')} ${chalk.dim(categoryLabel)}${zoomLabel}`
|
|
1062
1402
|
: ` ${title}${zoomLabel}`;
|
|
1063
1403
|
const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
|
|
1064
|
-
|
|
1404
|
+
let header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
|
|
1405
|
+
// Override header for the global-search list view
|
|
1406
|
+
if (mode === 'gsearch-list' && gsearchSession) {
|
|
1407
|
+
const totalResults = gsearchSession.results.length;
|
|
1408
|
+
const pageCount = Math.max(1, Math.ceil(totalResults / GSEARCH_PAGE_SIZE));
|
|
1409
|
+
const pageStart = gsearchSession.pageIdx * GSEARCH_PAGE_SIZE;
|
|
1410
|
+
const pageEnd = Math.min(pageStart + GSEARCH_PAGE_SIZE, totalResults);
|
|
1411
|
+
const pageLen = pageEnd - pageStart;
|
|
1412
|
+
const countLabel = totalResults === 0
|
|
1413
|
+
? 'no results'
|
|
1414
|
+
: pageCount > 1
|
|
1415
|
+
? `page ${gsearchSession.pageIdx + 1}/${pageCount} · ${pageStart + 1}–${pageEnd} of ${totalResults}`
|
|
1416
|
+
: `${totalResults} result${totalResults === 1 ? '' : 's'}`;
|
|
1417
|
+
const gsTitleLeft = ` ${chalk.bold(`Search: "${gsearchSession.query}"`)} ${chalk.dim('·')} ${chalk.dim(countLabel)}`;
|
|
1418
|
+
const gsPosition = pageLen > 0 ? `${gsearchSession.cursor + 1}/${pageLen}` : '';
|
|
1419
|
+
const gsPad = Math.max(1, cols - stripAnsi(gsTitleLeft).length - gsPosition.length - 2);
|
|
1420
|
+
header = `${gsTitleLeft}${' '.repeat(gsPad)}${chalk.dim(gsPosition)}`;
|
|
1421
|
+
}
|
|
1065
1422
|
// ── Footer (two lines for normal/study, one line for modal modes) ──────
|
|
1423
|
+
// Toggle indicator: cyan when active (state cue), magenta key + dim desc when off.
|
|
1424
|
+
const toggleHint = (on, key, desc) => on ? chalk.cyan(`${key} ${desc}`) : kb(key, desc);
|
|
1066
1425
|
let footer1;
|
|
1067
1426
|
let footer2 = null;
|
|
1068
1427
|
if (flash) {
|
|
1069
1428
|
footer1 = ` ${chalk.green(`✓ ${flash}`)}`;
|
|
1070
1429
|
}
|
|
1071
|
-
else if (mode === '
|
|
1072
|
-
if (
|
|
1073
|
-
const countLabel =
|
|
1430
|
+
else if (mode === 'find') {
|
|
1431
|
+
if (findSubmitted) {
|
|
1432
|
+
const countLabel = findMatches.length === 0
|
|
1074
1433
|
? 'no matches'
|
|
1075
|
-
: `match ${
|
|
1076
|
-
footer1 = ` / ${
|
|
1434
|
+
: `match ${findIdx + 1}/${findMatches.length}`;
|
|
1435
|
+
footer1 = ` / ${findQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
|
|
1077
1436
|
}
|
|
1078
1437
|
else {
|
|
1079
|
-
footer1 = ` / ${
|
|
1438
|
+
footer1 = ` / ${findQuery}${chalk.dim('_')} ${[kb('Enter', 'find'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
|
|
1080
1439
|
}
|
|
1081
1440
|
}
|
|
1082
1441
|
else if (mode === 'goto') {
|
|
1083
|
-
footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8
|
|
1442
|
+
footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8')}${FOOTER_SEP}${[kb('Enter', 'go'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
|
|
1443
|
+
}
|
|
1444
|
+
else if (mode === 'gsearch-input') {
|
|
1445
|
+
footer1 = ` ? ${gsearchInput}${chalk.dim('_')} ${chalk.dim('try grace · "only begotten" · grace in romans')}${FOOTER_SEP}${[kb('Enter', 'search'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
|
|
1446
|
+
}
|
|
1447
|
+
else if (mode === 'gsearch-list') {
|
|
1448
|
+
const showPageHint = gsearchSession ? gsearchSession.results.length > GSEARCH_PAGE_SIZE : false;
|
|
1449
|
+
const listKeys = [
|
|
1450
|
+
kb('↑↓', 'select'),
|
|
1451
|
+
kb('Tab/⇧Tab', 'quarter'),
|
|
1452
|
+
kb('g/G', 'first/last'),
|
|
1453
|
+
];
|
|
1454
|
+
if (showPageHint)
|
|
1455
|
+
listKeys.push(kb('←→', 'page'));
|
|
1456
|
+
listKeys.push(kb('Enter', 'open'), kb('?', 'edit'), kb('Esc', 'back'));
|
|
1457
|
+
footer1 = ` ${listKeys.join(FOOTER_SEP)}`;
|
|
1084
1458
|
}
|
|
1085
1459
|
else if (mode === 'study') {
|
|
1086
1460
|
const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
|
|
1087
1461
|
const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1462
|
+
footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${[
|
|
1463
|
+
kb('#', 'ref'),
|
|
1464
|
+
kb('v', 'range'),
|
|
1465
|
+
kb('c', 'copy'),
|
|
1466
|
+
toggleHint(notes, 'f', 'notes'),
|
|
1467
|
+
toggleHint(outline, 'o', 'outline'),
|
|
1468
|
+
kb('/', 'find'),
|
|
1469
|
+
kb('?', 'search'),
|
|
1470
|
+
].join(FOOTER_SEP)}`;
|
|
1471
|
+
const f2Parts = [
|
|
1472
|
+
kb('↑↓', 'verse'),
|
|
1473
|
+
kb('Tab/⇧Tab', 'quarter'),
|
|
1474
|
+
kb('g/G', 'top/bot'),
|
|
1475
|
+
kb('←→', 'ch'),
|
|
1476
|
+
kb(':', 'goto'),
|
|
1477
|
+
kb('[/]', 'back/fwd'),
|
|
1478
|
+
kb('d', 'exit'),
|
|
1479
|
+
];
|
|
1480
|
+
if (panelOverflow)
|
|
1481
|
+
f2Parts.push(kb(',/.', 'note'));
|
|
1482
|
+
footer2 = ` ${f2Parts.join(FOOTER_SEP)}`;
|
|
1093
1483
|
}
|
|
1094
1484
|
else if (mode === 'copy') {
|
|
1095
1485
|
const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
|
|
1096
|
-
footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${
|
|
1486
|
+
footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${[
|
|
1487
|
+
kb('↑↓', 'move'),
|
|
1488
|
+
kb('v', 'range'),
|
|
1489
|
+
kb('Enter', 'copy'),
|
|
1490
|
+
kb('Esc', 'cancel'),
|
|
1491
|
+
].join(FOOTER_SEP)}`;
|
|
1097
1492
|
}
|
|
1098
1493
|
else {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1494
|
+
footer1 = ` ${[
|
|
1495
|
+
toggleHint(notes, 'f', 'notes'),
|
|
1496
|
+
toggleHint(outline, 'o', 'outline'),
|
|
1497
|
+
kb('d', 'study'),
|
|
1498
|
+
kb('c', 'copy'),
|
|
1499
|
+
kb('/', 'find'),
|
|
1500
|
+
kb('?', 'search'),
|
|
1501
|
+
].join(FOOTER_SEP)}`;
|
|
1502
|
+
footer2 = ` ${[
|
|
1503
|
+
kb('↑↓', 'scroll'),
|
|
1504
|
+
kb('Tab/⇧Tab', 'quarter'),
|
|
1505
|
+
kb('g/G', 'top/bot'),
|
|
1506
|
+
kb('←→', 'ch'),
|
|
1507
|
+
kb('+/-', 'width'),
|
|
1508
|
+
kb(':', 'goto'),
|
|
1509
|
+
kb('H', 'home'),
|
|
1510
|
+
kb('[/]', 'back/fwd'),
|
|
1511
|
+
kb('q', 'quit'),
|
|
1512
|
+
].join(FOOTER_SEP)}`;
|
|
1103
1513
|
}
|
|
1104
1514
|
const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
|
|
1105
1515
|
// ── Render ───────────────────────────────────────────────────────────────
|
|
@@ -1110,7 +1520,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1110
1520
|
const lo = panelScrollClamped + 1;
|
|
1111
1521
|
const hi = panelScrollClamped + panelVisibleLines.length;
|
|
1112
1522
|
const total = panelLinesAll.length;
|
|
1113
|
-
parts.push(chalk.dim(
|
|
1523
|
+
parts.push(` ${chalk.dim(`── ${lo}–${hi} of ${total} ·`)} ${kb(',/.', 'scroll')} ${chalk.dim('──')}`);
|
|
1114
1524
|
}
|
|
1115
1525
|
studyPanelContent = parts.join('\n');
|
|
1116
1526
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rv-bible-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|