diff --git a/apps/cli/src/commands/tui.ts b/apps/cli/src/commands/tui.ts index d1766ad..a8b768f 100644 --- a/apps/cli/src/commands/tui.ts +++ b/apps/cli/src/commands/tui.ts @@ -5,7 +5,7 @@ import { failure, type Output } from '../output.js' import { type ParsedArgv } from '../parse-argv.js' import { resolveEndpoint, sshConfigHosts } from '../resolve.js' import { blankPane, buildEndpointChoices, defaultLocalPath, Tui } from '../tui/app.js' -import { runInherited } from '../tui/launch.js' +import { enableTmuxPassthrough, runInherited } from '../tui/launch.js' /** * `diskpush tui` — the two-pane browser, in a terminal. @@ -42,6 +42,9 @@ export async function runTui(parsed: ParsedArgv, store: DiskPushStore, output: O const choices = buildEndpointChoices(await store.listConnections(), sshConfigHosts(), defaultLocalPath()) const tui = new Tui(panes[0]!, panes[1]!, choices) + // Images ride on escape sequences tmux drops by default. + enableTmuxPassthrough() + try { void tui.loadBoth() // The browser runs until it quits, or until it wants the terminal handed diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index 427bbab..624373b 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -11,7 +11,8 @@ * screen can be rendered and asserted on in a test with no pty. */ import { join, posix } from 'node:path' -import type { App, Container, KeyEvent, MouseEvent, Theme } from '@profullstack/hqtui' +import type { App, Container, KeyEvent, MouseEvent, Rect, Theme } from '@profullstack/hqtui' +import { detectCapabilities } from '@profullstack/hqtui' import { knownHostsPath } from '@diskpush/database' import { SftpBrowser, SshSession } from '@diskpush/ssh-core' import { defaultRsyncOptions, type Change, type Connection } from '@diskpush/schemas' @@ -28,11 +29,13 @@ import { SORT_KEYS, type Transfer, DOCUMENT_LIMIT, + IMAGE_LIMIT, blankDocument, blankPane, clampIndex, endpointString, fillDocument, + isImageName, listLocal, looksBinary, nothingToDo, @@ -48,6 +51,7 @@ import { import { type Action, type Tone, type ViewState, draw, filterChoices } from './view.js' import { maxScroll, readsAsMarkdown } from './document.js' import { type Launch, type Launcher, type Target, editLaunch, openLaunch, systemLauncher } from './launch.js' +import { type Protocol, chooseProtocol, deleteImage, encodeImage, placeAt, tmuxPassthrough } from './graphics.js' export { blankPane, @@ -74,6 +78,12 @@ export class Tui { private documentLayout = { total: 0, rows: 1 } /** A program waiting for the terminal, once the app has given it up. See `start`. */ private handoff: Launch | null = null + /** Where the frame left room for an image, if it did. Set by the view, read after the frame. */ + private imageRect: Rect | null = null + /** The image on screen and the cells it was drawn into, so the next frame knows whether to draw again. */ + private shown: { doc: Document; cells: string } | null = null + /** How this terminal is handed an image. Decided once: the terminal does not change. */ + private readonly protocol: Protocol private filtering: Side | null = null private status: { text: string; tone: Tone } | null = null private busy = false @@ -91,13 +101,21 @@ export class Tui { right: Pane, private readonly choices: readonly EndpointChoice[] = [], private readonly launcher: Launcher = systemLauncher(), + protocol?: Protocol, ) { this.panes = { left, right } + this.protocol = protocol ?? chooseProtocol(detectCapabilities({}, launcher.env).program) } - /** Binds the app so background work (a load, a transfer tick) can redraw. */ + /** + * Binds the app so background work (a load, a transfer tick) can redraw, + * and so an image can be drawn once each frame is on the terminal. + */ attach(app: App): void { this.app = app + this.shown = null + app.on('frame', () => this.afterFrame()) + app.on('exit', () => this.clearImage()) } private invalidate(): void { @@ -122,7 +140,11 @@ export class Tui { /** The render callback handed to `app.render`. */ view(ui: Container, theme: Theme, width: number, height: number): void { + this.imageRect = null draw(ui, theme, width, height, this.snapshot(), { + onImageRect: (rect) => { + this.imageRect = rect + }, onPaneFocus: (side) => { this.active = side this.invalidate() @@ -637,10 +659,12 @@ export class Tui { // A finished transfer's panel and the document want the same rows. if (this.transfer && !this.transfer.running) this.transfer = null this.invalidate() + // An image is handed to the terminal whole, so it is read whole. + const limit = isImageName(row.entry.name) ? IMAGE_LIMIT : DOCUMENT_LIMIT try { const head = pane.connection - ? await this.readRemoteHead(pane, posix.join(pane.path, row.rel)) - : readLocalHead(join(pane.path, row.rel), DOCUMENT_LIMIT) + ? await this.readRemoteHead(pane, posix.join(pane.path, row.rel), limit) + : readLocalHead(join(pane.path, row.rel), limit) if (this.document !== doc) return fillDocument(doc, head, readsAsMarkdown) } catch (error) { @@ -652,6 +676,48 @@ export class Tui { } } + // ------------------------------------------------------------------ images + + private writeRaw(data: string): void { + if (data) this.app?.terminal.write(data) + } + + /** + * After each frame: draw the image the frame left room for, or take down + * the one that is no longer wanted. + * + * The image is not part of the frame. hqtui diffs cells, and an image is + * not a cell, so it is handed to the terminal separately, once, into cells + * the frame left blank — and drawn again only when the file or the box + * changes, never on every mouse move. Taking it down is a full repaint: + * an iTerm2 image is erased by drawing over it, and hqtui's diff would + * otherwise leave the untouched cells exactly as they were, image and all. + */ + private afterFrame(): void { + const doc = this.document + const rect = this.imageRect + const want = + doc?.kind === 'image' && doc.image && rect && rect.width > 0 && rect.height > 0 + ? { doc, cells: `${rect.x},${rect.y},${rect.width},${rect.height}` } + : null + if (this.shown && (!want || want.doc !== this.shown.doc || want.cells !== this.shown.cells)) { + this.clearImage() + this.app?.redraw() + return + } + if (!want || this.shown || !rect || !doc?.image) return + const sequence = encodeImage(this.protocol, doc.bytes, doc.image, { cols: rect.width, rows: rect.height }) + if (sequence) this.writeRaw(placeAt(sequence, rect.x, rect.y, this.launcher.inTmux)) + this.shown = want + } + + private clearImage(): void { + if (!this.shown) return + const sequence = deleteImage(this.protocol) + if (sequence) this.writeRaw(this.launcher.inTmux ? tmuxPassthrough(sequence) : sequence) + this.shown = null + } + // --------------------------------------------------------------- launching /** The row under the cursor, as something to hand to a program. */ @@ -752,10 +818,10 @@ export class Tui { this.invalidate() } - private async readRemoteHead(pane: Pane, path: string): Promise<{ bytes: Uint8Array; size: number }> { + private async readRemoteHead(pane: Pane, path: string, limit: number): Promise<{ bytes: Uint8Array; size: number }> { const browser = await SftpBrowser.open(await this.session(pane.connection!)) try { - return await browser.readHead(path, DOCUMENT_LIMIT) + return await browser.readHead(path, limit) } finally { browser.close() } diff --git a/apps/cli/src/tui/document.ts b/apps/cli/src/tui/document.ts index 5601238..5f49b63 100644 --- a/apps/cli/src/tui/document.ts +++ b/apps/cli/src/tui/document.ts @@ -41,6 +41,8 @@ function fromMarkdown(span: MarkdownSpan, theme: Theme): SpanLine[number] { } function render(doc: Document, width: number, theme: Theme): SpanLine[] { + // An image is drawn by the terminal, over cells this leaves blank. + if (doc.kind === 'image') return [] if (doc.kind === 'markdown') { return renderMarkdown(doc.text, Math.max(20, width)).map((line) => line.spans.map((span) => fromMarkdown(span, theme))) } diff --git a/apps/cli/src/tui/graphics.test.ts b/apps/cli/src/tui/graphics.test.ts new file mode 100644 index 0000000..6afbb5c --- /dev/null +++ b/apps/cli/src/tui/graphics.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { KITTY_IMAGE_ID, chooseProtocol, deleteImage, encodeImage, fitCells, fitsNaturally, placeAt, tmuxPassthrough } from './graphics.js' +import { blankDocument, fillDocument, imageInfo, isImageName } from './model.js' + +/** A 1×1 transparent PNG. */ +export const PNG_1x1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +) + +describe('choosing a protocol', () => { + it('speaks kitty to kitty and ghostty, and iTerm2 to everyone else, tmux included', () => { + expect(chooseProtocol('kitty')).toBe('kitty') + expect(chooseProtocol('ghostty')).toBe('kitty') + expect(chooseProtocol('wezterm')).toBe('iterm2') + expect(chooseProtocol('iterm')).toBe('iterm2') + expect(chooseProtocol('tmux')).toBe('iterm2') + expect(chooseProtocol('unknown')).toBe('iterm2') + }) +}) + +describe('fitting an image to a box', () => { + it('keeps the shape, counting a cell as twice as tall as wide', () => { + // A 200×100 image is 200 columns by 50 rows in cell units: twice the box's width matters. + expect(fitCells({ width: 200, height: 100 }, { cols: 100, rows: 50 })).toEqual({ cols: 100, rows: 25 }) + expect(fitCells({ width: 100, height: 400 }, { cols: 100, rows: 50 })).toEqual({ cols: 25, rows: 50 }) + expect(fitCells({ width: 0, height: 0 }, { cols: 100, rows: 50 })).toEqual({ cols: 0, rows: 0 }) + }) + + it('draws an icon at its own size and shrinks a photo', () => { + expect(fitsNaturally({ width: 64, height: 64 }, { cols: 80, rows: 20 })).toBe(true) + expect(fitsNaturally({ width: 4000, height: 3000 }, { cols: 80, rows: 20 })).toBe(false) + }) +}) + +describe('encoding', () => { + const info = imageInfo(PNG_1x1)! + + const photo = { ...info, width: 4000, height: 3000 } + + it('iTerm2: one OSC 1337 carrying the file, sized to the box, aspect kept', () => { + const seq = encodeImage('iterm2', PNG_1x1, photo, { cols: 80, rows: 20 })! + expect(seq.startsWith(`\x1b]1337;File=inline=1;size=${PNG_1x1.length};width=80;height=20;preserveAspectRatio=1:`)).toBe(true) + expect(encodeImage('iterm2', PNG_1x1, info, { cols: 80, rows: 20 })).toContain(';width=auto;height=auto;') + expect(seq.endsWith('\x07')).toBe(true) + const payload = seq.slice(seq.indexOf(':') + 1, -1) + expect(Buffer.from(payload, 'base64').equals(PNG_1x1)).toBe(true) + }) + + it('kitty: chunked APC with the id, the fitted cell box, and PNG only', () => { + const seq = encodeImage('kitty', PNG_1x1, photo, { cols: 80, rows: 20 })! + expect(seq.startsWith(`\x1b_Ga=T,f=100,i=${KITTY_IMAGE_ID},c=53,r=20,q=2,m=0;`)).toBe(true) + expect(encodeImage('kitty', PNG_1x1, info, { cols: 80, rows: 20 })).toContain(`i=${KITTY_IMAGE_ID},q=2,m=0;`) + expect(seq.endsWith('\x1b\\')).toBe(true) + expect(encodeImage('kitty', PNG_1x1, { ...info, format: 'jpeg' }, { cols: 80, rows: 20 })).toBeNull() + // A big payload is cut into 4096-character chunks, m=1 on all but the last. + const big = Buffer.concat([PNG_1x1, Buffer.alloc(9000)]) + const chunked = encodeImage('kitty', big, photo, { cols: 80, rows: 20 })! + const parts = chunked.split('\x1b\\').filter(Boolean) + expect(parts.length).toBe(3) + expect(parts[0]).toContain(',m=1;') + expect(parts[1]!.startsWith('\x1b_Gm=1;')).toBe(true) + expect(parts[2]!.startsWith('\x1b_Gm=0;')).toBe(true) + expect(Buffer.from(parts.map((part) => part.slice(part.indexOf(';') + 1)).join(''), 'base64').equals(big)).toBe(true) + }) + + it('deletes by id under kitty, and has nothing to delete under iTerm2', () => { + expect(deleteImage('kitty')).toBe(`\x1b_Ga=d,d=I,i=${KITTY_IMAGE_ID},q=2\x1b\\`) + expect(deleteImage('iterm2')).toBe('') + }) +}) + +describe('getting it through tmux and onto a cell', () => { + it('wraps in a DCS envelope with every ESC doubled', () => { + expect(tmuxPassthrough('\x1b]1337;x\x07')).toBe('\x1bPtmux;\x1b\x1b]1337;x\x07\x1b\\') + }) + + it('moves to the cell, draws, and puts the cursor back, wrapping only the image', () => { + expect(placeAt('IMG', 4, 9, false)).toBe('\x1b7\x1b[10;5HIMG\x1b8') + expect(placeAt('\x1bX', 0, 0, true)).toBe('\x1b7\x1b[1;1H\x1bPtmux;\x1b\x1bX\x1b\\\x1b8') + }) +}) + +describe('reading image headers', () => { + it('knows PNG, GIF, BMP, WebP and JPEG by their headers, and nothing else', () => { + expect(imageInfo(PNG_1x1)).toEqual({ format: 'png', width: 1, height: 1 }) + expect(imageInfo(Buffer.from('GIF89a\x40\x01\xf0\x00', 'latin1'))).toEqual({ format: 'gif', width: 320, height: 240 }) + const bmp = Buffer.alloc(26) + bmp.write('BM', 0, 'latin1') + bmp.writeInt32LE(640, 18) + bmp.writeInt32LE(-480, 22) + expect(imageInfo(bmp)).toEqual({ format: 'bmp', width: 640, height: 480 }) + const webp = Buffer.alloc(30) + webp.write('RIFF', 0, 'latin1') + webp.write('WEBP', 8, 'latin1') + webp.write('VP8X', 12, 'latin1') + webp.writeUIntLE(1023, 24, 3) + webp.writeUIntLE(767, 27, 3) + expect(imageInfo(webp)).toEqual({ format: 'webp', width: 1024, height: 768 }) + // SOI, an APP0 segment to step over, then SOF0 with height 600 and width 800. + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x02, 0x58, 0x03, 0x20, 0x03]) + expect(imageInfo(jpeg)).toEqual({ format: 'jpeg', width: 800, height: 600 }) + expect(imageInfo(Buffer.from('not an image at all, really'))).toBeNull() + expect(imageInfo(new Uint8Array())).toBeNull() + }) +}) + +describe('an image as a document', () => { + it('is an image when read whole, and a hex dump when the read was cut short', () => { + const whole = blankDocument('left', 'dot.png', '/x/dot.png') + fillDocument(whole, { bytes: PNG_1x1, size: PNG_1x1.length }) + expect(whole.kind).toBe('image') + expect(whole.image).toEqual({ format: 'png', width: 1, height: 1 }) + const cut = blankDocument('left', 'huge.png', '/x/huge.png') + fillDocument(cut, { bytes: PNG_1x1, size: 50_000_000 }) + expect(cut.kind).toBe('binary') + expect(cut.image).toBeNull() + expect(isImageName('a.JPG')).toBe(true) + expect(isImageName('a.svg')).toBe(false) + }) +}) diff --git a/apps/cli/src/tui/graphics.ts b/apps/cli/src/tui/graphics.ts new file mode 100644 index 0000000..bf3b80f --- /dev/null +++ b/apps/cli/src/tui/graphics.ts @@ -0,0 +1,121 @@ +/** + * Drawing an image in the terminal. + * + * Two protocols cover the terminals people use. The iTerm2 one (OSC 1337) is + * one escape sequence carrying the file itself, in whatever format the + * terminal can decode, and WezTerm, iTerm2 and Konsole speak it. The kitty one + * (APC G) is spoken by kitty and Ghostty; it takes PNG only, and wants the + * data in chunks. Both are ignored by a terminal that does not know them, so + * guessing wrong costs a blank box rather than mojibake. + * + * tmux drops both unless told to pass them through, and then it wants each + * sequence wrapped in a DCS envelope with every ESC doubled. `diskpush tui` + * turns passthrough on for its own pane at startup; this file only wraps. + * + * Everything here is a pure function of bytes and numbers. The one effect, + * writing to the terminal, is the caller's. + */ +import type { ImageInfo } from './model.js' + +export type Protocol = 'iterm2' | 'kitty' + +/** + * Which protocol to speak, from hqtui's guess at the emulator. + * + * kitty and Ghostty do not speak iTerm2's; everything else that draws images + * speaks it, and under tmux the emulator cannot be seen at all (tmux says + * `tmux`), so iTerm2's is the default: WezTerm is what is usually out there. + */ +export function chooseProtocol(program: string): Protocol { + return program === 'kitty' || program === 'ghostty' ? 'kitty' : 'iterm2' +} + +/** The image id every placement uses, so the next one replaces the last and a delete finds it. */ +export const KITTY_IMAGE_ID = 31337 + +/** Kitty wants its payload in chunks of at most this many base64 characters. */ +const KITTY_CHUNK = 4096 + +/** A cell is about twice as tall as it is wide; that is all the fitting needs. */ +const CELL_ASPECT = 2 + +/** + * A guess at a cell in pixels, for one question only: does the image fit the + * box at its own size? The terminal is not asked, because the answer would + * arrive on stdin in the middle of hqtui's input stream. Guessing small keeps + * a photo scaled down; guessing a little wrong only changes which icons are + * drawn one-to-one and which are shrunk. + */ +const CELL_PX = { width: 10, height: 20 } + +/** True when the image is small enough to be drawn at its own size inside the box. */ +export function fitsNaturally(image: { width: number; height: number }, box: { cols: number; rows: number }): boolean { + return image.width <= box.cols * CELL_PX.width && image.height <= box.rows * CELL_PX.height +} + +/** + * How many cells an image should take to fit a box without distortion. + * + * The iTerm2 protocol keeps the aspect ratio itself; kitty stretches to + * whatever cell box it is given, so the box has to be the image's shape. + */ +export function fitCells(image: { width: number; height: number }, box: { cols: number; rows: number }): { cols: number; rows: number } { + if (image.width <= 0 || image.height <= 0 || box.cols <= 0 || box.rows <= 0) return { cols: 0, rows: 0 } + // The image in cell units: width in columns, height in rows. + const wide = image.width + const tall = image.height / CELL_ASPECT + const scale = Math.min(box.cols / wide, box.rows / tall) + return { cols: Math.max(1, Math.floor(wide * scale)), rows: Math.max(1, Math.floor(tall * scale)) } +} + +/** An image as the terminal's escape sequence, sized to a box of cells. Null when the protocol cannot take the format. */ +export function encodeImage( + protocol: Protocol, + bytes: Uint8Array, + info: ImageInfo, + box: { cols: number; rows: number }, +): string | null { + const data = Buffer.from(bytes).toString('base64') + // A photo is shrunk to the box; an icon is drawn as it is, not blown up to fill it. + const natural = fitsNaturally(info, box) + if (protocol === 'iterm2') { + const size = natural ? ['width=auto', 'height=auto'] : [`width=${box.cols}`, `height=${box.rows}`] + const params = [`inline=1`, `size=${bytes.length}`, ...size, `preserveAspectRatio=1`] + return `\x1b]1337;File=${params.join(';')}:${data}\x07` + } + if (info.format !== 'png') return null + const fit = natural ? null : fitCells(info, box) + const chunks: string[] = [] + for (let at = 0; at < data.length; at += KITTY_CHUNK) chunks.push(data.slice(at, at + KITTY_CHUNK)) + return chunks + .map((chunk, index) => { + const last = index === chunks.length - 1 + const control = + index === 0 + ? `a=T,f=100,i=${KITTY_IMAGE_ID},${fit ? `c=${fit.cols},r=${fit.rows},` : ''}q=2,m=${last ? 0 : 1}` + : `m=${last ? 0 : 1}` + return `\x1b_G${control};${chunk}\x1b\\` + }) + .join('') +} + +/** Takes the last placement down. iTerm2 images are cells, and cells are cleared by drawing over them. */ +export function deleteImage(protocol: Protocol): string { + return protocol === 'kitty' ? `\x1b_Ga=d,d=I,i=${KITTY_IMAGE_ID},q=2\x1b\\` : '' +} + +/** A sequence tmux will hand to the terminal untouched, given `allow-passthrough on`. */ +export function tmuxPassthrough(sequence: string): string { + return `\x1bPtmux;${sequence.replaceAll('\x1b', '\x1b\x1b')}\x1b\\` +} + +/** + * The bytes that put `sequence` at a cell and leave the cursor where it was. + * + * The cursor moves are outside any tmux envelope, because tmux has to see + * them to know where the image lands; only the image itself is wrapped. + */ +export function placeAt(sequence: string, x: number, y: number, tmux: boolean): string { + const body = tmux ? tmuxPassthrough(sequence) : sequence + return `\x1b7\x1b[${y + 1};${x + 1}H${body}\x1b8` +} diff --git a/apps/cli/src/tui/interaction.test.ts b/apps/cli/src/tui/interaction.test.ts index 8e4b8cc..94ca8ad 100644 --- a/apps/cli/src/tui/interaction.test.ts +++ b/apps/cli/src/tui/interaction.test.ts @@ -13,6 +13,7 @@ import { renderToScreen } from '@profullstack/hqtui/testing' import { key } from './keys.fixture.js' import { visibleRows, type Entry, type Pane, type Side } from './model.js' import type { Launch, Launcher } from './launch.js' +import { PNG_1x1 } from './graphics.test.js' vi.mock('@diskpush/ssh-core', () => ({ SshSession: { connect: async () => ({ close: () => {} }) }, @@ -355,7 +356,7 @@ describe('the mouse', () => { it('quits from the key bar through the app it is attached to', async () => { const app = tui() const quit = vi.fn() - app.attach({ quit, invalidate: () => {} } as unknown as App) + app.attach({ quit, invalidate: () => {}, on: () => () => {} } as unknown as App) const screen = frame(app) const q = screen.find('q quit')! screen.click(q.x, q.y) @@ -366,7 +367,7 @@ describe('the mouse', () => { it('gives a dialog the whole screen: the key bar under it is not clickable', async () => { const app = tui() const quit = vi.fn() - app.attach({ quit, invalidate: () => {} } as unknown as App) + app.attach({ quit, invalidate: () => {}, on: () => () => {} } as unknown as App) await press(app, '?') const screen = frame(app) // Bottom row, where the key bar is: the click is taken by the backdrop, @@ -704,7 +705,7 @@ describe('editing and opening with the system', () => { it('without tmux, e hands the terminal over: the app quits and the runner gets the program', async () => { const { app, root, launched } = browser({ inTmux: false }) const quit = vi.fn() - app.attach({ quit, invalidate: () => {}, height: 30 } as unknown as App) + app.attach({ quit, invalidate: () => {}, height: 30, on: () => () => {} } as unknown as App) goto(app, 'notes.md') await press(app, 'e') expect(launched).toEqual([]) @@ -717,7 +718,7 @@ describe('editing and opening with the system', () => { it('coming back from a hand-off re-reads the panes and the open file', async () => { const { app, root } = browser({ inTmux: false }) - app.attach({ quit: () => {}, invalidate: () => {}, height: 30 } as unknown as App) + app.attach({ quit: () => {}, invalidate: () => {}, height: 30, on: () => () => {} } as unknown as App) goto(app, 'notes.md') await press(app, 'v') expect(state(app).document?.text).toBe('# notes\n') @@ -787,3 +788,105 @@ describe('editing and opening with the system', () => { expect(launched[0]?.launch.argv[0]).toBe('vim') }) }) + +describe('viewing an image', () => { + /** A fake app that records raw terminal writes and lets a test fire the frame event. */ + function fakeApp() { + const writes: string[] = [] + const listeners: Record void> = {} + const redraw = vi.fn() + const app = { + on: (event: string, fn: () => void) => { + listeners[event] = fn + return () => {} + }, + terminal: { write: (data: string) => writes.push(data) }, + redraw, + quit: () => {}, + invalidate: () => {}, + height: 30, + } as unknown as App + return { app, writes, listeners, redraw } + } + function withImage(inTmux: boolean, protocol: 'iterm2' | 'kitty' = 'iterm2') { + const root = mkdtempSync(join(tmpdir(), 'diskpush-img-')) + writeFileSync(join(root, 'dot.png'), PNG_1x1) + writeFileSync(join(root, 'notes.md'), '# notes\n') + const left = blankPane('Local', root) + left.entries = listLocal(root) + const launcher: Launcher = { + env: {}, + available: () => false, + inTmux, + tmux: async () => {}, + detach: () => {}, + } + const tui = new Tui(left, blankPane('Local', '/tmp/b'), [], launcher, protocol) + const fake = fakeApp() + tui.attach(fake.app) + return { tui, root, ...fake } + } + const goto = (app: Tui, name: string) => { + const p = pane(app, 'left') + p.index = visibleRows(p).findIndex((row) => row.entry.name === name) + } + + it('reads a PNG whole, says what it is, and hands it to the terminal once the frame is out', async () => { + const { tui, writes, listeners } = withImage(false) + goto(tui, 'dot.png') + await press(tui, 'v') + expect(state(tui).document?.kind).toBe('image') + expect(state(tui).document?.image).toEqual({ format: 'png', width: 1, height: 1 }) + const screen = frame(tui) + expect(screen.contains('PNG 1×1')).toBe(true) + expect(writes).toEqual([]) + listeners.frame!() + expect(writes).toHaveLength(1) + // Placed below the hint line, inside the panel, with the cursor put back. + expect(writes[0]).toMatch(/^\x1b7\x1b\[\d+;\d+H\x1b\]1337;File=inline=1;size=\d+;width=(?:\d+|auto);height=(?:\d+|auto);preserveAspectRatio=1:[A-Za-z0-9+/=]+\x07\x1b8$/) + // The same frame again draws nothing new. + frame(tui) + listeners.frame!() + expect(writes).toHaveLength(1) + }) + + it('under tmux the image is wrapped for passthrough, and the cursor moves are not', () => { + const { tui, writes, listeners } = withImage(true) + goto(tui, 'dot.png') + return press(tui, 'v').then(() => { + frame(tui) + listeners.frame!() + expect(writes[0]).toMatch(/^\x1b7\x1b\[\d+;\d+H\x1bPtmux;\x1b\x1b\]1337;.*\x07\x1b\\\x1b8$/) + }) + }) + + it('kitty gets chunked APC, and a delete when the file closes', async () => { + const { tui, writes, listeners, redraw } = withImage(false, 'kitty') + goto(tui, 'dot.png') + await press(tui, 'v') + frame(tui) + listeners.frame!() + expect(writes[0]).toContain('\x1b_Ga=T,f=100,i=31337,') + await press(tui, 'escape') + frame(tui) + listeners.frame!() + expect(writes[1]).toBe('\x1b_Ga=d,d=I,i=31337,q=2\x1b\\') + expect(redraw).toHaveBeenCalledTimes(1) + }) + + it('moving to a text file takes the image down with a full repaint', async () => { + const { tui, writes, listeners, redraw } = withImage(false) + goto(tui, 'dot.png') + await press(tui, 'v') + frame(tui) + listeners.frame!() + expect(writes).toHaveLength(1) + goto(tui, 'notes.md') + await press(tui, 'enter') + frame(tui) + listeners.frame!() + // iTerm2 has nothing to write for a delete; the repaint is what erases it. + expect(writes).toHaveLength(1) + expect(redraw).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/cli/src/tui/launch.ts b/apps/cli/src/tui/launch.ts index b9ca82a..7ddf1df 100644 --- a/apps/cli/src/tui/launch.ts +++ b/apps/cli/src/tui/launch.ts @@ -11,7 +11,7 @@ * * The planning is pure and tested; the two effects at the bottom are thin. */ -import { spawn } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { delimiter, dirname, extname, join } from 'node:path' import { shellJoin, shellQuote } from '@diskpush/rsync-core' @@ -208,3 +208,21 @@ export function systemLauncher(env: NodeJS.ProcessEnv = process.env): Launcher { detach: (launch) => spawnDetached(launch, env), } } + +/** + * Lets terminal graphics through tmux for this pane. + * + * tmux swallows the escape sequences an image rides on unless + * `allow-passthrough` is on, and it is off by default. Setting it on the + * pane, not the server, changes nothing for anyone else's windows and needs + * no line in anyone's config. A tmux too old to know the option says so on + * stderr, which is ignored: the image is then simply not drawn. + */ +export function enableTmuxPassthrough(env: NodeJS.ProcessEnv = process.env): void { + if (!env.TMUX) return + try { + spawnSync('tmux', ['set', '-p', 'allow-passthrough', 'on'], { env, stdio: 'ignore' }) + } catch { + // No tmux binary on PATH despite $TMUX: nothing to do. + } +} diff --git a/apps/cli/src/tui/model.ts b/apps/cli/src/tui/model.ts index 33ebe93..4222fed 100644 --- a/apps/cli/src/tui/model.ts +++ b/apps/cli/src/tui/model.ts @@ -384,8 +384,81 @@ export function formatDuration(seconds: number): string { */ export const DOCUMENT_LIMIT = 1024 * 1024 -/** How a document is drawn: rendered markdown, plain lines, or a hex dump. */ -export type DocumentKind = 'markdown' | 'text' | 'binary' +/** + * An image is read whole, up to this, because the terminal is handed the file + * itself and half a PNG draws nothing. Eight megabytes covers a photo; a + * scan or a poster past it shows as a hex dump with the way out spelled. + */ +export const IMAGE_LIMIT = 8 * 1024 * 1024 + +/** How a document is drawn: rendered markdown, plain lines, an image, or a hex dump. */ +export type DocumentKind = 'markdown' | 'text' | 'image' | 'binary' + +export type ImageFormat = 'png' | 'jpeg' | 'gif' | 'webp' | 'bmp' + +/** What the header of an image file says about it. */ +export type ImageInfo = { format: ImageFormat; width: number; height: number } + +/** Files that are worth reading whole, because they are drawn rather than dumped. */ +export function isImageName(name: string): boolean { + return /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(name) +} + +const ascii = (bytes: Uint8Array, at: number, text: string) => + bytes.length >= at + text.length && Buffer.from(bytes.subarray(at, at + text.length)).toString('latin1') === text + +/** + * The format and pixel size of an image, from its header alone. + * + * Nothing is decoded: each format writes its dimensions near the top, and + * that is what the viewer needs to size the box the terminal draws into and + * to say `PNG 2172×724` in the footer. A file whose header fits none of them + * is not an image, whatever its name. + */ +export function imageInfo(bytes: Uint8Array): ImageInfo | null { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + if (ascii(bytes, 1, 'PNG') && bytes[0] === 0x89 && bytes.length >= 24) { + return { format: 'png', width: view.getUint32(16), height: view.getUint32(20) } + } + if (ascii(bytes, 0, 'GIF8') && bytes.length >= 10) { + return { format: 'gif', width: view.getUint16(6, true), height: view.getUint16(8, true) } + } + if (ascii(bytes, 0, 'BM') && bytes.length >= 26) { + return { format: 'bmp', width: view.getInt32(18, true), height: Math.abs(view.getInt32(22, true)) } + } + if (ascii(bytes, 0, 'RIFF') && ascii(bytes, 8, 'WEBP') && bytes.length >= 30) { + const chunk = Buffer.from(bytes.subarray(12, 16)).toString('latin1') + if (chunk === 'VP8X') { + const width = 1 + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + const height = 1 + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + return { format: 'webp', width, height } + } + if (chunk === 'VP8 ') return { format: 'webp', width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff } + if (chunk === 'VP8L') { + const bits = view.getUint32(21, true) + return { format: 'webp', width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) } + } + return null + } + if (bytes[0] === 0xff && bytes[1] === 0xd8) { + // Walk the markers to the first start-of-frame, which carries the size. + let at = 2 + while (at + 9 < bytes.length && bytes[at] === 0xff) { + const marker = bytes[at + 1]! + if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { + at += 2 + continue + } + const length = view.getUint16(at + 2) + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { format: 'jpeg', height: view.getUint16(at + 5), width: view.getUint16(at + 7) } + } + at += 2 + length + } + return null + } + return null +} /** A file open under the panes. */ export type Document = { @@ -398,6 +471,8 @@ export type Document = { loading: boolean error: string | null kind: DocumentKind + /** Format and pixel size, for an image. */ + image: ImageInfo | null /** Decoded text for markdown and text; empty for a binary. */ text: string /** The raw head, kept for the hex view. */ @@ -416,6 +491,7 @@ export function blankDocument(side: Side, name: string, location: string): Docum loading: true, error: null, kind: 'text', + image: null, text: '', bytes: new Uint8Array(), size: 0, @@ -510,8 +586,13 @@ export function fillDocument( doc.loading = false doc.error = null doc.scroll = 0 + doc.image = null if (looksBinary(head.bytes)) { - doc.kind = 'binary' + // An image the terminal can be handed whole is drawn; one cut short by + // the read limit is a hex dump like any other binary. + const info = imageInfo(head.bytes) + doc.kind = info && head.size === head.bytes.length ? 'image' : 'binary' + doc.image = doc.kind === 'image' ? info : null doc.text = '' return } diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index d4d20cc..f61c819 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -5,7 +5,7 @@ * describes a frame. That is what lets `view.test.ts` assert on real rendered * text with no pty, and what keeps the app class down to state and effects. */ -import type { Color, Container, Theme } from '@profullstack/hqtui' +import type { Color, Container, Rect, Theme } from '@profullstack/hqtui' import { stringWidth, truncate, widgets } from '@profullstack/hqtui' import type { Change } from '@diskpush/schemas' import { @@ -108,6 +108,12 @@ export type ViewHandlers = { * a scroll before the next frame rather than after it. */ onDocumentLayout?: (total: number, rows: number) => void + /** + * Where an image should be drawn, in screen cells. The frame leaves the + * cells blank; the app hands the terminal the image after the frame is + * out, because an image is not a cell and no framebuffer can hold it. + */ + onImageRect?: (rect: Rect) => void } /** Rows the transfer panel takes when one is on screen. */ @@ -372,8 +378,14 @@ function drawDocument( // The border and its padding take two columns a side; the scrollbar one more. const textWidth = Math.max(10, width - 5) const lines = doc.loading || doc.error ? [] : documentLines(doc, textWidth, theme) - // A hex dump is the viewer admitting defeat, so it says what would work. - const hint = doc.kind === 'binary' ? 'Not a text file. e edits it; x opens it with a player, a viewer or the desktop.' : null + // A hex dump is the viewer admitting defeat, so it says what would work; + // an image says what it is, because the terminal may draw nothing at all. + const hint = + doc.kind === 'binary' + ? 'Not a text file. e edits it; x opens it with a player, a viewer or the desktop.' + : doc.kind === 'image' && doc.image + ? `${doc.image.format.toUpperCase()} ${doc.image.width}×${doc.image.height}. Drawn below when the terminal can (WezTerm, kitty, iTerm2, Konsole); x opens it elsewhere.` + : null const rows = Math.max(1, height - 2 - paneRowsBeside(height) - DOCUMENT_CHROME - (hint ? 1 : 0)) const scroll = Math.min(doc.scroll, maxScroll(lines.length, rows)) const scrolls = lines.length > rows @@ -382,6 +394,7 @@ function drawDocument( const last = Math.min(lines.length, scroll + rows) const footerParts = [ lines.length > 0 ? `lines ${scroll + 1}–${last} of ${lines.length}` : '', + doc.kind === 'image' && doc.image ? `${doc.image.width}×${doc.image.height}` : '', doc.loading || doc.error ? '' : doc.kind, doc.loading ? '' @@ -410,6 +423,12 @@ function drawDocument( panel.text(doc.error, { fg: theme.danger, wrap: true, align: 'center' }) return } + if (doc.kind === 'image') { + if (hint) panel.text(hint, { height: 1, fg: theme.muted }) + // Blank on purpose: the image goes here, after the frame. + panel.draw((surface) => handlers.onImageRect?.(surface.hitRect()), { height: 'fill' }) + return + } if (lines.length === 0) { panel.spacer(1) panel.text('Empty file', { align: 'center', fg: theme.muted })