komado 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.
Files changed (78) hide show
  1. package/README.md +208 -0
  2. package/dist/app.js +59 -0
  3. package/dist/cli.js +196 -0
  4. package/dist/components/List.js +41 -0
  5. package/dist/components/screens/ContinueScreen.js +66 -0
  6. package/dist/components/screens/HomeScreen.js +59 -0
  7. package/dist/components/screens/LibraryScreen.js +69 -0
  8. package/dist/components/screens/LoginScreen.js +85 -0
  9. package/dist/components/screens/MangaScreen.js +107 -0
  10. package/dist/components/screens/ReaderScreen.js +195 -0
  11. package/dist/components/screens/SearchScreen.js +111 -0
  12. package/dist/components/screens/SettingsScreen.js +144 -0
  13. package/dist/components/ui.js +33 -0
  14. package/dist/config.js +51 -0
  15. package/dist/domain/shape.js +44 -0
  16. package/dist/hooks/useStdoutDimensions.js +17 -0
  17. package/dist/lib/AppError.js +37 -0
  18. package/dist/lib/cache.js +54 -0
  19. package/dist/lib/catchAsync.js +12 -0
  20. package/dist/lib/envelope.js +15 -0
  21. package/dist/lib/fetchWithBackoff.js +65 -0
  22. package/dist/lib/logger.js +41 -0
  23. package/dist/lib/natsort.js +7 -0
  24. package/dist/lib/text.js +20 -0
  25. package/dist/render/chafa.js +56 -0
  26. package/dist/render/detect.js +86 -0
  27. package/dist/render/halfblock.js +42 -0
  28. package/dist/render/image.js +23 -0
  29. package/dist/render/sixel.js +88 -0
  30. package/dist/sixel-reader.js +309 -0
  31. package/dist/sources/index.js +17 -0
  32. package/dist/sources/local/archive.js +68 -0
  33. package/dist/sources/local/index.js +147 -0
  34. package/dist/sources/mangadex/auth.js +102 -0
  35. package/dist/sources/mangadex/client.js +76 -0
  36. package/dist/sources/mangadex/index.js +156 -0
  37. package/dist/sources/mangadex/normalize.js +54 -0
  38. package/dist/state/store.js +91 -0
  39. package/dist/ui-context.js +11 -0
  40. package/package.json +50 -0
  41. package/src/app.js +73 -0
  42. package/src/cli.js +218 -0
  43. package/src/components/List.js +60 -0
  44. package/src/components/screens/ContinueScreen.js +73 -0
  45. package/src/components/screens/HomeScreen.js +54 -0
  46. package/src/components/screens/LibraryScreen.js +79 -0
  47. package/src/components/screens/LoginScreen.js +92 -0
  48. package/src/components/screens/MangaScreen.js +125 -0
  49. package/src/components/screens/ReaderScreen.js +230 -0
  50. package/src/components/screens/SearchScreen.js +123 -0
  51. package/src/components/screens/SettingsScreen.js +146 -0
  52. package/src/components/ui.js +42 -0
  53. package/src/config.js +49 -0
  54. package/src/domain/shape.js +47 -0
  55. package/src/hooks/useStdoutDimensions.js +19 -0
  56. package/src/lib/AppError.js +26 -0
  57. package/src/lib/cache.js +57 -0
  58. package/src/lib/catchAsync.js +12 -0
  59. package/src/lib/envelope.js +14 -0
  60. package/src/lib/fetchWithBackoff.js +74 -0
  61. package/src/lib/logger.js +41 -0
  62. package/src/lib/natsort.js +7 -0
  63. package/src/lib/text.js +18 -0
  64. package/src/render/chafa.js +64 -0
  65. package/src/render/detect.js +112 -0
  66. package/src/render/halfblock.js +46 -0
  67. package/src/render/image.js +24 -0
  68. package/src/render/sixel.js +141 -0
  69. package/src/sixel-reader.js +359 -0
  70. package/src/sources/index.js +17 -0
  71. package/src/sources/local/archive.js +74 -0
  72. package/src/sources/local/index.js +155 -0
  73. package/src/sources/mangadex/auth.js +125 -0
  74. package/src/sources/mangadex/client.js +83 -0
  75. package/src/sources/mangadex/index.js +166 -0
  76. package/src/sources/mangadex/normalize.js +70 -0
  77. package/src/state/store.js +90 -0
  78. package/src/ui-context.js +12 -0
