rv-bible-cli 0.1.0
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 +159 -0
- package/dist/db.d.ts +63 -0
- package/dist/db.js +153 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.js +258 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +561 -0
- package/dist/parser.d.ts +20 -0
- package/dist/parser.js +320 -0
- package/dist/state.d.ts +6 -0
- package/dist/state.js +30 -0
- package/dist/ui/Pager.d.ts +5 -0
- package/dist/ui/Pager.js +999 -0
- package/dist/ui/nav.d.ts +6 -0
- package/dist/ui/nav.js +37 -0
- package/package.json +60 -0
- package/rv.db +0 -0
package/dist/ui/Pager.js
ADDED
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import clipboard from 'clipboardy';
|
|
6
|
+
import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, } from '../db.js';
|
|
7
|
+
import { renderVerses, stripMarkers, highlightTerms, toSuperscript } from '../format.js';
|
|
8
|
+
import { saveLastRead, getLastRead } from '../state.js';
|
|
9
|
+
import { resolveBook } from '../parser.js';
|
|
10
|
+
import { getNextChapter, getPrevChapter } from './nav.js';
|
|
11
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
12
|
+
function stripAnsi(s) {
|
|
13
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
14
|
+
}
|
|
15
|
+
// Detect verse-number lines by checking for chalk.dim() ANSI code (\x1b[2m) before
|
|
16
|
+
// a superscript character. This distinguishes verse numbers (dim) from inline footnote
|
|
17
|
+
// markers (cyan) that could appear at the start of word-wrapped continuation lines.
|
|
18
|
+
// Fallback regex (no ANSI) for edge cases where chalk colors are disabled.
|
|
19
|
+
const VERSE_NUM_ANSI = /^\s+\x1b\[2m[\u2070-\u209F\u00B9\u00B2\u00B3]/;
|
|
20
|
+
const VERSE_NUM_PLAIN = /^\s{2,4}[\u2070-\u209F\u00B9\u00B2\u00B3]/;
|
|
21
|
+
function buildVerseLineMap(lines, verses) {
|
|
22
|
+
// Pick regex: check if first lines contain ANSI codes at all
|
|
23
|
+
const hasAnsi = lines.some(l => /\x1b\[/.test(l));
|
|
24
|
+
const re = hasAnsi ? VERSE_NUM_ANSI : VERSE_NUM_PLAIN;
|
|
25
|
+
const map = [];
|
|
26
|
+
let vIdx = 0;
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
if (re.test(lines[i]) && vIdx < verses.length) {
|
|
29
|
+
if (map.length > 0)
|
|
30
|
+
map[map.length - 1].endLine = i - 1;
|
|
31
|
+
map.push({ verse: verses[vIdx].verse, startLine: i, endLine: i });
|
|
32
|
+
vIdx++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (map.length > 0)
|
|
36
|
+
map[map.length - 1].endLine = lines.length - 1;
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
function verseAtLine(map, line) {
|
|
40
|
+
for (let i = map.length - 1; i >= 0; i--) {
|
|
41
|
+
if (map[i].startLine <= line)
|
|
42
|
+
return i;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
// ── Home screen helpers ──────────────────────────────────────────────────────
|
|
47
|
+
const GRID_COLS = 12;
|
|
48
|
+
function getBookGrid() {
|
|
49
|
+
const all = getAllBooks();
|
|
50
|
+
return { ot: all.filter(b => b.testament === 'OT'), nt: all.filter(b => b.testament === 'NT') };
|
|
51
|
+
}
|
|
52
|
+
function flatBookList() {
|
|
53
|
+
const { ot, nt } = getBookGrid();
|
|
54
|
+
return [...ot, ...nt];
|
|
55
|
+
}
|
|
56
|
+
const OT_COUNT = 39;
|
|
57
|
+
const TOTAL_BOOKS = 66;
|
|
58
|
+
const OT_ROWS = Math.ceil(OT_COUNT / GRID_COLS);
|
|
59
|
+
const NT_ROWS = Math.ceil((TOTAL_BOOKS - OT_COUNT) / GRID_COLS);
|
|
60
|
+
// Navigate the home grid: handles OT↔NT boundary, row/col aware
|
|
61
|
+
function gridMove(idx, dir) {
|
|
62
|
+
if (dir === 'left')
|
|
63
|
+
return Math.max(0, idx - 1);
|
|
64
|
+
if (dir === 'right')
|
|
65
|
+
return Math.min(TOTAL_BOOKS - 1, idx + 1);
|
|
66
|
+
const inOT = idx < OT_COUNT;
|
|
67
|
+
const sectionStart = inOT ? 0 : OT_COUNT;
|
|
68
|
+
const localIdx = idx - sectionStart;
|
|
69
|
+
const row = Math.floor(localIdx / GRID_COLS);
|
|
70
|
+
const col = localIdx % GRID_COLS;
|
|
71
|
+
if (dir === 'down') {
|
|
72
|
+
if (inOT) {
|
|
73
|
+
const nextRow = row + 1;
|
|
74
|
+
if (nextRow < OT_ROWS) {
|
|
75
|
+
// Stay in OT, clamp to last book in that row
|
|
76
|
+
return Math.min(sectionStart + nextRow * GRID_COLS + col, OT_COUNT - 1);
|
|
77
|
+
}
|
|
78
|
+
// Jump to NT row 0, same column
|
|
79
|
+
return Math.min(OT_COUNT + col, TOTAL_BOOKS - 1);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const nextRow = row + 1;
|
|
83
|
+
if (nextRow < NT_ROWS) {
|
|
84
|
+
return Math.min(sectionStart + nextRow * GRID_COLS + col, TOTAL_BOOKS - 1);
|
|
85
|
+
}
|
|
86
|
+
return idx; // already at bottom
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// dir === 'up'
|
|
90
|
+
if (inOT) {
|
|
91
|
+
if (row > 0) {
|
|
92
|
+
return sectionStart + (row - 1) * GRID_COLS + col;
|
|
93
|
+
}
|
|
94
|
+
return idx; // already at top
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
if (row > 0) {
|
|
98
|
+
return Math.min(sectionStart + (row - 1) * GRID_COLS + col, TOTAL_BOOKS - 1);
|
|
99
|
+
}
|
|
100
|
+
// Jump to OT last row, same column
|
|
101
|
+
const otLastRowStart = (OT_ROWS - 1) * GRID_COLS;
|
|
102
|
+
return Math.min(otLastRowStart + col, OT_COUNT - 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ── Pager Component ──────────────────────────────────────────────────────────
|
|
106
|
+
function Pager({ initialBook, initialChapter, initialNotes, initialOutline, startHome }) {
|
|
107
|
+
const { exit } = useApp();
|
|
108
|
+
const { stdout } = useStdout();
|
|
109
|
+
// Chapter position (state + refs for useInput access)
|
|
110
|
+
const [book, setBook] = useState(initialBook ?? 'Gen');
|
|
111
|
+
const [chapter, setChapter] = useState(initialChapter ?? 1);
|
|
112
|
+
const bookRef = useRef(book);
|
|
113
|
+
const chapterRef = useRef(chapter);
|
|
114
|
+
useEffect(() => { bookRef.current = book; }, [book]);
|
|
115
|
+
useEffect(() => { chapterRef.current = chapter; }, [chapter]);
|
|
116
|
+
// Display toggles
|
|
117
|
+
const [notes, setNotes] = useState(initialNotes);
|
|
118
|
+
const [outline, setOutline] = useState(initialOutline);
|
|
119
|
+
// Scroll
|
|
120
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
121
|
+
const scrollRef = useRef(scrollOffset);
|
|
122
|
+
useEffect(() => { scrollRef.current = scrollOffset; }, [scrollOffset]);
|
|
123
|
+
// Mode
|
|
124
|
+
const [mode, setMode] = useState(startHome ? 'home' : 'normal');
|
|
125
|
+
// Copy mode state
|
|
126
|
+
const [cursorIdx, setCursorIdx] = useState(0);
|
|
127
|
+
const [selStart, setSelStart] = useState(null);
|
|
128
|
+
// Search mode state
|
|
129
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
130
|
+
const [searchSubmitted, setSearchSubmitted] = useState(false);
|
|
131
|
+
const [searchMatches, setSearchMatches] = useState([]);
|
|
132
|
+
const [matchIdx, setMatchIdx] = useState(0);
|
|
133
|
+
// Goto mode state
|
|
134
|
+
const [gotoInput, setGotoInput] = useState('');
|
|
135
|
+
// Navigation history: back/forward stacks (refs so useInput always sees latest)
|
|
136
|
+
const historyRef = useRef([]);
|
|
137
|
+
const forwardRef = useRef([]);
|
|
138
|
+
// Home mode state
|
|
139
|
+
const [homeIdx, setHomeIdx] = useState(0); // index into flat book list
|
|
140
|
+
const [homeChapter, setHomeChapter] = useState(1); // chapter selector
|
|
141
|
+
const allBooks = useMemo(() => flatBookList(), []);
|
|
142
|
+
const lastLetterRef = useRef(''); // tracks last typed letter for cycling
|
|
143
|
+
// Study mode state
|
|
144
|
+
const [studyCursorIdx, setStudyCursorIdx] = useState(0);
|
|
145
|
+
const [studySelStart, setStudySelStart] = useState(null);
|
|
146
|
+
const [studyRefInput, setStudyRefInput] = useState(''); // multi-digit ref number
|
|
147
|
+
const studyActiveRef = useRef(false); // persists across navigate so study mode survives ref follows
|
|
148
|
+
const pendingVerseRef = useRef(null); // target verse to jump to after navigate
|
|
149
|
+
// Flash message
|
|
150
|
+
const [flash, setFlash] = useState('');
|
|
151
|
+
// Terminal dimensions
|
|
152
|
+
const rows = stdout?.rows ?? 24;
|
|
153
|
+
const cols = stdout?.columns ?? 80;
|
|
154
|
+
// ── Chapter data (derived) ───────────────────────────────────────────────
|
|
155
|
+
const { verses, lines, verseMap, bookName, chapterCount } = useMemo(() => {
|
|
156
|
+
if (mode === 'home' || mode === 'home-chapter') {
|
|
157
|
+
return { verses: [], lines: [], verseMap: [], bookName: '', chapterCount: 0 };
|
|
158
|
+
}
|
|
159
|
+
const ref = { book, chapter };
|
|
160
|
+
const v = getVersesByRef(ref);
|
|
161
|
+
const info = getBookInfo(book);
|
|
162
|
+
const bName = info?.full_name ?? book;
|
|
163
|
+
const cCount = info?.chapter_count ?? 1;
|
|
164
|
+
const headers = outline ? getSectionHeaders(book) : [];
|
|
165
|
+
// In study mode: show inline markers but skip the footnote block (panel handles it)
|
|
166
|
+
const showBlock = notes && !studyActiveRef.current;
|
|
167
|
+
const footnotes = showBlock ? getFootnotesForChapter(book, chapter) : [];
|
|
168
|
+
const title = `${bName} ${chapter}`;
|
|
169
|
+
const rendered = renderVerses(v, headers, footnotes, { notes, title });
|
|
170
|
+
const ls = rendered.split('\n');
|
|
171
|
+
const vm = buildVerseLineMap(ls, v);
|
|
172
|
+
return { verses: v, lines: ls, verseMap: vm, bookName: bName, chapterCount: cCount };
|
|
173
|
+
}, [book, chapter, notes, outline, cols, mode]);
|
|
174
|
+
// ── Jump to pending target verse after navigate ───────────────────────
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const target = pendingVerseRef.current;
|
|
177
|
+
if (!target || verseMap.length === 0)
|
|
178
|
+
return;
|
|
179
|
+
pendingVerseRef.current = null;
|
|
180
|
+
// Find the verse in the map (handle sub-verses: '8' matches '8', '8a', '8b')
|
|
181
|
+
let idx = verseMap.findIndex(e => e.verse === target);
|
|
182
|
+
if (idx < 0)
|
|
183
|
+
idx = verseMap.findIndex(e => e.verse.startsWith(target));
|
|
184
|
+
if (idx >= 0) {
|
|
185
|
+
setStudyCursorIdx(idx);
|
|
186
|
+
const entry = verseMap[idx];
|
|
187
|
+
setScrollOffset(Math.max(0, entry.startLine - 2)); // show a couple lines of context above
|
|
188
|
+
}
|
|
189
|
+
}, [verseMap]);
|
|
190
|
+
const studyPanel = useMemo(() => {
|
|
191
|
+
if (mode !== 'study' || verseMap.length === 0)
|
|
192
|
+
return { lines: [], refs: [] };
|
|
193
|
+
const entry = verseMap[studyCursorIdx];
|
|
194
|
+
if (!entry)
|
|
195
|
+
return { lines: [], refs: [] };
|
|
196
|
+
const v = verses.find(vv => vv.verse === entry.verse);
|
|
197
|
+
if (!v)
|
|
198
|
+
return { lines: [], refs: [] };
|
|
199
|
+
// Get footnotes for this verse (only markers that appear inline)
|
|
200
|
+
const allFootnotes = getFootnotesForVerses(book, chapter, [v.verse]);
|
|
201
|
+
const inlineMarkers = new Set([...v.text.matchAll(/\[([^\]]+)\]/g)].map(m => m[1]));
|
|
202
|
+
const fns = allFootnotes.filter(f => inlineMarkers.has(f.marker));
|
|
203
|
+
// Get cross-refs for this verse, grouped by marker
|
|
204
|
+
const crossRefs = getCrossRefsForVerse(book, chapter, v.verse);
|
|
205
|
+
const refsByMarker = new Map();
|
|
206
|
+
for (const cr of crossRefs) {
|
|
207
|
+
const list = refsByMarker.get(cr.src_marker) ?? [];
|
|
208
|
+
list.push(cr);
|
|
209
|
+
refsByMarker.set(cr.src_marker, list);
|
|
210
|
+
}
|
|
211
|
+
// Build numbered refs and display lines
|
|
212
|
+
const studyRefs = [];
|
|
213
|
+
const panelLines = [];
|
|
214
|
+
const verseLabel = `${bookName} ${chapter}:${v.verse}`;
|
|
215
|
+
panelLines.push(` ${chalk.bold(verseLabel)} ${chalk.dim(`— ${fns.length} note${fns.length === 1 ? '' : 's'}`)}`);
|
|
216
|
+
for (const fn of fns) {
|
|
217
|
+
// Extract the word the marker attaches to (first word after "Book ch:v word" in footnote text)
|
|
218
|
+
const fnTextParts = fn.text.split(/\s+/);
|
|
219
|
+
const word = fnTextParts[2] ?? ''; // "Joh 3:16 loved ..." → "loved"
|
|
220
|
+
const markerDisp = toSuperscript(fn.marker);
|
|
221
|
+
const refs = refsByMarker.get(fn.marker) ?? [];
|
|
222
|
+
// Deduplicate refs by target
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
const uniqueRefs = refs.filter(r => {
|
|
225
|
+
const key = `${r.tgt_book}:${r.tgt_chapter}:${r.tgt_verse}`;
|
|
226
|
+
if (seen.has(key))
|
|
227
|
+
return false;
|
|
228
|
+
seen.add(key);
|
|
229
|
+
return true;
|
|
230
|
+
});
|
|
231
|
+
if (uniqueRefs.length === 0) {
|
|
232
|
+
// Study note with no cross-refs — show truncated text
|
|
233
|
+
const notePreview = fn.text.length > 60 ? fn.text.substring(0, 57) + '...' : fn.text;
|
|
234
|
+
panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)} ${chalk.dim(notePreview)}`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// Build numbered ref labels
|
|
238
|
+
const refLabels = [];
|
|
239
|
+
for (const r of uniqueRefs) {
|
|
240
|
+
const refNum = studyRefs.length + 1;
|
|
241
|
+
const tgtBookName = getBookInfo(r.tgt_book)?.abbr ?? r.tgt_book;
|
|
242
|
+
const label = `${tgtBookName} ${r.tgt_chapter}:${r.tgt_verse}`;
|
|
243
|
+
studyRefs.push({
|
|
244
|
+
num: refNum, marker: fn.marker,
|
|
245
|
+
tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
|
|
246
|
+
});
|
|
247
|
+
refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
|
|
248
|
+
}
|
|
249
|
+
// Compact: marker + word + refs on one line (wrap if needed)
|
|
250
|
+
const hasStudyNote = fn.text.length > 80;
|
|
251
|
+
const noteHint = hasStudyNote ? chalk.dim(' (note)') : '';
|
|
252
|
+
panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${noteHint} ${refLabels.join(' ')}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { lines: panelLines, refs: studyRefs };
|
|
256
|
+
}, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName]);
|
|
257
|
+
// ── Viewport height (shrinks when study panel is visible) ────────────
|
|
258
|
+
const studyPanelH = studyPanel.lines.length > 0 ? studyPanel.lines.length + 1 : 0; // +1 for separator
|
|
259
|
+
const hasFooter2 = mode === 'normal' || mode === 'study';
|
|
260
|
+
const chromeH = 3 + (hasFooter2 ? 2 : 1) + studyPanelH; // header + sep + footer(s) + sep + panel
|
|
261
|
+
const viewportH = Math.max(1, rows - chromeH);
|
|
262
|
+
// Clamp scroll when content or viewport changes
|
|
263
|
+
const maxScroll = Math.max(0, lines.length - viewportH);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
setScrollOffset(prev => Math.min(prev, maxScroll));
|
|
266
|
+
}, [maxScroll]);
|
|
267
|
+
// ── Search highlights ────────────────────────────────────────────────────
|
|
268
|
+
const displayLines = useMemo(() => {
|
|
269
|
+
if (!searchSubmitted || searchMatches.length === 0)
|
|
270
|
+
return lines;
|
|
271
|
+
const q = searchQuery.toLowerCase();
|
|
272
|
+
return lines.map((line) => {
|
|
273
|
+
const stripped = stripAnsi(line);
|
|
274
|
+
if (!stripped.toLowerCase().includes(q))
|
|
275
|
+
return line;
|
|
276
|
+
return highlightTerms(stripped, [searchQuery]);
|
|
277
|
+
});
|
|
278
|
+
}, [lines, searchSubmitted, searchMatches, searchQuery]);
|
|
279
|
+
// ── Navigation helpers ───────────────────────────────────────────────────
|
|
280
|
+
const restoreMode = useCallback(() => {
|
|
281
|
+
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
282
|
+
setSearchSubmitted(false);
|
|
283
|
+
setSearchQuery('');
|
|
284
|
+
setSearchMatches([]);
|
|
285
|
+
setStudyRefInput('');
|
|
286
|
+
setStudyCursorIdx(0);
|
|
287
|
+
}, []);
|
|
288
|
+
const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
|
|
289
|
+
if (pushHistory) {
|
|
290
|
+
historyRef.current = [...historyRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
|
|
291
|
+
forwardRef.current = [];
|
|
292
|
+
}
|
|
293
|
+
setBook(newBook);
|
|
294
|
+
setChapter(newChapter);
|
|
295
|
+
setScrollOffset(0);
|
|
296
|
+
restoreMode();
|
|
297
|
+
saveLastRead(newBook, newChapter);
|
|
298
|
+
}, [restoreMode]);
|
|
299
|
+
const goBack = useCallback(() => {
|
|
300
|
+
const hist = historyRef.current;
|
|
301
|
+
if (hist.length === 0)
|
|
302
|
+
return;
|
|
303
|
+
const last = hist[hist.length - 1];
|
|
304
|
+
forwardRef.current = [...forwardRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
|
|
305
|
+
historyRef.current = hist.slice(0, -1);
|
|
306
|
+
setBook(last.book);
|
|
307
|
+
setChapter(last.chapter);
|
|
308
|
+
setScrollOffset(last.scroll);
|
|
309
|
+
restoreMode();
|
|
310
|
+
saveLastRead(last.book, last.chapter);
|
|
311
|
+
}, [restoreMode]);
|
|
312
|
+
const goForward = useCallback(() => {
|
|
313
|
+
const fwd = forwardRef.current;
|
|
314
|
+
if (fwd.length === 0)
|
|
315
|
+
return;
|
|
316
|
+
const next = fwd[fwd.length - 1];
|
|
317
|
+
historyRef.current = [...historyRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
|
|
318
|
+
forwardRef.current = fwd.slice(0, -1);
|
|
319
|
+
setBook(next.book);
|
|
320
|
+
setChapter(next.chapter);
|
|
321
|
+
setScrollOffset(next.scroll);
|
|
322
|
+
restoreMode();
|
|
323
|
+
saveLastRead(next.book, next.chapter);
|
|
324
|
+
}, [restoreMode]);
|
|
325
|
+
const goNext = useCallback(() => {
|
|
326
|
+
const next = getNextChapter(book, chapter);
|
|
327
|
+
if (next)
|
|
328
|
+
navigate(next.book, next.chapter);
|
|
329
|
+
}, [book, chapter, navigate]);
|
|
330
|
+
const goPrev = useCallback(() => {
|
|
331
|
+
const prev = getPrevChapter(book, chapter);
|
|
332
|
+
if (prev)
|
|
333
|
+
navigate(prev.book, prev.chapter);
|
|
334
|
+
}, [book, chapter, navigate]);
|
|
335
|
+
// ── Scroll helpers ───────────────────────────────────────────────────────
|
|
336
|
+
const scrollTo = useCallback((n) => {
|
|
337
|
+
setScrollOffset(Math.max(0, Math.min(n, maxScroll)));
|
|
338
|
+
}, [maxScroll]);
|
|
339
|
+
const scrollDown = useCallback(() => scrollTo(scrollOffset + 1), [scrollOffset, scrollTo]);
|
|
340
|
+
const scrollUp = useCallback(() => scrollTo(scrollOffset - 1), [scrollOffset, scrollTo]);
|
|
341
|
+
const pageDown = useCallback(() => scrollTo(scrollOffset + viewportH - 2), [scrollOffset, viewportH, scrollTo]);
|
|
342
|
+
const pageUp = useCallback(() => scrollTo(scrollOffset - viewportH + 2), [scrollOffset, viewportH, scrollTo]);
|
|
343
|
+
// ── Copy helpers ─────────────────────────────────────────────────────────
|
|
344
|
+
const doCopy = useCallback(async () => {
|
|
345
|
+
const lo = selStart !== null ? Math.min(selStart, cursorIdx) : cursorIdx;
|
|
346
|
+
const hi = selStart !== null ? Math.max(selStart, cursorIdx) : cursorIdx;
|
|
347
|
+
const selected = verses.slice(lo, hi + 1);
|
|
348
|
+
if (selected.length === 0)
|
|
349
|
+
return;
|
|
350
|
+
const text = selected.map(v => `${bookName} ${v.chapter}:${v.verse} ${stripMarkers(v.text)}`).join('\n');
|
|
351
|
+
await clipboard.write(text);
|
|
352
|
+
const refLabel = selected.length === 1
|
|
353
|
+
? `${bookName} ${chapter}:${selected[0].verse}`
|
|
354
|
+
: `${bookName} ${chapter}:${selected[0].verse}–${selected[selected.length - 1].verse}`;
|
|
355
|
+
setFlash(`Copied ${refLabel}`);
|
|
356
|
+
setTimeout(() => setFlash(''), 2000);
|
|
357
|
+
setMode('normal');
|
|
358
|
+
setSelStart(null);
|
|
359
|
+
}, [selStart, cursorIdx, verses, bookName, chapter]);
|
|
360
|
+
// ── Search helpers ───────────────────────────────────────────────────────
|
|
361
|
+
const submitSearch = useCallback(() => {
|
|
362
|
+
if (!searchQuery)
|
|
363
|
+
return;
|
|
364
|
+
const q = searchQuery.toLowerCase();
|
|
365
|
+
const matches = [];
|
|
366
|
+
for (let i = 0; i < lines.length; i++) {
|
|
367
|
+
if (stripAnsi(lines[i]).toLowerCase().includes(q))
|
|
368
|
+
matches.push(i);
|
|
369
|
+
}
|
|
370
|
+
setSearchMatches(matches);
|
|
371
|
+
setSearchSubmitted(true);
|
|
372
|
+
setMatchIdx(0);
|
|
373
|
+
if (matches.length > 0)
|
|
374
|
+
scrollTo(matches[0]);
|
|
375
|
+
}, [searchQuery, lines, scrollTo]);
|
|
376
|
+
const nextMatch = useCallback(() => {
|
|
377
|
+
if (searchMatches.length === 0)
|
|
378
|
+
return;
|
|
379
|
+
const next = (matchIdx + 1) % searchMatches.length;
|
|
380
|
+
setMatchIdx(next);
|
|
381
|
+
scrollTo(searchMatches[next]);
|
|
382
|
+
}, [matchIdx, searchMatches, scrollTo]);
|
|
383
|
+
const prevMatch = useCallback(() => {
|
|
384
|
+
if (searchMatches.length === 0)
|
|
385
|
+
return;
|
|
386
|
+
const prev = (matchIdx - 1 + searchMatches.length) % searchMatches.length;
|
|
387
|
+
setMatchIdx(prev);
|
|
388
|
+
scrollTo(searchMatches[prev]);
|
|
389
|
+
}, [matchIdx, searchMatches, scrollTo]);
|
|
390
|
+
// ── Goto helpers ─────────────────────────────────────────────────────────
|
|
391
|
+
const submitGoto = useCallback(() => {
|
|
392
|
+
const trimmed = gotoInput.trim();
|
|
393
|
+
if (!trimmed) {
|
|
394
|
+
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// Try parsing as "book chapter" or just "book"
|
|
398
|
+
const parts = trimmed.split(/\s+/);
|
|
399
|
+
// Try longest book match first (e.g. "song of songs 1")
|
|
400
|
+
for (let nameLen = Math.min(parts.length, 4); nameLen >= 1; nameLen--) {
|
|
401
|
+
const bookCandidate = parts.slice(0, nameLen).join(' ');
|
|
402
|
+
const resolved = resolveBook(bookCandidate);
|
|
403
|
+
if (resolved) {
|
|
404
|
+
const chNum = parts[nameLen] ? parseInt(parts[nameLen], 10) : 1;
|
|
405
|
+
const info = getBookInfo(resolved);
|
|
406
|
+
const validCh = Math.max(1, Math.min(chNum || 1, info?.chapter_count ?? 1));
|
|
407
|
+
navigate(resolved, validCh);
|
|
408
|
+
setGotoInput('');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// No match — flash error
|
|
413
|
+
setFlash(`Unknown: "${trimmed}"`);
|
|
414
|
+
setTimeout(() => setFlash(''), 2000);
|
|
415
|
+
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
416
|
+
setGotoInput('');
|
|
417
|
+
}, [gotoInput, navigate]);
|
|
418
|
+
// ── Keyboard input ───────────────────────────────────────────────────────
|
|
419
|
+
useInput((input, key) => {
|
|
420
|
+
// ── Home mode: book grid browser ─────────────────────────────────────
|
|
421
|
+
if (mode === 'home') {
|
|
422
|
+
if (key.escape || input === 'q') {
|
|
423
|
+
if (initialBook) {
|
|
424
|
+
setMode('normal');
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
exit();
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// Space = continue reading last-read chapter
|
|
432
|
+
if (input === ' ') {
|
|
433
|
+
const last = getLastRead();
|
|
434
|
+
if (last)
|
|
435
|
+
navigate(last.book, last.chapter);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (key.return) {
|
|
439
|
+
const selectedBook = allBooks[homeIdx];
|
|
440
|
+
if (selectedBook) {
|
|
441
|
+
const info = getBookInfo(selectedBook.abbr);
|
|
442
|
+
if (info && info.chapter_count === 1) {
|
|
443
|
+
navigate(selectedBook.abbr, 1);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
setHomeChapter(1);
|
|
447
|
+
setMode('home-chapter');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// Arrow keys only for grid navigation (no j/k/h/l — those are type-ahead)
|
|
453
|
+
if (key.rightArrow) {
|
|
454
|
+
setHomeIdx(prev => gridMove(prev, 'right'));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (key.leftArrow) {
|
|
458
|
+
setHomeIdx(prev => gridMove(prev, 'left'));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (key.downArrow) {
|
|
462
|
+
setHomeIdx(prev => gridMove(prev, 'down'));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (key.upArrow) {
|
|
466
|
+
setHomeIdx(prev => gridMove(prev, 'up'));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// All letter/number keys → type-ahead jump; repeat same letter cycles matches
|
|
470
|
+
if (input && /^[a-zA-Z0-9]$/.test(input)) {
|
|
471
|
+
const lower = input.toLowerCase();
|
|
472
|
+
const matches = allBooks
|
|
473
|
+
.map((b, i) => ({ i, b }))
|
|
474
|
+
.filter(({ b }) => b.abbr.toLowerCase().startsWith(lower) || b.full_name.toLowerCase().startsWith(lower));
|
|
475
|
+
if (matches.length > 0) {
|
|
476
|
+
if (lower === lastLetterRef.current) {
|
|
477
|
+
// Same letter again: cycle to next match after current position
|
|
478
|
+
const nextMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
|
|
479
|
+
setHomeIdx(nextMatch.i);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
setHomeIdx(matches[0].i);
|
|
483
|
+
}
|
|
484
|
+
lastLetterRef.current = lower;
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// ── Home-chapter mode: chapter number selector ───────────────────────
|
|
491
|
+
if (mode === 'home-chapter') {
|
|
492
|
+
const selectedBook = allBooks[homeIdx];
|
|
493
|
+
const maxCh = selectedBook ? (getBookInfo(selectedBook.abbr)?.chapter_count ?? 1) : 1;
|
|
494
|
+
if (key.escape) {
|
|
495
|
+
setMode('home');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (key.return) {
|
|
499
|
+
if (selectedBook)
|
|
500
|
+
navigate(selectedBook.abbr, homeChapter);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (key.rightArrow || input === 'l') {
|
|
504
|
+
setHomeChapter(prev => Math.min(prev + 1, maxCh));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (key.leftArrow || input === 'h') {
|
|
508
|
+
setHomeChapter(prev => Math.max(prev - 1, 1));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (key.downArrow || input === 'j') {
|
|
512
|
+
setHomeChapter(prev => Math.min(prev + 10, maxCh));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (key.upArrow || input === 'k') {
|
|
516
|
+
setHomeChapter(prev => Math.max(prev - 10, 1));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// Number keys: build chapter number
|
|
520
|
+
if (input && /^[0-9]$/.test(input)) {
|
|
521
|
+
setHomeChapter(prev => {
|
|
522
|
+
const next = prev * 10 + parseInt(input, 10);
|
|
523
|
+
return next > maxCh ? parseInt(input, 10) : next;
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// ── Search mode ──────────────────────────────────────────────────────
|
|
530
|
+
if (mode === 'search') {
|
|
531
|
+
const returnMode = studyActiveRef.current ? 'study' : 'normal';
|
|
532
|
+
if (key.escape) {
|
|
533
|
+
setMode(returnMode);
|
|
534
|
+
setSearchQuery('');
|
|
535
|
+
setSearchSubmitted(false);
|
|
536
|
+
setSearchMatches([]);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (key.return) {
|
|
540
|
+
submitSearch();
|
|
541
|
+
setMode(returnMode);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (key.backspace || key.delete) {
|
|
545
|
+
setSearchQuery(prev => prev.slice(0, -1));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (searchSubmitted && input === 'n') {
|
|
549
|
+
nextMatch();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (searchSubmitted && input === 'N') {
|
|
553
|
+
prevMatch();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (input && !key.ctrl && !key.meta) {
|
|
557
|
+
setSearchQuery(prev => prev + input);
|
|
558
|
+
}
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
// ── Goto mode ────────────────────────────────────────────────────────
|
|
562
|
+
if (mode === 'goto') {
|
|
563
|
+
if (key.escape) {
|
|
564
|
+
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
565
|
+
setGotoInput('');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (key.return) {
|
|
569
|
+
submitGoto();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (key.backspace || key.delete) {
|
|
573
|
+
setGotoInput(prev => prev.slice(0, -1));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (input && !key.ctrl && !key.meta) {
|
|
577
|
+
setGotoInput(prev => prev + input);
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
// ── Study mode ───────────────────────────────────────────────────────
|
|
582
|
+
if (mode === 'study') {
|
|
583
|
+
if (key.escape || input === 'd') {
|
|
584
|
+
setMode('normal');
|
|
585
|
+
studyActiveRef.current = false;
|
|
586
|
+
setStudyRefInput('');
|
|
587
|
+
setStudySelStart(null);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (input === '[') {
|
|
591
|
+
goBack();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (input === ']') {
|
|
595
|
+
goForward();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (input === 'j' || key.downArrow) {
|
|
599
|
+
setStudyCursorIdx(prev => {
|
|
600
|
+
const next = Math.min(prev + 1, verseMap.length - 1);
|
|
601
|
+
const entry = verseMap[next];
|
|
602
|
+
if (entry && entry.startLine >= scrollOffset + viewportH) {
|
|
603
|
+
scrollTo(entry.startLine - viewportH + 3);
|
|
604
|
+
}
|
|
605
|
+
return next;
|
|
606
|
+
});
|
|
607
|
+
setStudyRefInput('');
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (input === 'k' || key.upArrow) {
|
|
611
|
+
setStudyCursorIdx(prev => {
|
|
612
|
+
const next = Math.max(prev - 1, 0);
|
|
613
|
+
const entry = verseMap[next];
|
|
614
|
+
if (entry && entry.startLine < scrollOffset) {
|
|
615
|
+
scrollTo(entry.startLine);
|
|
616
|
+
}
|
|
617
|
+
return next;
|
|
618
|
+
});
|
|
619
|
+
setStudyRefInput('');
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// Number keys: build ref number, then follow on Enter or after short delay
|
|
623
|
+
if (input && /^[0-9]$/.test(input)) {
|
|
624
|
+
const newInput = studyRefInput + input;
|
|
625
|
+
setStudyRefInput(newInput);
|
|
626
|
+
const num = parseInt(newInput, 10);
|
|
627
|
+
// Auto-follow if the number can't grow to match any ref
|
|
628
|
+
const maxRef = studyPanel.refs.length;
|
|
629
|
+
if (num > 0 && num <= maxRef && num * 10 > maxRef) {
|
|
630
|
+
// Unambiguous — follow immediately
|
|
631
|
+
const ref = studyPanel.refs[num - 1];
|
|
632
|
+
setStudyRefInput('');
|
|
633
|
+
pendingVerseRef.current = ref.tgt_verse;
|
|
634
|
+
navigate(ref.tgt_book, ref.tgt_chapter);
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (key.return && studyRefInput) {
|
|
639
|
+
const num = parseInt(studyRefInput, 10);
|
|
640
|
+
if (num > 0 && num <= studyPanel.refs.length) {
|
|
641
|
+
const ref = studyPanel.refs[num - 1];
|
|
642
|
+
setStudyRefInput('');
|
|
643
|
+
pendingVerseRef.current = ref.tgt_verse;
|
|
644
|
+
navigate(ref.tgt_book, ref.tgt_chapter);
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
setStudyRefInput('');
|
|
648
|
+
}
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
// Chapter nav
|
|
652
|
+
if (input === 'n' || key.rightArrow) {
|
|
653
|
+
goNext();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (input === 'p' || key.leftArrow) {
|
|
657
|
+
goPrev();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Range selection
|
|
661
|
+
if (input === 'v') {
|
|
662
|
+
setStudySelStart(studyCursorIdx);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// Copy cursor verse or selected range
|
|
666
|
+
if (input === 'c') {
|
|
667
|
+
const lo = studySelStart !== null ? Math.min(studySelStart, studyCursorIdx) : studyCursorIdx;
|
|
668
|
+
const hi = studySelStart !== null ? Math.max(studySelStart, studyCursorIdx) : studyCursorIdx;
|
|
669
|
+
const selected = verses.slice(lo, hi + 1);
|
|
670
|
+
if (selected.length > 0) {
|
|
671
|
+
const text = selected.map(v => `${bookName} ${v.chapter}:${v.verse} ${stripMarkers(v.text)}`).join('\n');
|
|
672
|
+
const refLabel = selected.length === 1
|
|
673
|
+
? `${bookName} ${chapter}:${selected[0].verse}`
|
|
674
|
+
: `${bookName} ${chapter}:${selected[0].verse}–${selected[selected.length - 1].verse}`;
|
|
675
|
+
clipboard.write(text).then(() => {
|
|
676
|
+
setFlash(`Copied ${refLabel}`);
|
|
677
|
+
setTimeout(() => setFlash(''), 2000);
|
|
678
|
+
});
|
|
679
|
+
setStudySelStart(null);
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// Goto
|
|
684
|
+
if (input === ':') {
|
|
685
|
+
setMode('goto');
|
|
686
|
+
setGotoInput('');
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// Search within chapter
|
|
690
|
+
if (input === '/') {
|
|
691
|
+
setMode('search');
|
|
692
|
+
setSearchQuery('');
|
|
693
|
+
setSearchSubmitted(false);
|
|
694
|
+
setSearchMatches([]);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// ── Copy mode ────────────────────────────────────────────────────────
|
|
700
|
+
if (mode === 'copy') {
|
|
701
|
+
if (key.escape) {
|
|
702
|
+
setMode('normal');
|
|
703
|
+
setSelStart(null);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (input === 'j' || key.downArrow) {
|
|
707
|
+
setCursorIdx(prev => {
|
|
708
|
+
const next = Math.min(prev + 1, verseMap.length - 1);
|
|
709
|
+
const entry = verseMap[next];
|
|
710
|
+
if (entry && entry.startLine >= scrollOffset + viewportH) {
|
|
711
|
+
scrollTo(entry.startLine - viewportH + 3);
|
|
712
|
+
}
|
|
713
|
+
return next;
|
|
714
|
+
});
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (input === 'k' || key.upArrow) {
|
|
718
|
+
setCursorIdx(prev => {
|
|
719
|
+
const next = Math.max(prev - 1, 0);
|
|
720
|
+
const entry = verseMap[next];
|
|
721
|
+
if (entry && entry.startLine < scrollOffset) {
|
|
722
|
+
scrollTo(entry.startLine);
|
|
723
|
+
}
|
|
724
|
+
return next;
|
|
725
|
+
});
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (input === 'v') {
|
|
729
|
+
setSelStart(cursorIdx);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (key.return || input === 'c') {
|
|
733
|
+
doCopy();
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// ── Normal mode ──────────────────────────────────────────────────────
|
|
739
|
+
if (input === 'q' || key.escape) {
|
|
740
|
+
exit();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (input === 'j' || key.downArrow) {
|
|
744
|
+
scrollDown();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (input === 'k' || key.upArrow) {
|
|
748
|
+
scrollUp();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (input === ' ') {
|
|
752
|
+
pageDown();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (input === 'b') {
|
|
756
|
+
pageUp();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (input === 'g') {
|
|
760
|
+
scrollTo(0);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (input === 'G') {
|
|
764
|
+
scrollTo(maxScroll);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (input === 'n' || key.rightArrow) {
|
|
768
|
+
if (searchSubmitted && searchMatches.length > 0) {
|
|
769
|
+
nextMatch();
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
goNext();
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (input === 'N') {
|
|
777
|
+
prevMatch();
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (input === 'p' || key.leftArrow) {
|
|
781
|
+
goPrev();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (input === 'f') {
|
|
785
|
+
setNotes(prev => !prev);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (input === 'o') {
|
|
789
|
+
setOutline(prev => !prev);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (input === '/') {
|
|
793
|
+
setMode('search');
|
|
794
|
+
setSearchQuery('');
|
|
795
|
+
setSearchSubmitted(false);
|
|
796
|
+
setSearchMatches([]);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (input === 'c') {
|
|
800
|
+
setMode('copy');
|
|
801
|
+
setCursorIdx(verseAtLine(verseMap, scrollOffset));
|
|
802
|
+
setSelStart(null);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (input === ':') {
|
|
806
|
+
setMode('goto');
|
|
807
|
+
setGotoInput('');
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (input === 'd') {
|
|
811
|
+
setMode('study');
|
|
812
|
+
studyActiveRef.current = true;
|
|
813
|
+
if (!notes)
|
|
814
|
+
setNotes(true);
|
|
815
|
+
setStudyCursorIdx(verseAtLine(verseMap, scrollOffset));
|
|
816
|
+
setStudyRefInput('');
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (input === 'H') {
|
|
820
|
+
setMode('home');
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (input === '[') {
|
|
824
|
+
goBack();
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (input === ']') {
|
|
828
|
+
goForward();
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
// ── Home screen render ───────────────────────────────────────────────────
|
|
833
|
+
if (mode === 'home' || mode === 'home-chapter') {
|
|
834
|
+
const { ot, nt } = getBookGrid();
|
|
835
|
+
const last = getLastRead();
|
|
836
|
+
const selectedBook = allBooks[homeIdx];
|
|
837
|
+
const homeLines = [];
|
|
838
|
+
homeLines.push('');
|
|
839
|
+
homeLines.push(chalk.bold(' Recovery Version Bible'));
|
|
840
|
+
homeLines.push('');
|
|
841
|
+
if (last) {
|
|
842
|
+
const lastBookName = getBookInfo(last.book)?.full_name ?? last.book;
|
|
843
|
+
homeLines.push(` Last read: ${chalk.bold(`${lastBookName} ${last.chapter}`)} ${chalk.dim('— press Space to continue')}`);
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
homeLines.push(chalk.dim(' Select a book to start reading'));
|
|
847
|
+
}
|
|
848
|
+
homeLines.push('');
|
|
849
|
+
// Render book grid with cursor highlight
|
|
850
|
+
function renderGridRow(books, startIdx) {
|
|
851
|
+
return ' ' + books.map((b, i) => {
|
|
852
|
+
const flatIdx = startIdx + i;
|
|
853
|
+
const abbr = b.abbr.padEnd(5);
|
|
854
|
+
if (flatIdx === homeIdx)
|
|
855
|
+
return chalk.inverse.bold(abbr);
|
|
856
|
+
return abbr;
|
|
857
|
+
}).join('');
|
|
858
|
+
}
|
|
859
|
+
homeLines.push(chalk.dim(' OLD TESTAMENT'));
|
|
860
|
+
for (let i = 0; i < ot.length; i += GRID_COLS) {
|
|
861
|
+
const row = ot.slice(i, i + GRID_COLS);
|
|
862
|
+
homeLines.push(renderGridRow(row, i));
|
|
863
|
+
}
|
|
864
|
+
homeLines.push('');
|
|
865
|
+
homeLines.push(chalk.dim(' NEW TESTAMENT'));
|
|
866
|
+
const otLen = ot.length;
|
|
867
|
+
for (let i = 0; i < nt.length; i += GRID_COLS) {
|
|
868
|
+
const row = nt.slice(i, i + GRID_COLS);
|
|
869
|
+
homeLines.push(renderGridRow(row, otLen + i));
|
|
870
|
+
}
|
|
871
|
+
if (mode === 'home-chapter' && selectedBook) {
|
|
872
|
+
const info = getBookInfo(selectedBook.abbr);
|
|
873
|
+
const maxCh = info?.chapter_count ?? 1;
|
|
874
|
+
homeLines.push('');
|
|
875
|
+
homeLines.push(` ${chalk.bold(selectedBook.full_name)} — select chapter:`);
|
|
876
|
+
// Render chapter numbers in rows of 10
|
|
877
|
+
const chNums = [];
|
|
878
|
+
for (let c = 1; c <= maxCh; c++) {
|
|
879
|
+
const label = String(c).padStart(3);
|
|
880
|
+
chNums.push(c === homeChapter ? chalk.inverse.bold(label) : chalk.dim(label));
|
|
881
|
+
}
|
|
882
|
+
for (let i = 0; i < chNums.length; i += 10) {
|
|
883
|
+
homeLines.push(' ' + chNums.slice(i, i + 10).join(' '));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const homeContent = homeLines.join('\n');
|
|
887
|
+
let homeFooter;
|
|
888
|
+
if (mode === 'home-chapter') {
|
|
889
|
+
homeFooter = ` ${chalk.dim('←/→ or type number · Enter go · Esc back')}`;
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
const lastRead = getLastRead();
|
|
893
|
+
const continueHint = lastRead ? 'Space continue · ' : '';
|
|
894
|
+
homeFooter = ` ${chalk.dim(`↑↓←→ navigate · Enter select · ${continueHint}type letter to jump · q quit`)}`;
|
|
895
|
+
}
|
|
896
|
+
const homeSep = chalk.dim('─'.repeat(cols));
|
|
897
|
+
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 })] }));
|
|
898
|
+
}
|
|
899
|
+
// ── Chapter view: build visible content ──────────────────────────────────
|
|
900
|
+
const visibleSlice = displayLines.slice(scrollOffset, scrollOffset + viewportH);
|
|
901
|
+
// Apply cursor highlight for copy mode and study mode
|
|
902
|
+
const contentLines = (mode === 'copy' || mode === 'study') ? visibleSlice.map((line, viewIdx) => {
|
|
903
|
+
const absLine = scrollOffset + viewIdx;
|
|
904
|
+
const isCopy = mode === 'copy';
|
|
905
|
+
const cursor = isCopy ? cursorIdx : studyCursorIdx;
|
|
906
|
+
const sel = isCopy ? selStart : studySelStart;
|
|
907
|
+
const lo = sel !== null ? Math.min(sel, cursor) : cursor;
|
|
908
|
+
const hi = sel !== null ? Math.max(sel, cursor) : cursor;
|
|
909
|
+
for (let v = lo; v <= hi; v++) {
|
|
910
|
+
const entry = verseMap[v];
|
|
911
|
+
if (entry && absLine >= entry.startLine && absLine <= entry.endLine) {
|
|
912
|
+
if (isCopy) {
|
|
913
|
+
// Copy mode: full inverse highlight
|
|
914
|
+
return absLine === entry.startLine
|
|
915
|
+
? chalk.inverse(`› ${stripAnsi(line)}`)
|
|
916
|
+
: chalk.inverse(` ${stripAnsi(line)}`);
|
|
917
|
+
}
|
|
918
|
+
// Study mode: subtle — just a › marker on first line, no bg change
|
|
919
|
+
if (absLine === entry.startLine) {
|
|
920
|
+
return `${chalk.cyan('›')}${line.substring(1)}`;
|
|
921
|
+
}
|
|
922
|
+
return line;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return line;
|
|
926
|
+
}) : visibleSlice;
|
|
927
|
+
const content = contentLines.join('\n');
|
|
928
|
+
// ── Header ───────────────────────────────────────────────────────────────
|
|
929
|
+
const CATEGORY_LABELS = {
|
|
930
|
+
Moses: 'Books of Moses', Israel: 'History of Israel', Poetry: 'Poetry & Wisdom',
|
|
931
|
+
MajorProphets: 'Major Prophets', MinorProphets: 'Minor Prophets',
|
|
932
|
+
Gospels: 'Gospels', Paul: 'Epistles of Paul', Others: 'General Epistles & Prophecy',
|
|
933
|
+
};
|
|
934
|
+
const bookInfo = getBookInfo(book);
|
|
935
|
+
const categoryLabel = CATEGORY_LABELS[bookInfo?.category ?? ''] ?? '';
|
|
936
|
+
const topVerseIdx = verseAtLine(verseMap, scrollOffset) + 1;
|
|
937
|
+
const title = `${bookName} ${chapter}`;
|
|
938
|
+
const position = `${topVerseIdx}/${verses.length}`;
|
|
939
|
+
const titleLeft = categoryLabel
|
|
940
|
+
? ` ${title} ${chalk.dim('·')} ${chalk.dim(categoryLabel)}`
|
|
941
|
+
: ` ${title}`;
|
|
942
|
+
const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
|
|
943
|
+
const header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
|
|
944
|
+
// ── Footer (two lines for normal/study, one line for modal modes) ──────
|
|
945
|
+
let footer1;
|
|
946
|
+
let footer2 = null;
|
|
947
|
+
if (flash) {
|
|
948
|
+
footer1 = ` ${chalk.green(`✓ ${flash}`)}`;
|
|
949
|
+
}
|
|
950
|
+
else if (mode === 'search') {
|
|
951
|
+
if (searchSubmitted) {
|
|
952
|
+
const countLabel = searchMatches.length === 0
|
|
953
|
+
? 'no matches'
|
|
954
|
+
: `match ${matchIdx + 1}/${searchMatches.length}`;
|
|
955
|
+
footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${chalk.dim('n/N cycle · Esc clear')}`;
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
footer1 = ` / ${searchQuery}${chalk.dim('_')} ${chalk.dim('Enter search · Esc cancel')}`;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
else if (mode === 'goto') {
|
|
962
|
+
footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8 · Enter go · Esc cancel')}`;
|
|
963
|
+
}
|
|
964
|
+
else if (mode === 'study') {
|
|
965
|
+
const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
|
|
966
|
+
const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
|
|
967
|
+
footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${chalk.dim('# follow ref · v range · c copy · / find')}`;
|
|
968
|
+
footer2 = ` ${chalk.dim('↑↓ verse · ←→ ch · : goto · [/] back/fwd · d exit')}`;
|
|
969
|
+
}
|
|
970
|
+
else if (mode === 'copy') {
|
|
971
|
+
const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
|
|
972
|
+
footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${chalk.dim('↑↓ move · v range · Enter copy · Esc cancel')}`;
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
const noteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
|
|
976
|
+
const outlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
|
|
977
|
+
footer1 = ` ${noteFlag} ${chalk.dim('·')} ${outlineFlag} ${chalk.dim('· d study · c copy · / find')}`;
|
|
978
|
+
footer2 = ` ${chalk.dim('↑↓ scroll · ←→ ch · : goto · H home · [/] back/fwd · q quit')}`;
|
|
979
|
+
}
|
|
980
|
+
const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
|
|
981
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
982
|
+
const studyPanelContent = studyPanel.lines.length > 0
|
|
983
|
+
? studyPanel.lines.join('\n')
|
|
984
|
+
: null;
|
|
985
|
+
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 })] }));
|
|
986
|
+
}
|
|
987
|
+
// ── Launcher ─────────────────────────────────────────────────────────────────
|
|
988
|
+
export async function launchPager(book, chapter, opts) {
|
|
989
|
+
process.stdout.write('\x1b[?1049h');
|
|
990
|
+
const instance = render(_jsx(Pager, { initialBook: book, initialChapter: chapter, initialNotes: opts.notes, initialOutline: opts.outline }));
|
|
991
|
+
await instance.waitUntilExit();
|
|
992
|
+
process.stdout.write('\x1b[?1049l');
|
|
993
|
+
}
|
|
994
|
+
export async function launchPagerHome() {
|
|
995
|
+
process.stdout.write('\x1b[?1049h');
|
|
996
|
+
const instance = render(_jsx(Pager, { initialNotes: false, initialOutline: false, startHome: true }));
|
|
997
|
+
await instance.waitUntilExit();
|
|
998
|
+
process.stdout.write('\x1b[?1049l');
|
|
999
|
+
}
|