@@ -0,0 +1,141 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import sharp from 'sharp';
4
+ import { withTempImage } from './chafa.js';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ // Fallback cell pixel size when the terminal didn't report one.
9
+ const DEFAULT_CELL_W = 10;
10
+ const DEFAULT_CELL_H = 20;
11
+
12
+ // Encode an already-correctly-sized image to sixel at its NATIVE pixel
13
+ // resolution. --exact-size + font-ratio 1/1 makes chafa emit ~1 image px → 1
14
+ // sixel px, so the displayed size is exactly what we sized the image to — no
15
+ // dependence on chafa guessing the terminal's cell size through a pipe.
16
+ export async function encodePixels(buffer, { format = 'sixel' } = {}) {
17
+ const { stdout } = await withTempImage(buffer, (file) =>
18
+ execFileAsync(
19
+ 'chafa',
20
+ ['--format', format, '--exact-size', 'on', '--font-ratio', '1/1', '--animate', 'off', file],
21
+ { maxBuffer: 256 * 1024 * 1024, encoding: 'buffer' },
22
+ ),
23
+ );
24
+ return stdout; // Buffer of sixel/kitty bytes
25
+ }
26
+
27
+ // Scale a page to the full viewport width ONCE. In 'width' mode the same scaled
28
+ // page is windowed for every vertical scroll position, so doing the decode+resize
29
+ // here (and caching the result in the caller) keeps it off the per-keypress path.
30
+ // Returns { buffer, width, height } with the real scaled pixel dimensions.
31
+ export async function scalePage(buffer, { cols, cellW }) {
32
+ const viewW = Math.max(1, Math.round(cols * (cellW || DEFAULT_CELL_W)));
33
+ const scaled = await sharp(buffer).resize({ width: viewW }).png().toBuffer();
34
+ const meta = await sharp(scaled).metadata();
35
+ return { buffer: scaled, width: meta.width || viewW, height: meta.height || 0 };
36
+ }
37
+
38
+ // Resize/crop a page to the exact viewport pixel rectangle.
39
+ // mode 'fit' → whole page fits inside cols×rows cells
40
+ // mode 'width' → full terminal width, vertical window at cell offset `scroll`
41
+ // Pass a pre-scaled page (`scaled` from scalePage) in 'width' mode to skip the
42
+ // per-scroll resize; without it the page is scaled inline.
43
+ // Returns { buffer, maxScroll, scroll, imageRows } — scroll clamped to range,
44
+ // imageRows = the rendered image's height in cells (so the caller can erase any
45
+ // rows below it instead of clearing the whole screen).
46
+ export async function prepareImage(buffer, { mode, cols, rows, scroll = 0, cellW, cellH, scaled = null }) {
47
+ const ch = cellH || DEFAULT_CELL_H;
48
+ const viewW = Math.max(1, Math.round(cols * (cellW || DEFAULT_CELL_W)));
49
+ const viewH = Math.max(1, Math.round(rows * ch));
50
+
51
+ if (mode === 'fit') {
52
+ const out = await sharp(buffer)
53
+ .resize({ width: viewW, height: viewH, fit: 'inside', withoutEnlargement: false })
54
+ .png()
55
+ .toBuffer();
56
+ const meta = await sharp(out).metadata();
57
+ return { buffer: out, maxScroll: 0, scroll: 0, imageRows: Math.round((meta.height || viewH) / ch) };
58
+ }
59
+
60
+ // mode 'width': scale to full width (reuse `scaled` when scrolling), then
61
+ // extract a vertical window.
62
+ const page = scaled || (await scalePage(buffer, { cols, cellW }));
63
+ const scaledH = page.height || viewH;
64
+
65
+ const maxScrollPx = Math.max(0, scaledH - viewH);
66
+ const maxScroll = Math.ceil(maxScrollPx / ch);
67
+ const clamped = Math.max(0, Math.min(scroll, maxScroll));
68
+ const top = Math.min(maxScrollPx, clamped * ch);
69
+ const cropH = Math.max(1, Math.min(viewH, scaledH - top));
70
+
71
+ const out = await sharp(page.buffer)
72
+ .extract({ left: 0, top: Math.round(top), width: page.width, height: Math.round(cropH) })
73
+ .png()
74
+ .toBuffer();
75
+ return { buffer: out, maxScroll, scroll: clamped, imageRows: Math.round(cropH / ch) };
76
+ }
77
+
78
+ // ---- Sliceable sixel page (smooth vertical scrolling) ----------------------
79
+ // Re-encoding the viewport on every scroll step is the real bottleneck (~90ms of
80
+ // chafa + ~50ms of sharp per frame). But sixel is laid out as a fixed palette
81
+ // defined UP FRONT followed by independent 6px-tall bands, each of which
82
+ // re-selects its own colours. So we encode the whole page to sixel ONCE, then
83
+ // window the pre-encoded bands per frame — a string slice, not a re-encode.
84
+ // (Verified against chafa 1.14: every palette entry precedes the first band and
85
+ // every band starts with a colour select, so any band range stands alone.)
86
+ const BAND_PX = 6;
87
+
88
+ export function parseSixelPage(raw) {
89
+ const dcsStart = raw.indexOf('\x1bP');
90
+ if (dcsStart < 0) throw new Error('no sixel DCS found in encoder output');
91
+ const stEnd = raw.indexOf('\x1b\\', dcsStart);
92
+ const payload = raw.slice(dcsStart, stEnd < 0 ? undefined : stEnd);
93
+
94
+ const qi = payload.indexOf('q'); // the DCS intro (ESC P <params> q) ends at 'q'
95
+ const intro = qi >= 0 ? payload.slice(0, qi + 1) : '\x1bPq';
96
+ let rest = payload.slice(intro.length);
97
+
98
+ const rasterM = rest.match(/^"(\d+);(\d+);(\d+);(\d+)/);
99
+ const raster = rasterM
100
+ ? { pan: rasterM[1], pad: rasterM[2], ph: rasterM[3] }
101
+ : { pan: '1', pad: '1', ph: '0' };
102
+ if (rasterM) rest = rest.slice(rasterM[0].length);
103
+
104
+ // The palette is defined entirely up front; the bands follow, separated by '-'.
105
+ const palette = rest.match(/^((?:#\d+;2;\d+;\d+;\d+)*)/)?.[1] ?? '';
106
+ const rawBands = rest.slice(palette.length).split('-');
107
+ while (rawBands.length && rawBands[rawBands.length - 1] === '') rawBands.pop();
108
+
109
+ // chafa omits a band's leading colour select when it equals the previous
110
+ // band's last colour (the register carries across '-'). For a band to be the
111
+ // TOP of a sliced window it must re-select that colour itself, so prepend the
112
+ // carried register wherever it's missing — making every band self-contained.
113
+ let carry = '0'; // sixel's default colour register
114
+ const bands = rawBands.map((b) => {
115
+ const fixed = b.startsWith('#') ? b : `#${carry}${b}`;
116
+ const sels = fixed.match(/#(\d+)/g);
117
+ if (sels) carry = sels[sels.length - 1].slice(1);
118
+ return fixed;
119
+ });
120
+
121
+ return { intro, raster, palette, bands, height: bands.length * BAND_PX };
122
+ }
123
+
124
+ // Encode a full page to a parsed, sliceable sixel (one chafa call per page).
125
+ export async function encodeSixelPage(buffer) {
126
+ const raw = (await encodePixels(buffer, { format: 'sixel' })).toString('latin1');
127
+ return parseSixelPage(raw);
128
+ }
129
+
130
+ // Compose a complete sixel image from a band window (sub-millisecond). Pv is
131
+ // rewritten to the window height; the palette/intro are reused verbatim.
132
+ // Returns { sixel: Buffer, startBand, bands } (startBand clamped to range).
133
+ export function sliceSixelPage(page, { startBand, numBands }) {
134
+ const total = page.bands.length;
135
+ const k = Math.max(1, Math.min(numBands, total));
136
+ const start = Math.max(0, Math.min(startBand, total - k));
137
+ const body = page.bands.slice(start, start + k).join('-');
138
+ const r = page.raster;
139
+ const str = `${page.intro}"${r.pan};${r.pad};${r.ph};${k * BAND_PX}${page.palette}${body}\x1b\\`;
140
+ return { sixel: Buffer.from(str, 'latin1'), startBand: start, bands: total };
141
+ }
@@ -0,0 +1,359 @@
1
+ import { getSource } from './sources/index.js';
2
+ import { setProgress } from './state/store.js';
3
+ import { chapterLabel } from './domain/shape.js';
4
+ import { encodePixels, prepareImage, scalePage, encodeSixelPage, sliceSixelPage } from './render/sixel.js';
5
+ import { logger } from './lib/logger.js';
6
+
7
+ const ESC = '\x1b';
8
+
9
+ // Synchronized-update markers (DEC private mode 2026). Wrapping a frame makes a
10
+ // supporting terminal present it atomically — so the strip-scroll's "scroll the
11
+ // region, then repaint the freed strip" can't flash a blank strip at the leading
12
+ // edge. Ignored (harmless) on terminals that don't implement it.
13
+ const SYNC_BEGIN = Buffer.from(`${ESC}[?2026h`, 'latin1');
14
+ const SYNC_END = Buffer.from(`${ESC}[?2026l`, 'latin1');
15
+
16
+ // A self-contained, raw-mode page reader that renders pages as sixel/kitty
17
+ // pixels. It fully owns the terminal (Ink is unmounted before this runs), so
18
+ // there's no cell layout to fight. Returns the route Ink should resume at.
19
+ export async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage = 0, caps = {} }) {
20
+ const source = getSource(sourceId);
21
+ const { stdin, stdout } = process;
22
+ const format = caps.kitty ? 'kitty' : 'sixel';
23
+
24
+ let ci = chapterIndex;
25
+ let pi = startPage;
26
+ let scroll = 0; // current vertical pan (cells)
27
+ let fitWidth = true; // full-width + vertical pan (max resolution); `f` toggles
28
+ let pages = null;
29
+ let maxScroll = 0;
30
+
31
+ // Cell/band geometry for the optional strip-scroll. Sixel bands are 6px tall;
32
+ // the terminal scrolls by whole cells. A "slot" = LCM(6, cellH)px is the
33
+ // smallest shift that's whole in BOTH grids — scrolling by slots lets us move
34
+ // the on-screen pixels with the terminal and repaint only the newly-exposed
35
+ // strip (seam-free), instead of re-sending the entire viewport every step.
36
+ // Opt-in (KOMADO_SCROLL_DELTA=1) because it relies on the terminal scrolling
37
+ // sixel graphics with the text (true on xterm; not universal).
38
+ const cellH = caps.cellH || 20;
39
+ const gcd = (a, b) => (b ? gcd(b, a % b) : a);
40
+ const slotBands = cellH / gcd(6, cellH);
41
+ const slotCells = 6 / gcd(6, cellH);
42
+ const deltaScroll = format === 'sixel' && process.env.KOMADO_SCROLL_DELTA === '1';
43
+ const scrollStep = deltaScroll ? slotCells : 2;
44
+ let shownTop = null; // top band currently on screen (delta baseline); null ⇒ unknown
45
+ let shownSig = null; // page + geometry the on-screen frame was drawn for
46
+
47
+ // Per-page caches. draw() runs on every keypress, but the page bytes and the
48
+ // full-width scale are constant within a page — re-doing them per scroll step
49
+ // (a network round-trip per step for remote sources) is what made scrolling
50
+ // crawl. Cache both, keyed by page (and width for the scale).
51
+ let rawKey = null;
52
+ let rawBuf = null;
53
+ let scaledKey = null;
54
+ let scaledBuf = null;
55
+ let sixelKey = null;
56
+ let sixelPg = null;
57
+
58
+ // Render scheduler state: coalesce bursts of keypresses into the fewest draws
59
+ // instead of dropping input mid-draw. `inputSeq` lets a finishing draw detect
60
+ // whether the user moved on while it was rendering.
61
+ let inputSeq = 0;
62
+ let drawing = false;
63
+ let pending = false;
64
+ let pendingFullClear = false;
65
+
66
+ const size = () => ({
67
+ cols: Math.max(20, stdout.columns || 80),
68
+ rows: Math.max(6, stdout.rows || 24),
69
+ });
70
+
71
+ async function ensurePages() {
72
+ if (pages) return;
73
+ pages = await source.getPages(chapters[ci].id);
74
+ pi = Math.max(0, Math.min(pi, pages.length - 1));
75
+ }
76
+
77
+ // The page bytes, fetched once per page (not per scroll step).
78
+ async function pageBuffer() {
79
+ const key = `${ci}:${pi}`;
80
+ if (rawKey === key && rawBuf) return rawBuf;
81
+ rawBuf = await source.loadPageBuffer(pages[pi]);
82
+ rawKey = key;
83
+ return rawBuf;
84
+ }
85
+
86
+ // The page scaled to full viewport width, reused across vertical scrolling.
87
+ async function scaledPage(cols) {
88
+ const key = `${ci}:${pi}:${cols}:${caps.cellW || ''}`;
89
+ if (scaledKey === key && scaledBuf) return scaledBuf;
90
+ scaledBuf = await scalePage(await pageBuffer(), { cols, cellW: caps.cellW });
91
+ scaledKey = key;
92
+ return scaledBuf;
93
+ }
94
+
95
+ // The page encoded to a sliceable sixel ONCE (palette + 6px bands), so every
96
+ // scroll step is a band-window slice instead of a fresh chafa+sharp encode.
97
+ async function sixelPageCached(cols) {
98
+ const key = `${ci}:${pi}:${cols}:${caps.cellW || ''}`;
99
+ if (sixelKey === key && sixelPg) return sixelPg;
100
+ const scaled = await scaledPage(cols);
101
+ sixelPg = await encodeSixelPage(scaled.buffer);
102
+ sixelKey = key;
103
+ return sixelPg;
104
+ }
105
+
106
+ function statusBar() {
107
+ const { cols } = size();
108
+ const left = `${manga.title} · ${chapterLabel(chapters[ci])} · ${pi + 1}/${pages ? pages.length : '?'}${fitWidth ? '' : ' · fit'}`;
109
+ const right = '←→ page · ↑↓ pan · N/P ch · f fit · q back';
110
+ const gap = Math.max(1, cols - left.length - right.length - 2);
111
+ return ` ${left}${' '.repeat(gap)}${right} `.slice(0, cols);
112
+ }
113
+
114
+ async function draw({ fullClear = false } = {}) {
115
+ const seq = inputSeq;
116
+ const { cols, rows } = size();
117
+ const imgRows = rows - 1; // reserve the bottom row for the status bar
118
+ try {
119
+ await ensurePages();
120
+ if (!pages.length) throw new Error('This chapter has no hosted pages.');
121
+
122
+ // Build the bytes for this frame (null ⇒ nothing changed, skip the write).
123
+ // Default sixel path windows the pre-encoded page bands; with strip-scroll
124
+ // opted in, a slot-sized move scrolls the terminal and repaints only the
125
+ // exposed strip. 'fit'/kitty fall back to a per-frame encode.
126
+ let frame = null;
127
+ let imageRows = imgRows;
128
+ let mScroll = 0;
129
+ let sScroll = scroll;
130
+
131
+ if (fitWidth && format === 'sixel') {
132
+ const page = await sixelPageCached(cols);
133
+ const viewBands = Math.max(1, Math.floor((imgRows * cellH) / 6));
134
+ const maxStart = Math.max(0, page.bands.length - viewBands);
135
+ let topBand = Math.round((scroll * cellH) / 6);
136
+ if (deltaScroll) topBand = Math.round(topBand / slotBands) * slotBands;
137
+ topBand = Math.max(0, Math.min(topBand, maxStart));
138
+
139
+ imageRows = Math.round((viewBands * 6) / cellH);
140
+ mScroll = (maxStart * 6) / cellH;
141
+ sScroll = (topBand * 6) / cellH;
142
+
143
+ const geomSig = `${ci}:${pi}:${cols}:${imgRows}`;
144
+ const reuse = !fullClear && shownTop !== null && shownSig === geomSig;
145
+ const delta = reuse ? topBand - shownTop : 0;
146
+
147
+ if (reuse && delta === 0) {
148
+ frame = null; // identical frame already on screen
149
+ } else if (deltaScroll && reuse && delta !== 0
150
+ && Math.abs(delta) % slotBands === 0 && Math.abs(delta) < viewBands) {
151
+ frame = buildDeltaFrame(page, { from: shownTop, to: topBand, viewBands, imgRows, rows });
152
+ } else {
153
+ const win = sliceSixelPage(page, { startBand: topBand, numBands: viewBands }).sixel;
154
+ frame = composeFull(win, { fullClear, imageRows, imgRows, rows });
155
+ }
156
+ shownTop = topBand;
157
+ shownSig = geomSig;
158
+ } else {
159
+ shownTop = null; // delta baseline doesn't apply to this render path
160
+ let prepared;
161
+ if (fitWidth) {
162
+ const page = await scaledPage(cols);
163
+ prepared = await prepareImage(null, {
164
+ mode: 'width', cols, rows: imgRows, scroll,
165
+ cellW: caps.cellW, cellH: caps.cellH, scaled: page,
166
+ });
167
+ } else {
168
+ prepared = await prepareImage(await pageBuffer(), {
169
+ mode: 'fit', cols, rows: imgRows, cellW: caps.cellW, cellH: caps.cellH,
170
+ });
171
+ }
172
+ imageRows = prepared.imageRows; mScroll = prepared.maxScroll; sScroll = prepared.scroll;
173
+ const sig = `f:${ci}:${pi}:${sScroll}:${cols}:${imgRows}`;
174
+ if (fullClear || sig !== shownSig) {
175
+ const buf = await encodePixels(prepared.buffer, { format });
176
+ // 'fit' images are letterboxed (top-left, smaller than the viewport), so
177
+ // clear first or the previous full-width view shows through the margins.
178
+ frame = composeFull(buf, { fullClear: fullClear || !fitWidth, imageRows, imgRows, rows });
179
+ shownSig = sig;
180
+ }
181
+ }
182
+
183
+ // Adopt the clamped scroll only if the user hasn't moved since this draw
184
+ // started; otherwise the newer keypress + its coalesced redraw owns it (a
185
+ // blind write-back here would undo input that arrived mid-render).
186
+ if (seq === inputSeq) {
187
+ maxScroll = mScroll;
188
+ scroll = Math.max(0, Math.min(sScroll, mScroll));
189
+ }
190
+
191
+ if (frame) stdout.write(Buffer.concat([SYNC_BEGIN, frame, SYNC_END]));
192
+
193
+ setProgress(manga.key, {
194
+ source: sourceId,
195
+ mangaId: manga.id,
196
+ mangaTitle: manga.title,
197
+ chapterId: chapters[ci].id,
198
+ chapterNumber: chapters[ci].number,
199
+ page: pi,
200
+ });
201
+ // Last page reached → push a read-marker to MangaDex (self-guarded/deduped).
202
+ if (pi === pages.length - 1 && source.syncChapterRead) {
203
+ source.syncChapterRead(manga.id, chapters[ci].id);
204
+ }
205
+ } catch (err) {
206
+ logger.warn('viewer draw failed', err);
207
+ stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
208
+ stdout.write(`Error: ${err.message}\r\n\r\nN/P chapter · q back\r\n`);
209
+ }
210
+ }
211
+
212
+ // Whole-viewport frame: home + overwrite (no full ESC[2J each step — that
213
+ // clear-to-blank is what made scrolling blink), erase only the rows a shorter
214
+ // image leaves below, then the status bar. One atomic write avoids partial
215
+ // frames and extra syscalls.
216
+ function composeFull(sixelBuf, { fullClear, imageRows, imgRows, rows }) {
217
+ const prefix = fullClear ? `${ESC}[2J${ESC}[H` : `${ESC}[H`;
218
+ let suffix = imageRows < imgRows ? `${ESC}[${imageRows + 1};1H${ESC}[0J` : '';
219
+ suffix += `${ESC}[${rows};1H${ESC}[7m${statusBar()}${ESC}[0m`;
220
+ return Buffer.concat([Buffer.from(prefix, 'latin1'), sixelBuf, Buffer.from(suffix, 'latin1')]);
221
+ }
222
+
223
+ // Strip-scroll frame (opt-in): scroll the image area with the terminal and
224
+ // repaint only the slot of sixel that scrolled into view — far less data than
225
+ // re-sending the whole viewport. Valid only for slot-sized shifts (whole cells
226
+ // AND whole bands), so the moved pixels stay band-aligned and there's no seam.
227
+ // Uses LF/RI inside a DECSTBM region (the most widely supported scroll path).
228
+ function buildDeltaFrame(page, { from, to, viewBands, imgRows, rows }) {
229
+ const delta = to - from; // bands, a non-zero multiple of slotBands
230
+ const dc = Math.round((Math.abs(delta) * 6) / cellH); // whole cells scrolled
231
+ const region = `${ESC}[1;${imgRows}r`;
232
+ const reset = `${ESC}[r`;
233
+ const status = `${ESC}[${rows};1H${ESC}[7m${statusBar()}${ESC}[0m`;
234
+ let head; let strip;
235
+ if (delta > 0) {
236
+ // content up → repaint the freed strip at the bottom
237
+ head = `${region}${ESC}[${imgRows};1H${'\n'.repeat(dc)}${reset}${ESC}[${imgRows - dc + 1};1H`;
238
+ strip = sliceSixelPage(page, { startBand: from + viewBands, numBands: delta }).sixel;
239
+ } else {
240
+ // content down → repaint the freed strip at the top
241
+ head = `${region}${ESC}[1;1H${`${ESC}M`.repeat(dc)}${reset}${ESC}[1;1H`;
242
+ strip = sliceSixelPage(page, { startBand: to, numBands: -delta }).sixel;
243
+ }
244
+ return Buffer.concat([Buffer.from(head, 'latin1'), strip, Buffer.from(status, 'latin1')]);
245
+ }
246
+
247
+ // Coalescing scheduler: while a draw runs, extra requests collapse into a
248
+ // single follow-up at the latest state — input is never dropped and draws
249
+ // never stack up behind a slow encode.
250
+ function schedule({ fullClear = false } = {}) {
251
+ if (fullClear) pendingFullClear = true;
252
+ if (drawing) { pending = true; return; }
253
+ drawing = true;
254
+ (async () => {
255
+ try {
256
+ do {
257
+ pending = false;
258
+ const fc = pendingFullClear;
259
+ pendingFullClear = false;
260
+ await draw({ fullClear: fc });
261
+ } while (pending);
262
+ } finally {
263
+ drawing = false;
264
+ }
265
+ })();
266
+ }
267
+
268
+ function changeChapter(delta) {
269
+ const next = ci + delta;
270
+ if (next < 0 || next >= chapters.length) return false;
271
+ ci = next;
272
+ pi = 0;
273
+ scroll = 0;
274
+ shownTop = null;
275
+ pages = null;
276
+ return true;
277
+ }
278
+ function nextPage() {
279
+ if (pages && pi < pages.length - 1) { pi += 1; scroll = 0; shownTop = null; }
280
+ else changeChapter(1);
281
+ }
282
+ function prevPage() {
283
+ if (pi > 0) { pi -= 1; scroll = 0; shownTop = null; }
284
+ else changeChapter(-1);
285
+ }
286
+
287
+ const prevRaw = stdin.isRaw;
288
+ let onKey;
289
+ // Ink leaves stdin unref'd after unmount, so a bare `await`-for-keypress won't
290
+ // keep the process alive — it would exit the moment the first page is drawn.
291
+ // A ref'd timer holds the event loop open until we're done.
292
+ const keepAlive = setInterval(() => {}, 1 << 30);
293
+ // Re-render on terminal resize so the page tracks the window size. Full clear:
294
+ // a narrower/shorter window can leave stale pixels outside the new image.
295
+ const onResize = () => { inputSeq += 1; schedule({ fullClear: true }); };
296
+ try {
297
+ await draw({ fullClear: true });
298
+ stdout.on('resize', onResize);
299
+
300
+ await new Promise((resolve) => {
301
+ onKey = (data) => {
302
+ const k = data.toString('latin1');
303
+ const pageStep = Math.max(1, size().rows - 2);
304
+
305
+ if (k === 'q' || k === ESC) { resolve(); return; }
306
+ if (k === 'n' || k === ' ') {
307
+ if (fitWidth && scroll < maxScroll) scroll = Math.min(maxScroll, scroll + pageStep);
308
+ else nextPage();
309
+ } else if (k === 'p') {
310
+ if (fitWidth && scroll > 0) scroll = Math.max(0, scroll - pageStep);
311
+ else prevPage();
312
+ } else if (k === 'd' || k === `${ESC}[C`) {
313
+ nextPage(); // → / d : next page
314
+ } else if (k === 'a' || k === `${ESC}[D`) {
315
+ prevPage(); // ← / a : previous page
316
+ } else if (k === 'j' || k === `${ESC}[B`) {
317
+ scroll = Math.min(maxScroll, scroll + scrollStep);
318
+ } else if (k === 'k' || k === `${ESC}[A`) {
319
+ scroll = Math.max(0, scroll - scrollStep);
320
+ } else if (k === 'N') {
321
+ changeChapter(1);
322
+ } else if (k === 'P') {
323
+ changeChapter(-1);
324
+ } else if (k === 'f') {
325
+ fitWidth = !fitWidth;
326
+ scroll = 0;
327
+ shownTop = null; shownSig = null; // render path changes → fresh baseline
328
+ } else if (k === 'g') {
329
+ scroll = 0; // jump to top
330
+ } else if (k === 'G') {
331
+ scroll = maxScroll; // jump to bottom
332
+ } else {
333
+ return; // ignore other keys without redrawing
334
+ }
335
+ // Update state synchronously, then coalesce the redraw — rapid repeats
336
+ // collapse into the fewest draws instead of being dropped mid-draw.
337
+ inputSeq += 1;
338
+ schedule();
339
+ };
340
+
341
+ // Enter raw mode *after* the first draw — Ink's unmount restores cooked
342
+ // mode on a deferred tick, which would otherwise leave stdin line-buffered
343
+ // (so single keypresses never arrive).
344
+ stdin.removeAllListeners('data');
345
+ try { stdin.setRawMode(true); } catch { /* ignore */ }
346
+ stdin.resume();
347
+ stdin.ref?.();
348
+ stdin.on('data', onKey);
349
+ });
350
+ } finally {
351
+ clearInterval(keepAlive);
352
+ stdout.removeListener('resize', onResize);
353
+ if (onKey) stdin.removeListener('data', onKey);
354
+ try { stdin.setRawMode(prevRaw); } catch { /* ignore */ }
355
+ stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
356
+ }
357
+
358
+ return { name: 'manga', params: { sourceId, manga } };
359
+ }
@@ -0,0 +1,17 @@
1
+ import * as mangadex from './mangadex/index.js';
2
+ import * as local from './local/index.js';
3
+
4
+ // Source registry. Every source implements the same interface:
5
+ // search, getManga, listChapters, getPages, loadPageBuffer
6
+ // so the hooks/UI never branch on where a manga comes from.
7
+ const sources = { mangadex, local };
8
+
9
+ export function getSource(sourceId) {
10
+ const source = sources[sourceId];
11
+ if (!source) throw new Error(`Unknown source: ${sourceId}`);
12
+ return source;
13
+ }
14
+
15
+ export const SOURCES = sources;
16
+ export const REMOTE_SOURCES = Object.values(sources).filter((s) => s.remote);
17
+ export const LOCAL_SOURCES = Object.values(sources).filter((s) => !s.remote);
@@ -0,0 +1,74 @@
1
+ import AdmZip from 'adm-zip';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { naturalSort } from '../../lib/natsort.js';
4
+
5
+ const IMAGE_RE = /\.(jpe?g|png|gif|webp|bmp|avif)$/i;
6
+
7
+ export const isArchive = (name) => /\.(cbz|zip|cbr|rar)$/i.test(name);
8
+ export const isRar = (name) => /\.(cbr|rar)$/i.test(name);
9
+
10
+ // ---- ZIP / CBZ (adm-zip parses the whole file on construct → tiny LRU) ----
11
+ const zipCache = new Map();
12
+ function openZip(filePath) {
13
+ let zip = zipCache.get(filePath);
14
+ if (!zip) {
15
+ zip = new AdmZip(filePath);
16
+ zipCache.set(filePath, zip);
17
+ if (zipCache.size > 8) zipCache.delete(zipCache.keys().next().value);
18
+ }
19
+ return zip;
20
+ }
21
+ function listZipImages(filePath) {
22
+ return naturalSort(
23
+ openZip(filePath)
24
+ .getEntries()
25
+ .filter((e) => !e.isDirectory && IMAGE_RE.test(e.entryName))
26
+ .map((e) => e.entryName),
27
+ );
28
+ }
29
+ function readZipEntry(filePath, entryName) {
30
+ const entry = openZip(filePath).getEntry(entryName);
31
+ if (!entry) throw new Error(`Entry not found in archive: ${entryName}`);
32
+ return entry.getData();
33
+ }
34
+
35
+ // ---- RAR / CBR (node-unrar-js — WASM, loaded lazily on first use) ----
36
+ let unrarPromise = null;
37
+ const getUnrar = () => (unrarPromise ??= import('node-unrar-js'));
38
+
39
+ const rarCache = new Map();
40
+ async function openRar(filePath) {
41
+ let extractor = rarCache.get(filePath);
42
+ if (!extractor) {
43
+ const { createExtractorFromData } = await getUnrar();
44
+ const buf = await readFile(filePath);
45
+ extractor = await createExtractorFromData({
46
+ data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
47
+ });
48
+ rarCache.set(filePath, extractor);
49
+ if (rarCache.size > 4) rarCache.delete(rarCache.keys().next().value);
50
+ }
51
+ return extractor;
52
+ }
53
+ async function listRarImages(filePath) {
54
+ const extractor = await openRar(filePath);
55
+ const names = [];
56
+ for (const header of extractor.getFileList().fileHeaders) {
57
+ if (!header.flags.directory && IMAGE_RE.test(header.name)) names.push(header.name);
58
+ }
59
+ return naturalSort(names);
60
+ }
61
+ async function readRarEntry(filePath, entryName) {
62
+ const extractor = await openRar(filePath);
63
+ const [file] = [...extractor.extract({ files: [entryName] }).files];
64
+ if (!file?.extraction) throw new Error(`Failed to extract from RAR: ${entryName}`);
65
+ return Buffer.from(file.extraction);
66
+ }
67
+
68
+ // ---- Unified async API (callers don't care about the container format) ----
69
+ export async function listArchiveImages(filePath) {
70
+ return isRar(filePath) ? listRarImages(filePath) : listZipImages(filePath);
71
+ }
72
+ export async function readArchiveEntry(filePath, entryName) {
73
+ return isRar(filePath) ? readRarEntry(filePath, entryName) : readZipEntry(filePath, entryName);
74
+ }