diff --git a/scripts/generate-art.mjs b/scripts/generate-art.mjs new file mode 100644 index 0000000..5f59614 --- /dev/null +++ b/scripts/generate-art.mjs @@ -0,0 +1,258 @@ +// Regenerates src/logo/ascii-emblem-*.txt and src/art/bitmaps.ts. Run: node scripts/generate-art.mjs +import { readFileSync, writeFileSync } from 'node:fs'; + +const TONE_RAMP = ' .:-=+*#%@'; + +function brailleDots(path) { + const lines = readFileSync(path, 'utf8').replace(/\s+$/, '').split('\n'); + const width = Math.max(...lines.map((line) => [...line].length)) * 2; + const height = lines.length * 4; + const dots = Array.from({ length: height }, () => new Float32Array(width)); + const offsets = [ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + [1, 1], + [1, 2], + [0, 3], + [1, 3], + ]; + lines.forEach((line, row) => { + [...line].forEach((char, col) => { + const mask = char.codePointAt(0) - 0x2800; + if (mask <= 0 || mask >= 256) return; + offsets.forEach(([dx, dy], bit) => { + if ((mask >> bit) & 1) dots[row * 4 + dy][col * 2 + dx] = 1; + }); + }); + }); + return { dots, width, height }; +} + +function boxBlur(grid, width, height, radius) { + return grid.map((_, y) => { + const out = new Float32Array(width); + for (let x = 0; x < width; x++) { + let sum = 0; + let count = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const yy = y + dy; + const xx = x + dx; + if (yy >= 0 && yy < height && xx >= 0 && xx < width) { + sum += grid[yy][xx]; + count++; + } + } + } + out[x] = sum / count; + } + return out; + }); +} + +function shadedEmblem(path, outWidth) { + const { dots, width, height } = brailleDots(path); + const blurred = boxBlur(boxBlur(dots, width, height, 1), width, height, 1); + const scaleX = width / outWidth; + const scaleY = height / outWidth; + const pixels = Array.from({ length: outWidth }, (_, y) => + Array.from({ length: outWidth }, (_, x) => { + let sum = 0; + let count = 0; + for (let yy = Math.floor(y * scaleY); yy < Math.floor((y + 1) * scaleY); yy++) { + for (let xx = Math.floor(x * scaleX); xx < Math.floor((x + 1) * scaleX); xx++) { + sum += blurred[yy][xx]; + count++; + } + } + return sum / count; + }), + ); + const peak = Math.max(...pixels.flat()); + const rows = []; + for (let y = 0; y < outWidth; y += 2) { + let line = ''; + for (let x = 0; x < outWidth; x++) { + const value = (pixels[y][x] + (pixels[y + 1]?.[x] ?? 0)) / 2 / peak; + const level = Math.round(Math.pow(value, 0.5) * (TONE_RAMP.length - 1)); + line += TONE_RAMP[Math.min(TONE_RAMP.length - 1, level)]; + } + rows.push(line.replace(/\s+$/, '')); + } + return rows.join('\n') + '\n'; +} + +class Canvas { + constructor(width, height) { + this.width = width; + this.height = height; + this.cells = Array.from({ length: height }, () => Array(width).fill(null)); + } + set(x, y, color) { + x = Math.round(x); + y = Math.round(y); + if (x >= 0 && x < this.width && y >= 0 && y < this.height) this.cells[y][x] = color; + } + get(x, y) { + return this.cells[y]?.[x] ?? null; + } + rect(x0, y0, x1, y1, color) { + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) + this.set(x, y, typeof color === 'function' ? color(x, y) : color); + } + circle(cx, cy, r, color, hole = 0) { + for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) { + for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) { + const d = Math.hypot(x - cx, (y - cy) * 1); + if (d <= r && d >= hole) + this.set(x, y, typeof color === 'function' ? color(x, y, d) : color); + } + } + } + line(x0, y0, x1, y1, color) { + const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)); + for (let i = 0; i <= steps; i++) + this.set(x0 + ((x1 - x0) * i) / steps, y0 + ((y1 - y0) * i) / steps, color); + } + toBitmap() { + const symbols = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const palette = {}; + const lookup = new Map(); + const rows = this.cells.map((row) => + row + .map((color) => { + if (color === null) return ' '; + if (!lookup.has(color)) { + if (lookup.size >= symbols.length) throw new Error('palette too large'); + const symbol = symbols[lookup.size]; + lookup.set(color, symbol); + palette[symbol] = color; + } + return lookup.get(color); + }) + .join(''), + ); + return { palette, rows }; + } +} + +const ICON = { + navy: '#102a52', + blue: '#0ea5e9', + cyan: '#06b6d4', + pale: '#bae6fd', + white: '#f1f5f9', + amber: '#f59e0b', + slate: '#94a3b8', +}; + +function calendarPage(c, x0, y0, x1, y1, mark) { + c.rect(x0, y0 + 1, x1, y1, ICON.white); + c.rect(x0, y0 + 1, x1, y0 + 2, ICON.blue); + c.set(x0 + 2, y0, ICON.slate); + c.set(x1 - 2, y0, ICON.slate); + for (let y = y0 + 4; y <= y1 - 1; y += 2) + for (let x = x0 + 2; x <= x1 - 1; x += 2) c.set(x, y, ICON.pale); + if (mark) c.rect(...mark, ICON.amber); +} + +function icons() { + const make = (draw) => { + const c = new Canvas(12, 10); + draw(c); + return c.toBitmap(); + }; + return { + default: make((c) => calendarPage(c, 1, 0, 10, 9, [5, 6, 6, 7])), + meeting: make((c) => { + c.rect(0, 0, 7, 4, ICON.cyan); + c.set(1, 5, ICON.cyan); + c.set(2, 5, ICON.cyan); + c.set(1, 6, ICON.cyan); + [2, 4, 6].forEach((x) => c.set(x, 2, ICON.navy)); + c.rect(4, 5, 11, 9, ICON.pale); + c.set(10, 7, null); + [6, 8, 10].forEach((x) => c.set(x, 7, ICON.blue)); + }), + workshop: make((c) => { + c.rect(0, 0, 11, 9, ICON.navy); + [ + [3, 2], + [2, 3], + [1, 4], + [1, 5], + [2, 6], + [3, 7], + [8, 2], + [9, 3], + [10, 4], + [10, 5], + [9, 6], + [8, 7], + ].forEach(([x, y]) => c.set(x, y, ICON.pale)); + [ + [7, 2], + [7, 3], + [6, 4], + [6, 5], + [5, 6], + [5, 7], + ].forEach(([x, y]) => c.set(x, y, ICON.cyan)); + }), + repair: make((c) => { + c.circle(5.5, 4.5, 3.6, (x, y, d) => (d < 2.4 ? ICON.slate : ICON.white), 1.4); + [ + [5, 0], + [6, 0], + [5, 9], + [6, 9], + [0, 4], + [0, 5], + [11, 4], + [11, 5], + [2, 1], + [9, 1], + [2, 8], + [9, 8], + ].forEach(([x, y]) => c.set(x, y, ICON.slate)); + c.set(5, 4, ICON.amber); + c.set(6, 5, ICON.amber); + }), + conference: make((c) => { + c.rect(0, 0, 11, 6, ICON.slate); + c.rect(1, 1, 10, 5, ICON.navy); + c.rect(2, 4, 3, 5, ICON.cyan); + c.rect(5, 2, 6, 5, ICON.pale); + c.rect(8, 3, 9, 5, ICON.amber); + c.rect(5, 7, 6, 8, ICON.slate); + c.rect(3, 9, 8, 9, ICON.slate); + }), + }; +} + +function emitBitmap(name, bitmap) { + const palette = JSON.stringify(bitmap.palette) + .replace(/"/g, "'") + .replace(/,/g, ', ') + .replace(/:/g, ': '); + const rows = bitmap.rows.map((row) => ` '${row}',`).join('\n'); + return `export const ${name}: Bitmap = {\n palette: ${palette},\n rows: [\n${rows}\n ],\n};\n`; +} + +const bitmaps = [ + "import type { Bitmap } from '../core/pixel-art.js';", + '', + '// Generated by scripts/generate-art.mjs; edit the script, not this file.', +]; +const iconSet = icons(); +for (const [name, bitmap] of Object.entries(iconSet)) + bitmaps.push(emitBitmap(`eventIcon${name[0].toUpperCase()}${name.slice(1)}`, bitmap)); +writeFileSync('src/art/bitmaps.ts', bitmaps.join('\n')); + +const large = 'src/logo/ca-dotmatrix-large.txt'; +writeFileSync('src/logo/ascii-emblem-large.txt', shadedEmblem(large, 44)); +writeFileSync('src/logo/ascii-emblem-medium.txt', shadedEmblem(large, 30)); +console.log('art generated'); diff --git a/scripts/generate-glyphs.mjs b/scripts/generate-glyphs.mjs new file mode 100644 index 0000000..60ac845 --- /dev/null +++ b/scripts/generate-glyphs.mjs @@ -0,0 +1,55 @@ +/* global document */ +// Measures how much ink each printable ASCII character puts in a 4 x 8 grid of its cell, and writes +// src/art/glyphs.ts. Needs Playwright with a Chromium, which is not a dependency of this package: +// PLAYWRIGHT=/path/to/playwright/index.mjs node scripts/generate-glyphs.mjs +import { writeFileSync } from 'node:fs'; + +const { chromium } = await import(process.env.PLAYWRIGHT ?? 'playwright'); +const COLS = 4; +const ROWS = 8; +const browser = await chromium.launch(); +const page = await browser.newPage(); +const table = await page.evaluate( + async ({ cols, rows }) => { + await document.fonts.load('200px "JetBrainsMono NFM"'); + const cellWidth = 120; + const cellHeight = 264; + const baseline = 204; + const canvas = document.createElement('canvas'); + canvas.width = cellWidth; + canvas.height = cellHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + const out = []; + for (let code = 0x20; code < 0x7f; code++) { + context.clearRect(0, 0, cellWidth, cellHeight); + context.fillStyle = '#000'; + context.font = '200px "JetBrainsMono NFM"'; + context.textBaseline = 'alphabetic'; + context.fillText(String.fromCharCode(code), 0, baseline); + const data = context.getImageData(0, 0, cellWidth, cellHeight).data; + const cover = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + let sum = 0; + const w = cellWidth / cols; + const h = cellHeight / rows; + for (let y = Math.floor(row * h); y < Math.floor((row + 1) * h); y++) { + for (let x = Math.floor(col * w); x < Math.floor((col + 1) * w); x++) + sum += data[(y * cellWidth + x) * 4 + 3] ?? 0; + } + cover.push(Math.round(sum / (w * h))); + } + } + out.push(cover.map((value) => value.toString(16).padStart(2, '0')).join('')); + } + return out; + }, + { cols: COLS, rows: ROWS }, +); +await browser.close(); +const body = table.map((hex) => ` '${hex}',`).join('\n'); +writeFileSync( + 'src/art/glyphs.ts', + `// Generated by scripts/generate-glyphs.mjs from JetBrains Mono; edit the script, not this file.\n// One entry per printable ASCII character from space to ~: ink coverage 00 to ff for a 4 x 8 grid, row by row.\nexport const GLYPH_COLS = ${COLS};\nexport const GLYPH_ROWS = ${ROWS};\nexport const GLYPHS: readonly string[] = [\n${body}\n];\n`, +); +console.log(`${table.length} glyphs`); diff --git a/src/app/ambient.test.ts b/src/app/ambient.test.ts new file mode 100644 index 0000000..fad4d89 --- /dev/null +++ b/src/app/ambient.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { withAmbient, type AmbientSpec } from './ambient.js'; +import { eventIconConference } from '../art/bitmaps.js'; +import { stripAnsi, visualWidth } from '../core/text.js'; + +const body = Array.from( + { length: 6 }, + (_, index) => ` line ${String(index)} of the page content ${'.'.repeat(30)}`, +); +const noon = new Date('2026-10-28T12:00:00'); + +const specs: [string, AmbientSpec][] = [ + ['sky', { kind: 'sky' }], + ['mountain', { kind: 'mountain', progress: 0.4, weeks: 17 }], + ['shelf', { kind: 'shelf' }], + ['emblem', { kind: 'emblem' }], + ['icon', { kind: 'icon', icon: eventIconConference }], +]; + +describe('withAmbient', () => { + it.each(specs)('draws the %s in a big window without disturbing the content', (_name, spec) => { + const lines = withAmbient(body, spec, 120, 38, noon); + expect(lines.length).toBeGreaterThan(body.length); + expect(lines.length).toBeLessThanOrEqual(38); + expect(lines.every((line) => visualWidth(line) <= 120)).toBe(true); + expect(lines.map((line) => stripAnsi(line).trimEnd()).join('\n')).toContain( + 'line 5 of the page content', + ); + }); + + it.each(specs)( + 'leaves the %s out when the content fills an ordinary 80 x 24 window', + (_name, spec) => { + const full = Array.from( + { length: 17 }, + (_, index) => ` line ${String(index)} ${'.'.repeat(50)}`, + ); + expect(withAmbient(full, spec, 80, 19, noon)).toEqual(full); + }, + ); + + it('lays a scene under the content as a panorama when enough rows are free', () => { + const lines = withAmbient(body, { kind: 'sky' }, 120, 38, noon); + expect(lines.slice(0, body.length)).toEqual(body); + expect(lines[body.length]).toBe(''); + expect(lines.length).toBe(38); + expect(Math.max(...lines.slice(body.length + 1).map(visualWidth))).toBeGreaterThan(100); + }); + + it('puts a scene beside the content when too few rows remain below it', () => { + const tall = Array.from( + { length: 24 }, + (_, index) => ` row ${String(index)} ${'.'.repeat(40)}`, + ); + const lines = withAmbient(tall, { kind: 'sky' }, 120, 30, noon); + expect(lines.length).toBeLessThanOrEqual(30); + expect(visualWidth(lines[0] ?? '')).toBeGreaterThan(visualWidth(tall[0] ?? '')); + }); + + it('reuses the picture for the same window and minute', () => { + const first = withAmbient(body, { kind: 'sky' }, 120, 38, noon); + const second = withAmbient(body, { kind: 'sky' }, 120, 38, new Date(noon.getTime() + 20_000)); + expect(second).toEqual(first); + }); + + it('grows the picture with the space', () => { + const small = withAmbient(body, { kind: 'sky' }, 100, 16, noon); + const large = withAmbient(body, { kind: 'sky' }, 120, 38, noon); + expect(large.length).toBeGreaterThan(small.length); + }); + + it('keeps the page untouched without a spec or without a measured window', () => { + expect(withAmbient(body, undefined, 120, 38)).toEqual(body); + expect(withAmbient(body, { kind: 'sky' }, Number.POSITIVE_INFINITY, 38)).toEqual(body); + }); + + it('is plain ASCII when the picture is drawn by tone', () => { + const lines = withAmbient(body, { kind: 'sky' }, 120, 38, noon).map(stripAnsi); + expect(lines.join('')).toMatch(/^[\x20-\x7e]*$/); + }); + + it('puts a cup of tea where the moon would go on a rest day', () => { + const narrow = (spec: AmbientSpec) => + withAmbient(body, spec, 84, 12, noon).map(stripAnsi).join('\n'); + expect(narrow({ kind: 'sky', rest: true })).toContain(',---------.'); + expect(narrow({ kind: 'sky' })).not.toContain(',---------.'); + expect(withAmbient(body, { kind: 'sky', rest: true }, 120, 38, noon).length).toBeGreaterThan( + body.length + 8, + ); + }); +}); diff --git a/src/app/ambient.ts b/src/app/ambient.ts new file mode 100644 index 0000000..b271157 --- /dev/null +++ b/src/app/ambient.ts @@ -0,0 +1,118 @@ +import { emblemLines } from '../core/logo.js'; +import { padEndV, visualWidth } from '../core/text.js'; +import chalk from 'chalk'; +import { pictureMode, renderBitmap, type Bitmap } from '../core/pixel-art.js'; +import { mountainScene, scaleBitmap, shelfScene, skyScene } from '../art/scene.js'; +import { teaCup } from '../art/motifs.js'; +import { gasGiantLines, moonLines, planetSize } from '../art/planet.js'; +import { space } from '../core/theme.js'; + +/** What a page draws in the space its content leaves empty. */ +export type AmbientSpec = + | { kind: 'sky'; rest?: boolean } + | { kind: 'shelf' } + | { kind: 'mountain'; progress: number; weeks: number } + | { kind: 'emblem' } + | { kind: 'planet'; style: 'moon' | 'gas' } + | { kind: 'icon'; icon: Bitmap }; + +const GAP = 4; +const MIN_COLS = 30; +const MIN_ROWS = 7; +const MIN_PANORAMA_ROWS = 8; +const MAX_COLS = 130; +const TEA_COLS = 16; +const TEA_ROWS = 6; +const SCENE_GAMMA = 0.8; +const FINE = { x: 2, y: 4 } as const; + +const cache = new Map(); + +function remember(key: string, build: () => string[]): string[] { + const hit = cache.get(key); + if (hit) return hit; + const lines = build(); + if (cache.size >= 8) cache.delete(cache.keys().next().value ?? ''); + cache.set(key, lines); + return lines; +} + +/** A landscape that fills the given box of cells with Braille-fine pixels, or undefined when the box is too small. */ +function scene(spec: AmbientSpec, cols: number, rows: number, now: Date): string[] | undefined { + if (spec.kind !== 'sky' && spec.kind !== 'mountain' && spec.kind !== 'shelf') return undefined; + const width = Math.min(cols, MAX_COLS); + if (width < MIN_COLS || rows < MIN_ROWS) return undefined; + const minute = Math.floor(now.getTime() / 300_000); + const key = [ + spec.kind, + width, + rows, + minute, + spec.kind === 'mountain' ? spec.progress.toFixed(3) : '', + pictureMode(), + chalk.level, + ].join('|'); + return remember(key, () => { + const pixels = { width: width * FINE.x, height: rows * FINE.y }; + const bitmap = + spec.kind === 'sky' + ? skyScene(pixels.width, pixels.height, now) + : spec.kind === 'mountain' + ? mountainScene(pixels.width, pixels.height, spec.progress, spec.weeks, now) + : shelfScene(pixels.width, pixels.height); + return renderBitmap(bitmap, undefined, SCENE_GAMMA); + }); +} + +function compact(spec: AmbientSpec, cols: number, rows: number, now: Date): string[] | undefined { + if (spec.kind === 'emblem') return emblemLines(cols, rows); + if (spec.kind === 'sky') { + if (spec.rest) return cols < TEA_COLS || rows < TEA_ROWS ? undefined : teaCup(now.getTime()); + return cols < MIN_COLS ? undefined : moonLines(planetSize(cols, rows), now); + } + if (spec.kind === 'planet') { + const size = planetSize(cols, rows); + return spec.style === 'moon' ? moonLines(size, now) : gasGiantLines(size); + } + if (spec.kind !== 'icon') return undefined; + const factor = Math.min( + Math.floor(cols / (spec.icon.rows[0]?.length ?? 1)), + Math.floor((rows * 2) / spec.icon.rows.length), + 3, + ); + return factor >= 2 ? renderBitmap(scaleBitmap(spec.icon, factor)) : undefined; +} + +/** + * Fills the empty space of a page: a full-width panorama underneath when enough rows are free, + * otherwise a picture to the right of the content, as large as the leftover space allows. + */ +export function withAmbient( + body: readonly string[], + spec: AmbientSpec | undefined, + cols: number, + bodyRows: number, + now = new Date(), +): string[] { + if (!spec || !Number.isFinite(cols) || !Number.isFinite(bodyRows)) return [...body]; + const indent = visualWidth(space.indent); + const rows = Math.floor(bodyRows); + + const below = rows - body.length - 1; + const panorama = scene(spec, cols - indent * 2, below, now); + if (panorama && below >= MIN_PANORAMA_ROWS) { + return [...body, '', ...panorama.map((line) => space.indent + line)]; + } + + const contentWidth = Math.max(0, ...body.map(visualWidth)); + const width = cols - contentWidth - GAP - indent; + const side = Math.min(rows - 1, Math.round(Math.min(width, MAX_COLS) * 0.55)); + const art = scene(spec, width, side, now) ?? compact(spec, width, rows - 1, now); + if (!art) return [...body]; + + return Array.from({ length: Math.max(body.length, art.length) }, (_, index) => { + const left = body[index] ?? ''; + const right = art[index]; + return right === undefined ? left : `${padEndV(left, contentWidth + GAP)}${right}`; + }); +} diff --git a/src/app/app.ts b/src/app/app.ts index c61312f..c1de302 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -11,6 +11,7 @@ import { import { renderHeader, renderFooter, resolveChromeLayout } from './chrome.js'; import type { AppContext, AppSize, View } from './view.js'; import { homeView } from './views/home.js'; +import { withAmbient } from './ambient.js'; import { scheduleView } from './views/schedule.js'; import { docsView } from './views/docs.js'; import { eventsView } from './views/events.js'; @@ -102,7 +103,7 @@ export async function runApp(): Promise { const activeTab = tabs.find((tab) => tab.id === view); const body = helpOpen ? renderHelp(activeTab?.title ?? '', active?.shortcuts?.() ?? [], tabs.length, cols) - : (active?.render(ctx) ?? []); + : withAmbient(active?.render(ctx) ?? [], active?.ambient?.(), cols, ctx.bodyRows); const bodyScroll = !helpOpen && active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll; const height = computeBodyRows(rows, chrome.headerLines, chrome.footerLines); lastBody = { length: body.length, height }; diff --git a/src/app/view.ts b/src/app/view.ts index 372e2d3..d878430 100644 --- a/src/app/view.ts +++ b/src/app/view.ts @@ -1,4 +1,5 @@ import type { ViewId } from './keys.js'; +import type { AmbientSpec } from './ambient.js'; export interface AppSize { rows: number; @@ -26,6 +27,8 @@ export interface View { contextPath?(): readonly string[] | undefined; /** Keys this view adds on top of the global ones, for the help overlay. */ shortcuts?(): readonly { key: string; label: string }[]; + /** A picture for the space the page's content leaves empty; omitted while the page is busy with a form or a list. */ + ambient?(): AmbientSpec | undefined; isBusy?(): boolean; /** The body is a plain scrollable document right now, with no cursor of its own. */ scrollsBody?(): boolean; diff --git a/src/app/views/docs-render.ts b/src/app/views/docs-render.ts index d46c13b..cdf1176 100644 --- a/src/app/views/docs-render.ts +++ b/src/app/views/docs-render.ts @@ -6,7 +6,11 @@ import type { TextField } from '../fields/text-field.js'; import type { DocLink } from '../../features/docs.js'; import { visualWidth, wrapAnsiWithIndent } from '../../core/text.js'; import { loadingLines } from '../../core/components/spinner.js'; -import { emptyStateLines } from '../../core/components/empty-state.js'; +import { + emptyStateLines, + loadingStateLines, + offlineContext, +} from '../../core/components/empty-state.js'; export type DocsMode = | 'loading' @@ -98,7 +102,7 @@ export function renderDocs( let lines: string[]; switch (state.mode) { case 'loading': - lines = loadingLines(trans.common.loading, cols); + lines = loadingStateLines(trans.common.loading, cols, bodyRows); break; case 'sections': { const context = state.stale ? offlineNotice(cols) : []; @@ -111,7 +115,9 @@ export function renderDocs( break; } case 'files': - lines = state.filesField?.render(bodyRows, cols) ?? loadingLines(trans.common.loading, cols); + lines = + state.filesField?.render(bodyRows, cols) ?? + loadingStateLines(trans.common.loading, cols, bodyRows); break; case 'archivedGroups': lines = state.archivedGroupsField?.render(bodyRows, cols) ?? []; @@ -119,7 +125,7 @@ export function renderDocs( case 'archivedFiles': lines = state.archivedFilesField?.render(bodyRows, cols) ?? - loadingLines(trans.common.loading, cols); + loadingStateLines(trans.common.loading, cols, bodyRows); break; case 'search': lines = state.searchField?.render(cols) ?? []; @@ -146,15 +152,11 @@ export function renderDocs( : renderReader(state.readerRender ?? (() => []), cols); break; case 'error': { - const context = [ - ...wrapAnsiWithIndent( - type.body(state.errorMessage ?? trans.docs.loadError), - cols, - space.indent, - ), - ...hintLines(trans.docs.offlineHint, cols), - '', - ]; + const message = type.body(state.errorMessage ?? trans.docs.loadError); + const hint = hintLines(trans.docs.offlineHint, cols); + const plain = [...wrapAnsiWithIndent(message, cols, space.indent), ...hint, '']; + const fieldRows = state.errorField?.render(Number.POSITIVE_INFINITY, cols).length ?? 0; + const context = offlineContext(message, hint, plain, cols, bodyRows - fieldRows); return state.errorField ? renderListFieldWithContext(context, state.errorField, bodyRows, cols) : context; diff --git a/src/app/views/docs.ts b/src/app/views/docs.ts index 3b58ee8..3f2c614 100644 --- a/src/app/views/docs.ts +++ b/src/app/views/docs.ts @@ -1,4 +1,5 @@ import type { AppContext, View } from '../view.js'; +import type { AmbientSpec } from '../ambient.js'; import { captureFooterHint, digitTabHint, fitFooterHint, passiveFooterHint } from '../chrome.js'; import { ListField, computeMaxVisible } from '../fields/list-field.js'; import { TextField } from '../fields/text-field.js'; @@ -604,6 +605,10 @@ export const docsView = { return renderDocs(state, ctx.size.cols, ctx.bodyRows); }, + ambient(): AmbientSpec | undefined { + return state.mode === 'sections' ? { kind: 'shelf' } : undefined; + }, + isBusy(): boolean { return ( state.mode === 'loading' || state.mode === 'searchLoading' || state.mode === 'readerLoading' diff --git a/src/app/views/events-render.ts b/src/app/views/events-render.ts index 7556d75..93e51f2 100644 --- a/src/app/views/events-render.ts +++ b/src/app/views/events-render.ts @@ -12,8 +12,14 @@ import { } from '../../features/calendar.js'; import { renderHeatmap } from '../../features/calendar-heatmap.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; -import { loadingLines } from '../../core/components/spinner.js'; -import { emptyStateLines } from '../../core/components/empty-state.js'; +import { pictureBeside } from '../../core/components/picture.js'; +import { renderBitmap } from '../../core/pixel-art.js'; +import { eventIcon } from '../../features/event-art.js'; +import { + emptyStateLines, + loadingStateLines, + offlineContext, +} from '../../core/components/empty-state.js'; export type EventsMode = 'loading' | 'hub' | 'heatmap' | 'list' | 'detail' | 'search' | 'error'; @@ -160,7 +166,7 @@ export function renderEvents( const notice = state.stale ? offlineNotice(cols ?? Number.POSITIVE_INFINITY) : []; switch (state.mode) { case 'loading': - return loadingLines(trans.calendar.loading, cols); + return loadingStateLines(trans.calendar.loading, cols, bodyRows); case 'hub': return renderHubBody(state, now, bodyRows, cols); case 'heatmap': @@ -197,10 +203,11 @@ export function renderEvents( return renderListFieldWithContext(context, state.listField, bodyRows, cols); } case 'detail': { - const context = [ - ...notice, - ...wrappedIndentedLines(state.detailTitle ?? '', cols, type.heading), - ...wrappedIndentedLines(state.detailMeta ?? '', cols, type.hint), + const headerText = [type.heading(state.detailTitle ?? ''), type.hint(state.detailMeta ?? '')]; + const plainHeader = headerText.flatMap((line) => + wrappedIndentedLines(line, cols, (value) => value), + ); + const body = [ '', ...(state.detailDescription ? state.detailDescription @@ -212,6 +219,17 @@ export function renderEvents( ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), ''] : []), ]; + const fieldRows = state.detailField?.render(Number.POSITIVE_INFINITY, cols ?? 80).length ?? 0; + const picture = pictureBeside( + renderBitmap(eventIcon(state.detailTitle ?? '', state.detailDescription ?? '')), + headerText, + cols ?? Number.POSITIVE_INFINITY, + ); + const withPicture = + picture !== undefined && + (!Number.isFinite(bodyRows) || + notice.length + picture.length + body.length + fieldRows <= bodyRows); + const context = [...notice, ...(withPicture ? picture : plainHeader), ...body]; return state.detailField ? renderListFieldWithContext(context, state.detailField, bodyRows, cols) : Number.isFinite(bodyRows) @@ -221,11 +239,11 @@ export function renderEvents( case 'search': return [...notice, ...(state.searchField?.render(cols) ?? [])]; case 'error': { - const context = [ - ...wrappedIndentedLines(state.errorMessage ?? trans.calendar.error, cols, type.body), - ...wrappedIndentedLines(trans.calendar.errorHint, cols, type.hint), - '', - ]; + const message = state.errorMessage ?? trans.calendar.error; + const hint = wrappedIndentedLines(trans.calendar.errorHint, cols, type.hint); + const plain = [...wrappedIndentedLines(message, cols, type.body), ...hint, '']; + const fieldRows = state.errorField?.render(Number.POSITIVE_INFINITY, cols).length ?? 0; + const context = offlineContext(type.body(message), hint, plain, cols, bodyRows - fieldRows); if (state.errorField) { return renderListFieldWithContext(context, state.errorField, bodyRows, cols); } diff --git a/src/app/views/events.test.ts b/src/app/views/events.test.ts index c852834..f973c58 100644 --- a/src/app/views/events.test.ts +++ b/src/app/views/events.test.ts @@ -402,6 +402,21 @@ describe('eventsView navigation', () => { expect(selectedLine(ctx)).toContain('Second'); }); + it('draws a picture beside the title of an event detail when there is room, and drops it when there is not', async () => { + calendarInRange.mockReturnValue([event('Git 工作坊', '2026-07-16T10:00:00')]); + const roomy = { ...fakeCtx(), size: { rows: 34, cols: 100 }, bodyRows: 28 }; + await eventsView.load(roomy); + press(roomy, DOWN, '\r', '\r'); + + const titleRow = screen(roomy).findIndex((line) => line.includes('Git 工作坊')); + const withPicture = screen(roomy)[titleRow] ?? ''; + expect(withPicture.slice(0, withPicture.indexOf('Git')).trim()).not.toBe(''); + + const tight = { ...roomy, size: { rows: 12, cols: 100 }, bodyRows: 7 }; + const plain = screen(tight).find((line) => line.includes('Git 工作坊')) ?? ''; + expect(plain.slice(0, plain.indexOf('Git')).trim()).toBe(''); + }); + it('keeps the selected hub item after Esc from a list', async () => { const ctx = fakeCtx(); await eventsView.load(ctx); diff --git a/src/app/views/events.ts b/src/app/views/events.ts index 2464664..6c26b39 100644 --- a/src/app/views/events.ts +++ b/src/app/views/events.ts @@ -1,5 +1,8 @@ import type { Calendar, CalendarEvent } from '@nbtca/nbtcal'; import type { AppContext, View } from '../view.js'; +import type { AmbientSpec } from '../ambient.js'; +import { eventIcon } from '../../features/event-art.js'; +import { eventIconDefault } from '../../art/bitmaps.js'; import { captureFooterHint, passiveFooterHint } from '../chrome.js'; import { ListField, computeMaxVisible } from '../fields/list-field.js'; import { TextField } from '../fields/text-field.js'; @@ -218,6 +221,15 @@ export const eventsView = { ); }, + ambient(): AmbientSpec | undefined { + if (state.mode !== 'hub') return undefined; + const next = state.nextEvent; + return { + kind: 'icon', + icon: next ? eventIcon(next.title, next.description) : eventIconDefault, + }; + }, + isBusy(): boolean { return state.mode === 'loading'; }, diff --git a/src/app/views/home.test.ts b/src/app/views/home.test.ts index fcd08a0..9a0a863 100644 --- a/src/app/views/home.test.ts +++ b/src/app/views/home.test.ts @@ -75,6 +75,12 @@ function defined(value: T | undefined): T { return value; } +describe('homeView ambient picture', () => { + it('asks for the sky, which follows the hour', () => { + expect(homeView.ambient()).toEqual({ kind: 'sky' }); + }); +}); + describe('homeView footer', () => { it('offers tab switching and quitting without move or open actions', () => { const hint = stripAnsi(homeView.footerHint(5, 80)); diff --git a/src/app/views/home.ts b/src/app/views/home.ts index b134b99..36c5c18 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -6,6 +6,7 @@ import { nextClassLine, peekSchedule, todayLines, + isRestDay, weekAheadInfo, type CachedSchedule, type WeekAheadInfo, @@ -20,12 +21,12 @@ import { } from '../../features/calendar.js'; import { weekdayShortLabel } from '../../features/schedule-render.js'; import type { View, AppContext } from '../view.js'; +import type { AmbientSpec } from '../ambient.js'; import { offlineNotice, passiveFooterHint } from '../chrome.js'; import { campusDateTime, campusIsoDate } from '@nbtca/nbtcal/timetable'; import { isoDayDifference, localDayDifference, parseLocalDate } from '../../core/calendar-day.js'; import { currentEvents } from '../../features/calendar-query.js'; -import { loadingLines } from '../../core/components/spinner.js'; -import { emptyStateLines } from '../../core/components/empty-state.js'; +import { emptyStateLines, loadingStateLines } from '../../core/components/empty-state.js'; import type { Calendar } from '@nbtca/nbtcal'; const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7] as const; @@ -237,7 +238,7 @@ export function renderHome(data: HomeData, now: Date, bodyRows = 100, cols = 80) if (shown === 0) shown = 1; lines.push(...rendered.slice(0, shown).flat(), ...moreLine(shown)); } else if (data.loading) { - lines.push(...loadingLines(trans.common.loading, cols)); + lines.push(...loadingStateLines(trans.common.loading, cols, bodyRows - lines.length)); } else if (data.eventsLoadFailed) { lines.push(...wrappedIndentedLines(trans.calendar.error, cols, type.hint)); } else { @@ -329,6 +330,12 @@ export const homeView = { } }, + ambient(): AmbientSpec { + return data.schedule && isRestDay(data.schedule) + ? { kind: 'sky', rest: true } + : { kind: 'sky' }; + }, + isBusy(): boolean { return data.loading === true; }, diff --git a/src/app/views/schedule-render.test.ts b/src/app/views/schedule-render.test.ts index 2d37849..b44936f 100644 --- a/src/app/views/schedule-render.test.ts +++ b/src/app/views/schedule-render.test.ts @@ -1183,3 +1183,23 @@ describe('renderSchedule — public mode', () => { } }); }); + +describe('termProgress', () => { + it('is the share of the term already gone, and undefined without a known end', async () => { + const { termProgress } = await import('./schedule-render.js'); + const window = { + status: 'inTerm', + academicYear: '2026-2027', + semester: '1', + weekOneMonday: '2026-09-07', + currentWeek: 5, + nextBreakStart: '2026-12-21', + } as unknown as Parameters[0]; + const term = termProgress(window); + expect(term?.progress).toBeGreaterThan(0.2); + expect(term?.progress).toBeLessThan(0.5); + expect(term?.weeks).toBe(15); + expect(termProgress(null)).toBeUndefined(); + expect(termProgress({ status: 'onBreak', breakTitle: 'Winter' })).toBeUndefined(); + }); +}); diff --git a/src/app/views/schedule-render.ts b/src/app/views/schedule-render.ts index 7ae2920..1c76aea 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -36,6 +36,7 @@ import { } from '../../core/text.js'; import { isoDayDifference } from '../../core/calendar-day.js'; import { loadingLines } from '../../core/components/spinner.js'; +import { loadingStateLines } from '../../core/components/empty-state.js'; import { offlineNotice } from '../chrome.js'; export type ScheduleMode = @@ -412,6 +413,14 @@ function renderHubBody( return withGrid.grid ? withGrid.lines : layoutHub(state, now, bodyRows, cols, shortcuts).lines; } +export function termProgress( + w: AcademicWindow | OnBreak | null | undefined, +): { progress: number; weeks: number } | undefined { + if (!w || !('currentWeek' in w) || !w.nextBreakStart) return undefined; + const weeks = Math.max(1, Math.round(isoDayDifference(w.weekOneMonday, w.nextBreakStart) / 7)); + return { progress: Math.min(1, Math.max(0, w.currentWeek / weeks)), weeks }; +} + const TERM_PROGRESS_WIDTH = 20; function renderTermProgressBar(w: AcademicWindow, cols: number): string[] | null { @@ -456,7 +465,7 @@ function renderPublicBody( const w = state.publicWindow; if (w === undefined) { - lines.push(...loadingLines(trans.common.loading, cols)); + lines.push(...loadingStateLines(trans.common.loading, cols, bodyRows - lines.length)); } else if (w === null) { lines.push(...hintLines(trans.timetable.publicUnavailable, cols)); } else if (w.status === 'onBreak') { @@ -512,7 +521,7 @@ export function renderSchedule( const trans = t(); switch (state.mode) { case 'loading': - return loadingLines(trans.common.loading, cols); + return loadingStateLines(trans.common.loading, cols, bodyRows); case 'public': return renderPublicBody(state, now, bodyRows, cols); case 'records': diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index a249878..829b52c 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -8,10 +8,16 @@ import { type Timetable, } from '@nbtca/nbtcal/timetable'; import type { AppContext, View } from '../view.js'; +import type { AmbientSpec } from '../ambient.js'; import { captureFooterHint, digitTabHint, fitFooterHint, passiveFooterHint } from '../chrome.js'; import { ListField, computeMaxVisible } from '../fields/list-field.js'; import { TextField } from '../fields/text-field.js'; -import { renderSchedule, hubShortcuts, type ScheduleViewState } from './schedule-render.js'; +import { + renderSchedule, + hubShortcuts, + termProgress, + type ScheduleViewState, +} from './schedule-render.js'; import { handleGridKey, todayGridCursor } from './schedule-grid-cursor.js'; import { setVimKeysActive } from '../../core/vim-keys.js'; import { t } from '../../i18n/index.js'; @@ -470,6 +476,12 @@ export const scheduleView = { return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols); }, + ambient(): AmbientSpec | undefined { + if (state.mode !== 'public' && state.mode !== 'hub') return undefined; + const term = termProgress(state.publicWindow); + return term === undefined ? undefined : { kind: 'mountain', ...term }; + }, + isBusy(): boolean { if (state.mode === 'records') return recordsPanel?.isBusy() ?? false; return state.mode === 'loading'; diff --git a/src/app/views/settings-render.ts b/src/app/views/settings-render.ts index 1527c18..015fa81 100644 --- a/src/app/views/settings-render.ts +++ b/src/app/views/settings-render.ts @@ -1,8 +1,12 @@ import { space, type } from '../../core/theme.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; +import { pictureBeside } from '../../core/components/picture.js'; +import { gasGiantLines } from '../../art/planet.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; -export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'reset' | 'about'; +const ABOUT_PLANET_COLS = 28; + +export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'picture' | 'reset' | 'about'; export interface SettingsViewState { mode: SettingsMode; @@ -40,15 +44,25 @@ export function renderSettings( case 'language': case 'icon': case 'color': + case 'picture': case 'reset': return state.subField?.render(bodyRows, cols) ?? []; case 'about': { - const context = [ - ...(state.aboutLines ?? []).flatMap((line) => + const aboutLines = state.aboutLines ?? []; + const plain = [ + ...aboutLines.flatMap((line) => line ? wrappedIndentedLines(line, cols, (value) => value) : [''], ), '', ]; + const picture = gasGiantLines(ABOUT_PLANET_COLS) ?? []; + const fieldRows = state.backField?.render(Number.POSITIVE_INFINITY, cols).length ?? 0; + const fits = (lines: readonly string[]) => + !Number.isFinite(bodyRows) || lines.length + fieldRows <= bodyRows; + const beside = pictureBeside(picture, aboutLines, cols, 52); + const stacked = [...picture.map((line) => space.indent + line), '', ...plain]; + const context = + beside && fits([...beside, '']) ? [...beside, ''] : fits(stacked) ? stacked : plain; if (!state.backField) return context; const fieldLines = state.backField.render(Number.POSITIVE_INFINITY, cols); if (!Number.isFinite(bodyRows) || context.length + fieldLines.length <= bodyRows) { diff --git a/src/app/views/settings.test.ts b/src/app/views/settings.test.ts index 6c5b02b..f1bfafd 100644 --- a/src/app/views/settings.test.ts +++ b/src/app/views/settings.test.ts @@ -119,10 +119,10 @@ describe('settingsView', () => { it('asks before resetting and stays put when cancelled', async () => { const ctx = fakeCtx(); await settingsView.load(); - for (let step = 0; step < 3; step += 1) settingsView.handleKey('\x1b[B'); + for (let step = 0; step < 4; step += 1) settingsView.handleKey('\x1b[B'); settingsView.handleKey('\r'); const confirm = stripAnsi(settingsView.render(ctx).join('\n')); - expect(confirm).toContain('Reset icon mode and color mode to their defaults?'); + expect(confirm).toContain('Reset icon mode, color mode, and picture detail to their defaults?'); expect(selectedLabel(ctx)).toBe('Cancel'); settingsView.handleKey('\r'); @@ -139,4 +139,45 @@ describe('settingsView', () => { expect(stripAnsi(settingsView.render(ctx).join('\n'))).toMatch(/Icon mode (updated|changed)/u); expect(selectedLabel(ctx)).toBe('Icon Mode'); }); + + it('offers fine pictures and names the setting that changed', async () => { + const ctx = fakeCtx(); + await settingsView.load(); + for (let step = 0; step < 3; step += 1) settingsView.handleKey('\x1b[B'); + settingsView.handleKey('\r'); + expect(stripAnsi(settingsView.render(ctx).join('\n'))).toContain('Fine (Unicode blocks)'); + settingsView.handleKey('\r'); + expect(stripAnsi(settingsView.render(ctx).join('\n'))).toMatch( + /Picture detail (updated|changed)/u, + ); + expect(selectedLabel(ctx)).toBe('Picture Detail'); + }); + + it('draws the scene beside the about text on a wide terminal and omits it on a short one', async () => { + await settingsView.load(); + settingsView.handleKey('\x1b[F'); + settingsView.handleKey('\r'); + + const wide = { ...fakeCtx(), size: { rows: 30, cols: 120 }, bodyRows: 25 }; + const lines = settingsView.render(wide).map(stripAnsi); + const projectLine = lines.find((line) => line.includes('Project')) ?? ''; + expect(projectLine.slice(0, projectLine.indexOf('Project')).trim()).not.toBe(''); + expect(lines.length).toBeLessThanOrEqual(wide.bodyRows); + + const short = settingsView.render(fakeCtx()).map(stripAnsi); + expect( + short + .find((line) => line.includes('Project')) + ?.trim() + .startsWith('Project'), + ).toBe(true); + }); + + it('offers the emblem on the menu and nothing inside a sub-page', async () => { + await settingsView.load(); + expect(settingsView.ambient()).toEqual({ kind: 'emblem' }); + settingsView.handleKey('\r'); + expect(settingsView.ambient()).toBeUndefined(); + await settingsView.load(); + }); }); diff --git a/src/app/views/settings.ts b/src/app/views/settings.ts index b213a2b..6e6936e 100644 --- a/src/app/views/settings.ts +++ b/src/app/views/settings.ts @@ -1,4 +1,5 @@ import type { AppContext, View } from '../view.js'; +import type { AmbientSpec } from '../ambient.js'; import { ListField } from '../fields/list-field.js'; import { renderSettings, type SettingsViewState } from './settings-render.js'; import { @@ -6,8 +7,10 @@ import { loadPreferences, resetPreferences, setColorMode, + setPictureDetail, setIconMode, type ColorMode, + type PictureDetail, type IconMode, } from '../../config/preferences.js'; import { resetIconCache } from '../../core/icons.js'; @@ -29,7 +32,7 @@ function currentHint(current: boolean, hint: string): { hint?: string } { return current ? { hint } : {}; } -function modeLabel(mode: IconMode | ColorMode): string { +function modeLabel(mode: IconMode | ColorMode | PictureDetail): string { const theme = t().theme; return { auto: theme.modeAuto, @@ -37,10 +40,14 @@ function modeLabel(mode: IconMode | ColorMode): string { unicode: theme.modeUnicode, on: theme.modeOn, off: theme.modeOff, + fine: theme.modeFine, }[mode]; } -function modeOptions(modes: readonly T[], current: T) { +function modeOptions( + modes: readonly T[], + current: T, +) { return modes.map((mode) => ({ value: mode, label: modeLabel(mode), @@ -60,6 +67,7 @@ function buildMenuField(statusMessage?: string, focus?: string): SettingsViewSta }, { value: 'icon', label: trans.theme.iconMode, hint: modeLabel(prefs.iconMode) }, { value: 'color', label: trans.theme.colorMode, hint: modeLabel(prefs.colorMode) }, + { value: 'picture', label: trans.theme.pictureDetail, hint: modeLabel(prefs.pictureDetail) }, { value: 'reset', label: trans.theme.resetLabel }, { value: 'about', label: trans.about.title }, ]; @@ -93,6 +101,10 @@ export const settingsView = { return renderSettings(state, ctx.bodyRows, ctx.size.cols); }, + ambient(): AmbientSpec | undefined { + return state.mode === 'menu' ? { kind: 'emblem' } : undefined; + }, + capturesInput(): boolean { return false; }, @@ -173,6 +185,22 @@ export const settingsView = { }; return; } + if (result.selected === 'picture') { + const prefs = loadPreferences(); + const options = modeOptions(['ascii', 'fine'] as const, prefs.pictureDetail); + state = { + mode: 'picture', + subField: new ListField({ + title: trans.theme.pictureDetail, + options, + initialIndex: Math.max( + 0, + options.findIndex((o) => o.value === prefs.pictureDetail), + ), + }), + }; + return; + } if (result.selected === 'reset') { state = { mode: 'reset', @@ -245,6 +273,13 @@ export const settingsView = { goToMenu(saved ? trans.theme.colorUpdated : trans.theme.colorUpdatedSessionOnly); return; } + case 'picture': { + const result = state.subField?.handleKey(key); + if (!result?.selected) return; + const saved = setPictureDetail(result.selected as PictureDetail); + goToMenu(saved ? trans.theme.pictureUpdated : trans.theme.pictureUpdatedSessionOnly); + return; + } case 'reset': { const result = state.subField?.handleKey(key); if (!result?.selected) return; diff --git a/src/art/bitmaps.ts b/src/art/bitmaps.ts new file mode 100644 index 0000000..0dfd893 --- /dev/null +++ b/src/art/bitmaps.ts @@ -0,0 +1,82 @@ +import type { Bitmap } from '../core/pixel-art.js'; + +// Generated by scripts/generate-art.mjs; edit the script, not this file. +export const eventIconDefault: Bitmap = { + palette: { '0': '#94a3b8', '1': '#0ea5e9', '2': '#f1f5f9', '3': '#bae6fd', '4': '#f59e0b' }, + rows: [ + ' 0 0 ', + ' 1111111111 ', + ' 1111111111 ', + ' 2222222222 ', + ' 2232323232 ', + ' 2222222222 ', + ' 2232443232 ', + ' 2222442222 ', + ' 2232323232 ', + ' 2222222222 ', + ], +}; + +export const eventIconMeeting: Bitmap = { + palette: { '0': '#06b6d4', '1': '#102a52', '2': '#bae6fd', '3': '#0ea5e9' }, + rows: [ + '00000000 ', + '00000000 ', + '00101010 ', + '00000000 ', + '00000000 ', + ' 00 22222222', + ' 0 22222222', + ' 22323232', + ' 22222222', + ' 22222222', + ], +}; + +export const eventIconWorkshop: Bitmap = { + palette: { '0': '#102a52', '1': '#bae6fd', '2': '#06b6d4' }, + rows: [ + '000000000000', + '000000000000', + '000100021000', + '001000020100', + '010000200010', + '010000200010', + '001002000100', + '000102001000', + '000000000000', + '000000000000', + ], +}; + +export const eventIconRepair: Bitmap = { + palette: { '0': '#94a3b8', '1': '#f1f5f9', '2': '#f59e0b' }, + rows: [ + ' 00 ', + ' 0 11 0 ', + ' 111111 ', + ' 100001 ', + '0 1102 011 0', + '0 110 2011 0', + ' 100001 ', + ' 111111 ', + ' 0 11 0 ', + ' 00 ', + ], +}; + +export const eventIconConference: Bitmap = { + palette: { '0': '#94a3b8', '1': '#102a52', '2': '#bae6fd', '3': '#f59e0b', '4': '#06b6d4' }, + rows: [ + '000000000000', + '011111111110', + '011112211110', + '011112213310', + '014412213310', + '014412213310', + '000000000000', + ' 00 ', + ' 00 ', + ' 000000 ', + ], +}; diff --git a/src/art/glyphs.ts b/src/art/glyphs.ts new file mode 100644 index 0000000..26a7f16 --- /dev/null +++ b/src/art/glyphs.ts @@ -0,0 +1,101 @@ +// Generated by scripts/generate-glyphs.mjs from JetBrains Mono; edit the script, not this file. +// One entry per printable ASCII character from space to ~: ink coverage 00 to ff for a 4 x 8 grid, row by row. +export const GLYPH_COLS = 4; +export const GLYPH_ROWS = 8; +export const GLYPHS: readonly string[] = [ + '0000000000000000000000000000000000000000000000000000000000000000', + '0000000000181800005858000051510000424200003d3d000014140000000000', + '00000000032a2a03099f9e090259580200000000000000000000000000000000', + '000000000020041d007d2f4e39b2a85b5fa4b239522b7d001402170000000000', + '00050700005768004b9b964e32be63000037a5605d959f60004b5a0000010200', + '00000000262900239293325473867700007c8c7c5e28989518001b1800000000', + '00000000003e2a0038828d0c29c30b07a22ba08a9f549f800b34031e00000000', + '000000000017170000545400002d2d0000000000000000000000000000000000', + '000000000011912200911f00009e0000009e0000008f210000108f2200000000', + '0000000022911100001f910000009e0000009e0000218f00228f100000000000', + '0000000000000000002c2c006294946202a0a002143737140000000000000000', + '0000000000000000001414000b5b5b0b3e99993e002f2f000000000000000000', + '0000000000000000000000000000000000000000003a4b00007b310000170200', + '00000000000000000000000001141401086c6c08000000000000000000000000', + '0000000000000000000000000000000000000000005c5c000016160000000000', + '000000000000474a0000a107002286000088210008a000004a45000000000000', + '00000000003b3b00596a6a5979434379793838794f7b7b500028280000000000', + '0000000000212300418f870004178700001787002364af34132e2e1c00000000', + '00000000003c3b005c6a735700005f4f004782003dc1552c142e2e1800000000', + '000000001b4242131a46bb1e00739c0a0701476062728a41002d240000000000', + '0000000000002f00004a6b0012a2231b806f8a461d367b460000100d00000000', + '0000000017424212576e41114f9c7f0d060141685a788449002a260000000000', + '0000000000062800007737001fc97510852525855e77775d0028280000000000', + '000000001f42422b735d638700008d1f000f9d0000733a00001f000000000000', + '00000000003b3b0055737355309392306e4f4f6e637575630029290000000000', + '00000000003b3c0068646469803030800a6ad018004a6d00001f030000000000', + '0000000000000000001616000056560000000000005454000018180000000000', + '0000000000000000001717000059590000000000004045000084290000180100', + '0000000000000000000011271b7e7a174292330000065d400000000000000000', + '0000000000000000000000003980803927595927112727110000000000000000', + '000000000000000027110000197f821b002e8e423e5d06000000000000000000', + '0000000009421b000a47a51e001c9a23008c2900006911000025030000000000', + '00000000003a45046f4d38838d2e7aa08d68258d8e2e826363642e0000293f00', + '00000000001e1e00008f91000196970233b8b8347c25237c1d00001d00000000', + '000000001a422e0063697e5163828c31634e52666378805e122e1f0000000000', + '0000000000373e00487d685b65390000653902063f8b7a5600242a0000000000', + '000000001a422600656988426539326c6539336b6578943a122e160000000000', + '000000001742421f5774411f578e641857591c0757835528102e2e1600000000', + '0000000019424222606d412160754c1960683713603e0000110b000000000000', + '0000000000393c004e776d546c3827126c3f767646867e4f0026290000000000', + '000000001a0f0f1a633b3b636383836363575763633b3b63120b0b1200000000', + '0000000014424214147d7d1400505000005050001a8b8b1a0e2e2e0e00000000', + '000000000000141400004f4f00004f4f11004f4f855a9a2902331c0000000000', + '000000001a0f052865396d4165819f006556ad026539575f120a012000000000', + '00000000091f000024790000247900002479000024a65539072e2e1f00000000', + '00000000231c1c2387827d8787847e8787232187871212871903031900000000', + '000000001b1e0c1b68a8306868a04d686847a7686830a4681309151300000000', + '00000000003a3a004d76764d6c32326c6c32326c458585450027270000000000', + '000000001a423702656a5c826555428e6581510765390000120a000000000000', + '00000000003b3b00596f6f5979242479792424794f8080500028a7170000141e', + '000000001a423300636a6b6c636162716375ba026339614d120a041c00000000', + '00000000003b38004f73723d33a14a01060d64685d78755d00292a0000000000', + '000000002a42422a2a7b7b29004f4f00004f4f00004f4f00000e0e0000000000', + '000000001b0e0e1b683535686835356868353568448686450028280000000000', + '000000002a00002a772a2a762e716e2e019d960100918d000015140000000000', + '000000002616182490686a898c6e6d858275727e6288875f0e17170e00000000', + '00000000280806283979723800908a0000989f0054595e552001012000000000', + '000000002c00002b6247426203a29d0300626200004f4f00000e0e0000000000', + '000000001b4242191a419f39001e8c00009b0f00519b5526142e2e1400000000', + '00000000008b8001009e0000009e0000009e0000009e00000089800100000000', + '000000004a47000008a1000000862200002188000000a0080000454a00000000', + '0000000001808b0000009e0000009e0000009e0000009e000180890000000000', + '0000000000171800008684002d615f2e0e03030e000000000000000000000000', + '0000000000000000000000000000000000000000000000004978784900000000', + '00000000005e0b0000141b000000000000000000000000000000000000000000', + '0000000000000000002627002c6d8249428490657761806502351d1200000000', + '000000001a0f0000654b2b006582814c6539306e65807f4e121f2e0000000000', + '000000000000000000222800438977516c3200004587745200252b0000000000', + '0000000000000f1a002b4b654c8181656e3039654e7f8065002e1f1200000000', + '000000000000000000242400477a7a4a7287794b487e6a300026290000000000', + '00000000000a40240088502354ca884700930b0000930b00001b020000000000', + '0000000000000000002b1d114b8483636b333a632e9c9363003a7e4c00413400', + '000000001a0f0000654c2b006580844c6539356865393568120a0a1300000000', + '00000000002c48000d3b31001b70790000247900266da63c142e2e2100000000', + '0000000000007301132b53002758b70000009e0000009e001945a3001c420c00', + '00000000181100005d41041b5d4183355d9fa7005d417744110c022000000000', + '000000003642050034a81300008a1300008a130000746c3900022a1f00000000', + '0000000000000000172a200d9577718d94414194944141941b0c0c1b00000000', + '0000000000000000111d2b006580844c6539356865393568120a0a1300000000', + '00000000000000000022220047858547722b2b72488382480024240000000000', + '0000000000000000111c2b006582814d6539306e657f7f4e654c2e001c0f0000', + '0000000000000000002b1c114d8082656e3039654e7e7f65002e4d6500000f1c', + '00000000000000000b1b320243996a6e435b0526435b00000c10000000000000', + '0000000000000000002a2900507b692d1e8d942c4168735c002e2e0000000000', + '00000000001100001eae2b143dbf582a009e0000008f5b2600062d1400000000', + '0000000000000000120909126835356868353568438687420026260000000000', + '00000000000000001b02011b67413e6710949010009793000014140000000000', + '0000000000000000170e0e168768687f7c747576538d914e0a17180900000000', + '000000000000000017080818278d8c29008f9000338383331d05051d00000000', + '00000000000000001b02021b5d4c4461069e9609007b860000802500002d0000', + '00000000000000000f2b2b0e1f58c0320066550040b25523132e2e1300000000', + '00000000000f993000247a00225967001f576d0000247a00000e973000000000', + '0000000000444400004f4f00004f4f00004f4f00004f4f000043430000000000', + '00000000309a110000811e00007e4e2200864a1f00801f003098100000000000', + '000000000000000000000000327e154e581c863b000000000000000000000000', +]; diff --git a/src/art/motifs.test.ts b/src/art/motifs.test.ts new file mode 100644 index 0000000..a381457 --- /dev/null +++ b/src/art/motifs.test.ts @@ -0,0 +1,43 @@ +import chalk from 'chalk'; +import { describe, expect, it } from 'vitest'; +import { magnifier, moonSky, orbit, signalLost, teaCup } from './motifs.js'; +import { stripAnsi, visualWidth } from '../core/text.js'; + +describe('motifs', () => { + const level = chalk.level; + + it.each([ + ['moon and stars', moonSky], + ['magnifier', magnifier], + ] as const)('draws the %s in six rows of ASCII', (_name, draw) => { + const lines = draw('tone'); + expect(lines).toHaveLength(6); + expect(lines.join('')).toMatch(/^[\x20-\x7e]*$/); + expect(Math.max(...lines.map(visualWidth))).toBeLessThanOrEqual(22); + expect(lines.join('').replace(/\s/g, '').length).toBeGreaterThan(20); + }); + + it('adds colour on top of the ASCII when the terminal can', () => { + chalk.level = 3; + for (const draw of [moonSky, magnifier]) { + expect(draw('color').join('')).toContain('\x1b[38;2;'); + expect(stripAnsi(draw('color').join(''))).toMatch(/^[\x20-\x7e]*$/); + } + chalk.level = level; + }); + + it('keeps the ASCII picture in the fine tier', () => { + expect(stripAnsi(magnifier('fine').join(''))).toMatch(/^[\x20-\x7e]*$/); + }); + + it('draws the tea cup, the orbit, and the dropped signal in plain ASCII too', () => { + for (const lines of [teaCup(0, 'tone'), orbit(0, 'tone'), signalLost('tone')]) { + expect(lines).toHaveLength(6); + expect(lines.join('')).toMatch(/^[\x20-\x7e]*$/); + } + }); + + it('moves the moon along its orbit from frame to frame', () => { + expect(orbit(0, 'tone')).not.toEqual(orbit(800, 'tone')); + }); +}); diff --git a/src/art/motifs.ts b/src/art/motifs.ts new file mode 100644 index 0000000..85e7394 --- /dev/null +++ b/src/art/motifs.ts @@ -0,0 +1,226 @@ +import chalk from 'chalk'; +import { gasGiantLines, moonLines, tint } from './planet.js'; +import { getCapabilities } from '../core/capabilities.js'; +import { pictureMode, type PictureMode } from '../core/pixel-art.js'; + +type Vec = readonly [number, number, number]; + +const RAMP = '.-:=+*xo#%&@'; +const ROWS = 6; +const MOON_COLS = 12; +const MOON_AT = 7; +const WIDTH = 22; +const STAR_PALE: Vec = [200, 212, 255]; +const STAR_WARM: Vec = [255, 226, 150]; +const STARS: readonly (readonly [number, number, string, Vec])[] = [ + [1, 1, '*', STAR_WARM], + [4, 0, '.', STAR_PALE], + [3, 3, '+', STAR_PALE], + [0, 5, '.', STAR_PALE], + [6, 4, '.', STAR_WARM], + [MOON_AT + MOON_COLS + 1, 1, '.', STAR_PALE], + [MOON_AT + MOON_COLS + 2, 3, '*', STAR_WARM], + [MOON_AT + MOON_COLS, 5, '.', STAR_PALE], +]; + +const plain = (mode: PictureMode): boolean => mode === 'tone'; + +/** A crescent moon among a few stars: nothing is happening tonight. */ +export function moonSky(mode: PictureMode = pictureMode()): string[] { + const waning = new Date(Date.UTC(2000, 0, 6, 18, 14) + 21.5 * 86_400_000); + const moon = moonLines(MOON_COLS, waning, mode === 'fine' ? 'color' : mode) ?? []; + return Array.from({ length: ROWS }, (_, row) => { + const cells = new Map( + STARS.filter(([, starRow]) => starRow === row).map(([col, , char, color]) => [ + col, + plain(mode) ? char : chalk.rgb(...color)(char), + ]), + ); + const moonLine = moon[row] ?? ''; + const moonWidth = Math.max( + 0, + ...moon.map((line) => line.replace(/\x1b\[[0-9;]*m/g, '').length), + ); + let line = ''; + for (let col = 0; col < MOON_AT; col++) line += cells.get(col) ?? ' '; + line += moonLine; + const visible = moonLine.replace(/\x1b\[[0-9;]*m/g, '').length; + line += ' '.repeat(Math.max(0, moonWidth - visible)); + for (let col = MOON_AT + moonWidth; col < WIDTH; col++) line += cells.get(col) ?? ' '; + return line.replace(/\s+$/, ''); + }); +} + +const GLASS: Vec = [125, 211, 252]; +const HANDLE: Vec = [245, 158, 11]; +const LENS = { x: 5.6, y: 5.4, radius: 5.1, rim: 1.25 }; + +/** A magnifying glass with a rim lit from the upper left and a glint on the glass. */ +export function magnifier(mode: PictureMode = pictureMode()): string[] { + const handleFrom = { + x: LENS.x + LENS.radius * 0.72, + y: LENS.y + LENS.radius * 0.72, + }; + const handleTo = { x: handleFrom.x + 4.2, y: handleFrom.y + 3.2 }; + const segment = { x: handleTo.x - handleFrom.x, y: handleTo.y - handleFrom.y }; + const length = segment.x ** 2 + segment.y ** 2; + return Array.from({ length: ROWS }, (_, row) => { + let line = ''; + for (let col = 0; col < 14; col++) { + const x = col + 0.5; + const y = (row + 0.5) * 2; + const dx = x - LENS.x; + const dy = y - LENS.y; + const dist = Math.hypot(dx, dy); + const along = Math.max( + 0, + Math.min(1, ((x - handleFrom.x) * segment.x + (y - handleFrom.y) * segment.y) / length), + ); + const handleDist = Math.hypot( + x - (handleFrom.x + along * segment.x), + y - (handleFrom.y + along * segment.y), + ); + let char = ' '; + let color = GLASS; + let level = 0; + if (Math.abs(dist - LENS.radius) < LENS.rim) { + const facing = (-dx - dy) / (dist * Math.SQRT2 || 1); + level = 0.62 + 0.38 * facing; + char = RAMP[Math.min(RAMP.length - 1, Math.max(6, Math.floor(level * RAMP.length)))] ?? '+'; + } else if (handleDist < 1.2 && dist > LENS.radius) { + char = '\\'; + color = HANDLE; + level = 0.55; + } else if (dist < LENS.radius) { + const glint = Math.max(0, 1 - Math.hypot(x - (LENS.x - 1.9), y - (LENS.y - 1.9)) / 2.2); + if (glint > 0.2) { + level = glint; + char = RAMP[Math.min(5, Math.floor(glint * 6))] ?? '.'; + color = [226, 242, 255]; + } + } + line += char === ' ' || plain(mode) ? char : tint(color, level, char); + } + return line.replace(/\s+$/, ''); + }); +} + +const ANSI = /(?:\x1b\[[0-9;]*m)*[^\x1b](?:\x1b\[[0-9;]*m)*/gu; +const ORBIT = { + cols: 22, + centre: { x: 11, y: 3 }, + radiusX: 10, + radiusY: 2.3, + period: 3200, + spin: 6400, +}; +const MOON_ON_ORBIT: Vec = [226, 232, 240]; +const ORBIT_TRAIL: Vec = [71, 85, 105]; + +/** A small planet with a moon circling it; the moon moves one step per frame. */ +export function orbit(at: number, mode: PictureMode = pictureMode()): string[] { + const grid = Array.from({ length: ROWS }, () => Array.from({ length: ORBIT.cols }, () => ' ')); + const place = (x: number, y: number, cell: string) => { + const row = grid[y]; + if (row && x >= 0 && x < ORBIT.cols) row[x] = cell; + }; + const dot = (angle: number, cell: string) => { + place( + Math.round(ORBIT.centre.x + ORBIT.radiusX * Math.cos(angle)), + Math.round(ORBIT.centre.y - 0.5 + ORBIT.radiusY * Math.sin(angle)), + cell, + ); + }; + const trail = plain(mode) ? '.' : chalk.rgb(...ORBIT_TRAIL)('.'); + for (let step = 0; step < 24; step++) dot((step / 24) * 2 * Math.PI, trail); + + const turn = (at / ORBIT.spin) * 2 * Math.PI; + const planet = gasGiantLines(8, mode === 'fine' ? 'color' : mode, turn) ?? []; + planet.forEach((line, index) => { + (line.match(ANSI) ?? []).forEach((cell, col) => { + if (cell.replace(/\x1b\[[0-9;]*m/g, '') !== ' ') place(7 + col, 1 + index, cell); + }); + }); + + const angle = ((at % ORBIT.period) / ORBIT.period) * 2 * Math.PI; + const x = Math.round(ORBIT.centre.x + ORBIT.radiusX * Math.cos(angle)); + const y = Math.round(ORBIT.centre.y - 0.5 + ORBIT.radiusY * Math.sin(angle)); + const behind = Math.sin(angle) < 0 && grid[y]?.[x] !== ' ' && x > 6 && x < 15 && y > 0 && y < 5; + if (!behind) place(x, y, plain(mode) ? 'o' : chalk.rgb(...MOON_ON_ORBIT)('o')); + return grid.map((row) => row.join('').replace(/\s+$/, '')); +} + +const STEEL: Vec = [148, 163, 184]; +const WAVE: Vec = [71, 85, 105]; +const LOST: Vec = [248, 113, 113]; +const TOWER: readonly (readonly [string, Vec | undefined])[][] = [ + [[' x', LOST]], + [ + [' . ( ', WAVE], + ['|', STEEL], + [' ) .', WAVE], + ], + [ + [' . ( ( ', WAVE], + ['/|\\', STEEL], + [' ) ) .', WAVE], + ], + [ + [' ', undefined], + ['/|\\', STEEL], + ], + [ + [' ', undefined], + ['/ | \\', STEEL], + ], + [ + [' ', undefined], + ['/__|__\\', STEEL], + ], +]; + +/** A transmitter whose signal has dropped: dim waves and a red cross above the mast. */ +export function signalLost(mode: PictureMode = pictureMode()): string[] { + return TOWER.map((parts) => + parts + .map(([text, color]) => (plain(mode) || !color ? text : chalk.rgb(...color)(text))) + .join('') + .replace(/\s+$/, ''), + ); +} + +const PORCELAIN: Vec = [226, 232, 240]; +const TEA: Vec = [217, 119, 6]; +const STEAM: Vec = [148, 163, 184]; +const STEAM_FRAMES: readonly (readonly [string, string])[] = [ + [' ) ( ) ', ' ( ) ( '], + [' ( ) ( ', ' ) ( ) '], + [' ( ) ( ', ' ) ( ) '], + [' ) ( ) ', ' ( ) ( '], +]; +const STEAM_MS = 500; +const CUP: readonly (readonly [string, Vec])[][] = [ + [[' ,---------.', PORCELAIN]], + [ + [' |', PORCELAIN], + ['~~~~~~~~~', TEA], + ['|', PORCELAIN], + ['-.', PORCELAIN], + ], + [[' | | |', PORCELAIN]], + [[" \\_______/-'", PORCELAIN]], +]; + +/** A cup of tea with drifting steam: a day without classes. */ +export function teaCup(at: number, mode: PictureMode = pictureMode()): string[] { + const frame = getCapabilities().reducedMotion + ? 0 + : Math.floor(at / STEAM_MS) % STEAM_FRAMES.length; + const steam = (STEAM_FRAMES[frame] ?? STEAM_FRAMES[0] ?? ['', '']).map((line) => + plain(mode) ? line : chalk.rgb(...STEAM)(line), + ); + const cup = CUP.map((parts) => + parts.map(([text, color]) => (plain(mode) ? text : chalk.rgb(...color)(text))).join(''), + ); + return [...steam, ...cup].map((line) => line.replace(/\s+$/, '')); +} diff --git a/src/art/noise.test.ts b/src/art/noise.test.ts new file mode 100644 index 0000000..9e066dd --- /dev/null +++ b/src/art/noise.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { fbm3, noise3, worley3 } from './noise.js'; + +const points = Array.from( + { length: 200 }, + (_, index) => [index * 0.37, index * 0.11 - 5, index * 0.53] as const, +); + +describe('noise', () => { + it('stays within 0..1 and repeats for the same input', () => { + for (const point of points) { + for (const value of [noise3(point), fbm3(point)]) { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + } + expect(noise3(point, 4)).toBe(noise3(point, 4)); + } + }); + + it('changes smoothly between nearby points', () => { + for (const [x, y, z] of points) { + expect(Math.abs(noise3([x, y, z]) - noise3([x + 0.01, y, z]))).toBeLessThan(0.05); + } + }); + + it('measures Worley distance to a feature point within one cell of any location', () => { + for (const point of points) { + const distance = worley3(point, 2); + expect(distance).toBeGreaterThanOrEqual(0); + expect(distance).toBeLessThan(Math.sqrt(3)); + } + }); +}); diff --git a/src/art/noise.ts b/src/art/noise.ts new file mode 100644 index 0000000..9eb6246 --- /dev/null +++ b/src/art/noise.ts @@ -0,0 +1,53 @@ +type Vec = readonly [number, number, number]; + +function hash(x: number, y: number, z: number, seed: number): number { + let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 1274126177) ^ seed; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} + +const smooth = (t: number): number => t * t * (3 - 2 * t); +const mix = (a: number, b: number, t: number): number => a + (b - a) * t; + +/** Smooth value noise in 0..1. */ +export function noise3([x, y, z]: Vec, seed = 0): number { + const [ix, iy, iz] = [Math.floor(x), Math.floor(y), Math.floor(z)]; + const [fx, fy, fz] = [smooth(x - ix), smooth(y - iy), smooth(z - iz)]; + const at = (dx: number, dy: number, dz: number) => hash(ix + dx, iy + dy, iz + dz, seed); + return mix( + mix(mix(at(0, 0, 0), at(1, 0, 0), fx), mix(at(0, 1, 0), at(1, 1, 0), fx), fy), + mix(mix(at(0, 0, 1), at(1, 0, 1), fx), mix(at(0, 1, 1), at(1, 1, 1), fx), fy), + fz, + ); +} + +/** Fractal Brownian motion: octaves of value noise, in 0..1. */ +export function fbm3(p: Vec, octaves = 3, seed = 0): number { + let sum = 0; + let amplitude = 0.5; + let scale = 1; + for (let octave = 0; octave < octaves; octave++) { + sum += amplitude * noise3([p[0] * scale, p[1] * scale, p[2] * scale], seed + octave * 17); + amplitude /= 2; + scale *= 2; + } + return sum / (1 - 0.5 ** octaves); +} + +/** Worley noise: distance to the nearest of one random feature point per unit cell, which makes crater bowls. */ +export function worley3([x, y, z]: Vec, seed = 0): number { + const [ix, iy, iz] = [Math.floor(x), Math.floor(y), Math.floor(z)]; + let nearest = Number.POSITIVE_INFINITY; + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dz = -1; dz <= 1; dz++) { + const [cx, cy, cz] = [ix + dx, iy + dy, iz + dz]; + const px = cx + hash(cx, cy, cz, seed + 1); + const py = cy + hash(cx, cy, cz, seed + 2); + const pz = cz + hash(cx, cy, cz, seed + 3); + nearest = Math.min(nearest, (px - x) ** 2 + (py - y) ** 2 + (pz - z) ** 2); + } + } + } + return Math.sqrt(nearest); +} diff --git a/src/art/planet.test.ts b/src/art/planet.test.ts new file mode 100644 index 0000000..a86d332 --- /dev/null +++ b/src/art/planet.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; +import { gasGiantLines, moonLines, planetSize } from './planet.js'; +import { stripAnsi } from '../core/text.js'; + +const fullMoon = new Date(Date.UTC(2000, 0, 21, 4, 40)); +const newMoon = new Date(Date.UTC(2000, 0, 6, 18, 14)); + +describe('planets', () => { + const level = chalk.level; + const lit = (lines: string[] | undefined) => + (lines ?? []).map((line) => line.replace(/[ .]/g, '').length); + + it('draws a round body with a 2:1 cell aspect and nothing but ASCII', () => { + chalk.level = 0; + const lines = gasGiantLines(30, 'tone') ?? []; + expect(lines).toHaveLength(15); + expect(lines.join('')).toMatch(/^[\x20-\x7e]*$/); + expect(Math.max(...lines.map((line) => line.length))).toBeLessThanOrEqual(30); + chalk.level = level; + }); + + it('is brightest where the light falls', () => { + chalk.level = 0; + const lines = gasGiantLines(40, 'tone') ?? []; + const half = Math.floor(lines.length / 2); + const weight = (rows: string[]) => rows.join('').replace(/[ .:-]/g, '').length; + expect(weight(lines.slice(0, half))).toBeGreaterThan(weight(lines.slice(half)) * 0.9); + chalk.level = level; + }); + + it('follows the moon phase', () => { + chalk.level = 0; + expect(lit(moonLines(30, fullMoon, 'tone')).reduce((a, b) => a + b, 0)).toBeGreaterThan( + lit(moonLines(30, newMoon, 'tone')).reduce((a, b) => a + b, 0) * 3, + ); + chalk.level = level; + }); + + it('colours the characters when the terminal has colour', () => { + chalk.level = 3; + expect((moonLines(30, fullMoon, 'color') ?? []).join('')).toContain('\x1b[38;2;'); + expect(stripAnsi((gasGiantLines(30, 'color') ?? []).join(''))).toMatch(/[@#%&]/); + chalk.level = level; + }); + + it('draws the fine tier as smoothly shaded half blocks', () => { + chalk.level = 3; + const lines = moonLines(30, fullMoon, 'fine') ?? []; + expect(lines).toHaveLength(15); + expect(stripAnsi(lines.join(''))).toMatch(/^[\u2580\u2584 ]*$/u); + expect(lines.join('')).toContain('\x1b[48;2;'); + chalk.level = level; + }); + + it('turns the surface texture but not the light', () => { + chalk.level = 0; + const before = gasGiantLines(30, 'tone', 0) ?? []; + const after = gasGiantLines(30, 'tone', 1.4) ?? []; + expect(after).not.toEqual(before); + expect(after).toHaveLength(before.length); + chalk.level = level; + }); + + it('gives the moon relief that the light picks out', () => { + chalk.level = 0; + const lit = (turn: number) => (moonLines(30, fullMoon, 'tone', turn) ?? []).join('\n'); + expect(lit(0.3)).not.toEqual(lit(1.9)); + chalk.level = level; + }); + + it('declines when there is no room', () => { + expect(gasGiantLines(6)).toBeUndefined(); + expect(moonLines(6, fullMoon)).toBeUndefined(); + expect(planetSize(100, 5)).toBe(10); + }); +}); diff --git a/src/art/planet.ts b/src/art/planet.ts new file mode 100644 index 0000000..ff2b23a --- /dev/null +++ b/src/art/planet.ts @@ -0,0 +1,239 @@ +import chalk from 'chalk'; +import { fbm3, worley3 } from './noise.js'; +import { moonPhase } from './scene.js'; +import { pictureMode, type PictureMode } from '../core/pixel-art.js'; + +type Vec = readonly [number, number, number]; + +// Light to dark ink, as in classic tone-shaded ASCII spheres. +const RAMP = '.-:=+*xo#%&@'; +const MIN_COLS = 8; +const MAX_COLS = 44; + +interface Surface { + /** Brightness multiplier of the surface material. */ + albedo: number; + /** Colour of fully lit surface. */ + color: Vec; + /** Perturbed normal, when the surface has relief. */ + normal?: Vec; +} + +interface Sphere { + light: Vec; + /** The surface at a point of the unit sphere. */ + sample: (point: Vec) => Surface; +} + +interface Shade { + level: number; + color: Vec; +} + +const normalize = (v: Vec): Vec => { + const length = Math.hypot(...v); + return [v[0] / length, v[1] / length, v[2] / length]; +}; + +function shadeAt(sphere: Sphere, x: number, y: number): Shade | undefined { + const radius = x * x + y * y; + if (radius > 1) return undefined; + const z = Math.sqrt(1 - radius); + const surface = sphere.sample([x, y, z]); + const [nx, ny, nz] = surface.normal ?? [x, y, z]; + const lit = Math.max(0, nx * sphere.light[0] + ny * sphere.light[1] + nz * sphere.light[2]); + return { level: Math.min(1, lit * surface.albedo * (0.55 + 0.45 * z)), color: surface.color }; +} + +const NIGHT: Vec = [48, 54, 70]; + +export function tint(color: Vec, level: number, char: string): string { + const gain = 0.62 + 0.38 * level; + return chalk.rgb( + Math.round(color[0] * gain), + Math.round(color[1] * gain), + Math.round(color[2] * gain), + )(char); +} + +function drawSphere(cols: number, sphere: Sphere, mode: PictureMode): string[] { + if (mode === 'fine') return drawFineSphere(cols, sphere); + const rows = Math.round(cols / 2); + const colour = mode !== 'tone'; + return Array.from({ length: rows }, (_, row) => { + let line = ''; + for (let col = 0; col < cols; col++) { + const shade = shadeAt(sphere, ((col + 0.5) / cols) * 2 - 1, ((row + 0.5) / rows) * 2 - 1); + if (!shade) { + line += ' '; + continue; + } + if (shade.level < 0.02) { + line += colour ? chalk.rgb(...NIGHT)('.') : ' '; + continue; + } + const char = RAMP[Math.min(RAMP.length - 1, Math.floor(shade.level * RAMP.length))] ?? ' '; + line += colour ? tint(shade.color, shade.level, char) : char; + } + return line.replace(/\s+$/, ''); + }); +} + +const SUPERSAMPLE = 3; +const EDGE_COVERAGE = 0.4; + +/** One truecolor pixel of the sphere, antialiased at the rim; undefined outside it. */ +function fineColor(sphere: Sphere, cols: number, px: number, py: number): Vec | undefined { + const size = cols; + let covered = 0; + const sum: [number, number, number] = [0, 0, 0]; + for (let i = 0; i < SUPERSAMPLE; i++) { + for (let j = 0; j < SUPERSAMPLE; j++) { + const x = ((px + (i + 0.5) / SUPERSAMPLE) / size) * 2 - 1; + const y = ((py + (j + 0.5) / SUPERSAMPLE) / size) * 2 - 1; + const shade = shadeAt(sphere, x, y); + if (!shade) continue; + covered++; + const bright = Math.pow(shade.level, 0.75); + for (let channel = 0; channel < 3; channel++) + sum[channel] = + (sum[channel] ?? 0) + + (NIGHT[channel] ?? 0) * 0.6 * (1 - bright) + + (shade.color[channel] ?? 0) * bright; + } + } + if (covered < EDGE_COVERAGE * SUPERSAMPLE ** 2) return undefined; + return [sum[0] / covered, sum[1] / covered, sum[2] / covered]; +} + +/** Half blocks with a smoothly shaded colour on each half: twice the character resolution, no dithering. */ +function drawFineSphere(cols: number, sphere: Sphere): string[] { + const rows = Math.round(cols / 2); + return Array.from({ length: rows }, (_, row) => { + let line = ''; + for (let col = 0; col < cols; col++) { + const top = fineColor(sphere, cols, col, row * 2); + const bottom = fineColor(sphere, cols, col, row * 2 + 1); + const rgb = (v: Vec) => [Math.round(v[0]), Math.round(v[1]), Math.round(v[2])] as const; + if (top && bottom) line += chalk.rgb(...rgb(top)).bgRgb(...rgb(bottom))('▀'); + else if (top) line += chalk.rgb(...rgb(top))('▀'); + else if (bottom) line += chalk.rgb(...rgb(bottom))('▄'); + else line += ' '; + } + return line.replace(/\s+$/, ''); + }); +} + +const smoothstep = (low: number, high: number, value: number): number => { + const t = Math.min(1, Math.max(0, (value - low) / (high - low))); + return t * t * (3 - 2 * t); +}; + +/** The point as the texture sees it: turned about the vertical axis. */ +function turned([x, y, z]: Vec, angle: number): Vec { + const [cos, sin] = [Math.cos(angle), Math.sin(angle)]; + return [x * cos + z * sin, y, -x * sin + z * cos]; +} + +const EPSILON = 0.03; +const RELIEF = 0.9; + +/** + * Bumps a unit sphere's normal by the slope of a height field, as bump mapping does: the field's gradient, + * with its component along the normal removed, tilts the normal away from rising ground. + */ +function bumped(point: Vec, height: (p: Vec) => number): Vec { + const base = height(point); + const gradient: Vec = [ + (height([point[0] + EPSILON, point[1], point[2]]) - base) / EPSILON, + (height([point[0], point[1] + EPSILON, point[2]]) - base) / EPSILON, + (height([point[0], point[1], point[2] + EPSILON]) - base) / EPSILON, + ]; + const along = gradient[0] * point[0] + gradient[1] * point[1] + gradient[2] * point[2]; + return normalize([ + point[0] - RELIEF * (gradient[0] - along * point[0]), + point[1] - RELIEF * (gradient[1] - along * point[1]), + point[2] - RELIEF * (gradient[2] - along * point[2]), + ]); +} + +const CRATER = 0.34; + +/** Crater bowls with raised rims over rolling highlands. */ +function lunarHeight(p: Vec, turn: number): number { + const q = turned(p, turn); + const scaled: Vec = [q[0] * 1.7, q[1] * 1.7, q[2] * 1.7]; + const d = worley3(scaled, 7); + const bowl = -Math.pow(Math.max(0, 1 - d / CRATER), 0.7) * 0.38; + const rim = Math.exp(-(((d - CRATER) / 0.06) ** 2)) * 0.2; + return bowl + rim + fbm3([q[0] * 3, q[1] * 3, q[2] * 3], 3, 3) * 0.12; +} + +/** The moon at today's phase, lit from the side the real moon is lit from. */ +export function moonLines( + cols: number, + now: Date, + mode = pictureMode(), + turn = 0.9, +): string[] | undefined { + if (cols < MIN_COLS) return undefined; + const angle = 2 * Math.PI * moonPhase(now); + return drawSphere( + Math.min(cols, MAX_COLS), + { + light: normalize([Math.sin(angle), -0.12, -Math.cos(angle)]), + sample: (point) => { + const q = turned(point, turn); + const maria = smoothstep(0.42, 0.6, fbm3([q[0] * 1.3, q[1] * 1.3, q[2] * 1.3], 3, 11)); + return { + albedo: 1 - 0.38 * maria, + color: [236, 238, 246], + normal: bumped(point, (p) => lunarHeight(p, turn)), + }; + }, + }, + mode, + ); +} + +const STORM = { lon: 0.55, lat: 0.34, width: 0.5, height: 0.15 }; + +/** A banded gas giant lit from the upper left, with turbulent bands and a storm that turns with it. */ +export function gasGiantLines( + cols: number, + mode = pictureMode(), + turn = 0.9, +): string[] | undefined { + if (cols < MIN_COLS) return undefined; + return drawSphere( + Math.min(cols, MAX_COLS), + { + light: normalize([-0.6, -0.45, 0.66]), + sample: (point) => { + const q = turned(point, turn); + const turbulence = fbm3([q[0] * 2.4, q[1] * 9, q[2] * 2.4], 3, 5); + const band = 0.5 + 0.5 * Math.sin(q[1] * 11 + turbulence * 3.2); + const lon = Math.atan2(q[0], q[2]); + const spread = Math.cos(Math.asin(Math.max(-1, Math.min(1, q[1])))); + const dLon = ((lon - STORM.lon + Math.PI * 3) % (Math.PI * 2)) - Math.PI; + const storm = Math.exp( + -(((dLon * spread) / STORM.width) ** 2) - ((q[1] - STORM.lat) / STORM.height) ** 2, + ); + const base: Vec = [236 - 40 * band, 176 - 30 * band, 120 + 40 * band]; + const spot: Vec = [214, 92, 66]; + return { + albedo: 0.82 + 0.18 * band + 0.1 * storm, + color: [ + base[0] + (spot[0] - base[0]) * storm, + base[1] + (spot[1] - base[1]) * storm, + base[2] + (spot[2] - base[2]) * storm, + ], + }; + }, + }, + mode, + ); +} + +export const planetSize = (cols: number, rows: number): number => + Math.min(MAX_COLS, cols, rows * 2); diff --git a/src/art/scene.test.ts b/src/art/scene.test.ts new file mode 100644 index 0000000..c1075b0 --- /dev/null +++ b/src/art/scene.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { moonPhase, mountainScene, scaleBitmap, shelfScene, skyScene } from './scene.js'; +import { bitmapSize, type Bitmap } from '../core/pixel-art.js'; + +const at = (hour: number, day = '2026-10-28'): Date => + new Date(`${day}T${String(Math.floor(hour)).padStart(2, '0')}:${hour % 1 ? '30' : '00'}:00`); + +function colorsOf(bitmap: Bitmap): Set { + return new Set( + [...new Set(bitmap.rows.join(''))] + .filter((symbol) => symbol !== ' ') + .map((symbol) => bitmap.palette[symbol] ?? ''), + ); +} + +function expectWellFormed(bitmap: Bitmap, width: number, height: number): void { + expect(bitmap.rows).toHaveLength(height); + expect(bitmap.rows.every((row) => row.length === width)).toBe(true); + expect(bitmap.density).toEqual({ x: 2, y: 4 }); + for (const symbol of new Set(bitmap.rows.join('').replace(/ /g, ''))) { + expect(bitmap.palette[symbol]).toBeDefined(); + } +} + +describe('scenes', () => { + it.each([ + [60, 28], + [120, 52], + [260, 60], + ])('draw a well-formed sky at %i x %i pixels for every hour', (width, height) => { + for (const hour of [0, 3, 6.5, 9, 12, 15, 17.5, 19, 21, 23.5]) { + expectWellFormed(skyScene(width, height, at(hour)), width, height); + } + }); + + it('fits whole terminal cells', () => { + expect(bitmapSize(skyScene(120, 56, at(12)))).toEqual({ cols: 60, rows: 14 }); + }); + + it('changes with the hour and shows more detail when it is bigger', () => { + expect(colorsOf(skyScene(120, 56, at(12)))).not.toEqual(colorsOf(skyScene(120, 56, at(0)))); + expect(colorsOf(skyScene(240, 112, at(12))).size).toBeGreaterThan( + colorsOf(skyScene(60, 28, at(12))).size, + ); + }); + + it('puts stars in the night sky and none at noon', () => { + const stars = ['#ffffff', '#c7d4ef', '#7f93bf']; + const count = (hour: number) => + stars.filter((color) => colorsOf(skyScene(200, 80, at(hour))).has(color)).length; + expect(count(0)).toBeGreaterThan(0); + expect(count(12)).toBe(0); + }); + + it('lights the city windows after dark only', () => { + expect(colorsOf(skyScene(200, 80, at(22))).has('#ffd27a')).toBe(true); + expect(colorsOf(skyScene(200, 80, at(12))).has('#ffd27a')).toBe(false); + }); + + it('computes the phase of the moon', () => { + expect(moonPhase(new Date('2000-01-06T18:14:00Z'))).toBeCloseTo(0, 2); + expect(moonPhase(new Date('2026-02-17T12:00:00Z'))).toBeLessThan(0.05); + const full = moonPhase(new Date('2026-03-03T12:00:00Z')); + expect(full).toBeGreaterThan(0.45); + expect(full).toBeLessThan(0.55); + }); + + it('draws a different moon on different nights', () => { + const night = (day: string) => skyScene(160, 72, at(22, day)).rows.join(''); + expect(night('2026-03-03')).not.toBe(night('2026-03-10')); + }); + + it('moves the flag to the right as progress grows', () => { + const flagColumn = (progress: number) => { + const bitmap = mountainScene(120, 60, progress, 17, at(12)); + const symbol = + Object.entries(bitmap.palette).find(([, color]) => color === '#f59e0b')?.[0] ?? ''; + return Math.min( + ...bitmap.rows.map((row) => row.indexOf(symbol)).filter((index) => index >= 0), + ); + }; + expect(flagColumn(0.1)).toBeLessThan(flagColumn(0.5)); + expect(flagColumn(0.5)).toBeLessThan(flagColumn(0.9)); + expectWellFormed(mountainScene(120, 60, 1.4, 17, at(12)), 120, 60); + }); + + it('fills a bookcase deterministically, with the cat on the top shelf', () => { + expectWellFormed(shelfScene(100, 60), 100, 60); + expect(shelfScene(100, 60)).toEqual(shelfScene(100, 60)); + expect(colorsOf(shelfScene(100, 60)).has('#fbbf24')).toBe(true); + }); + + it('enlarges a bitmap by whole pixels', () => { + const scaled = scaleBitmap({ palette: { a: '#ffffff' }, rows: ['a ', ' a'] }, 3); + expect(scaled.rows).toEqual(['aaa ', 'aaa ', 'aaa ', ' aaa', ' aaa', ' aaa']); + }); +}); diff --git a/src/art/scene.ts b/src/art/scene.ts new file mode 100644 index 0000000..90c5914 --- /dev/null +++ b/src/art/scene.ts @@ -0,0 +1,530 @@ +import type { Bitmap } from '../core/pixel-art.js'; + +type Rgb = [number, number, number]; + +const ASCII_SYMBOLS = Array.from({ length: 94 }, (_, index) => String.fromCharCode(0x21 + index)); +const FINE = { x: 2, y: 4 } as const; + +function hexToRgb(hex: string): Rgb { + return [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)) as Rgb; +} + +function rgbToHex(rgb: Rgb): string { + return `#${rgb.map((value) => Math.round(value).toString(16).padStart(2, '0')).join('')}`; +} + +function mix(from: string, to: string, amount: number): string { + const a = hexToRgb(from); + const b = hexToRgb(to); + const t = Math.min(1, Math.max(0, amount)); + return rgbToHex( + [0, 1, 2].map((index) => (a[index] ?? 0) + ((b[index] ?? 0) - (a[index] ?? 0)) * t) as Rgb, + ); +} + +class Canvas { + readonly cells: (string | null)[][]; + constructor( + readonly width: number, + readonly height: number, + ) { + this.cells = Array.from({ length: height }, () => Array(width).fill(null)); + } + get(x: number, y: number): string | null { + return this.cells[Math.round(y)]?.[Math.round(x)] ?? null; + } + set(x: number, y: number, color: string | null): void { + const col = Math.round(x); + const line = this.cells[Math.round(y)]; + if (line && col >= 0 && col < this.width) line[col] = color; + } + rect(x0: number, y0: number, x1: number, y1: number, color: string): void { + for (let y = Math.round(y0); y <= Math.round(y1); y++) { + for (let x = Math.round(x0); x <= Math.round(x1); x++) this.set(x, y, color); + } + } + /** Paints a blob; the callback sees the distance from the centre, 0 at the middle and 1 at the rim. */ + blob( + cx: number, + cy: number, + rx: number, + ry: number, + paint: (x: number, y: number, edge: number) => string | null, + ): void { + for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry); y++) { + for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x++) { + const edge = Math.hypot((x - cx) / rx, (y - cy) / ry); + if (edge <= 1) { + const color = paint(x, y, edge); + if (color !== null) this.set(x, y, color); + } + } + } + } + line(x0: number, y0: number, x1: number, y1: number, color: string): void { + const steps = Math.max(1, Math.round(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)))); + for (let step = 0; step <= steps; step++) { + this.set(x0 + ((x1 - x0) * step) / steps, y0 + ((y1 - y0) * step) / steps, color); + } + } + /** Softens the staircase along high-contrast edges so silhouettes read as smooth curves. */ + smoothEdges(): void { + const luminance = (hex: string): number => { + const [r, g, b] = hexToRgb(hex); + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + }; + const next = this.cells.map((row) => [...row]); + for (let y = 1; y < this.height - 1; y++) { + for (let x = 1; x < this.width - 1; x++) { + const self = this.cells[y]?.[x]; + if (!self) continue; + const around = [ + this.get(x - 1, y), + this.get(x + 1, y), + this.get(x, y - 1), + this.get(x, y + 1), + ]; + const unlike = around.filter( + (color): color is string => + color !== null && Math.abs(luminance(color) - luminance(self)) > 0.2, + ); + const target = unlike[0]; + const row = next[y]; + if (unlike.length === 1 && target !== undefined && row) row[x] = mix(self, target, 0.35); + } + } + for (let y = 0; y < this.height; y++) this.cells[y] = next[y] ?? []; + } + toBitmap(): Bitmap { + this.smoothEdges(); + const palette: Record = {}; + const symbols = new Map(); + const rows = this.cells.map((row) => + row + .map((color) => { + if (color === null) return ' '; + let symbol = symbols.get(color); + if (symbol === undefined) { + const index = symbols.size; + symbol = + ASCII_SYMBOLS[index] ?? String.fromCharCode(0x100 + index - ASCII_SYMBOLS.length); + symbols.set(color, symbol); + palette[symbol] = color; + } + return symbol; + }) + .join(''), + ); + return { palette, rows, density: FINE, smooth: true }; + } +} + +function random(seed: number): () => number { + let state = seed; + return () => (state = (state * 16807) % 2147483647) / 2147483647; +} + +// [hour, sky at the top, sky at the horizon] +const SKY: readonly (readonly [number, string, string])[] = [ + [0, '#050b1e', '#0b1a3a'], + [5, '#0b1a3a', '#3b2a5a'], + [6.5, '#3a4a8a', '#f08a5a'], + [9, '#2f78c8', '#9fd3ff'], + [13, '#1d6fb8', '#8fcbff'], + [17, '#2a5fa8', '#f2b36a'], + [18.5, '#4a3a8a', '#f07a5a'], + [20, '#0b1a3a', '#4a3a7a'], + [22, '#050b1e', '#0b1a3a'], + [24, '#050b1e', '#0b1a3a'], +]; + +function skyColors(hour: number): { top: string; horizon: string } { + const after = SKY.findIndex(([at]) => at >= hour); + const next = SKY[Math.max(1, after)] ?? SKY[SKY.length - 1]; + const prev = SKY[Math.max(0, after - 1)] ?? SKY[0]; + if (!next || !prev) return { top: '#050b1e', horizon: '#0b1a3a' }; + const amount = (hour - prev[0]) / (next[0] - prev[0] || 1); + return { top: mix(prev[1], next[1], amount), horizon: mix(prev[2], next[2], amount) }; +} + +const isDark = (hour: number): boolean => hour < 6 || hour >= 18.5; + +/** 0 is a new moon, 0.5 a full moon, counted from the new moon of 6 January 2000. */ +export function moonPhase(date: Date): number { + const synodic = 29.530588853; + const days = (date.getTime() - Date.UTC(2000, 0, 6, 18, 14)) / 86_400_000; + return (((days / synodic) % 1) + 1) % 1; +} + +function drawSky(c: Canvas, horizon: number, hour: number, rand: () => number): void { + const { top, horizon: low } = skyColors(hour); + for (let y = 0; y < horizon; y++) + c.rect(0, y, c.width - 1, y, mix(top, low, (y / horizon) ** 1.4)); + if (isDark(hour) || hour < 7 || hour > 18) { + const strength = isDark(hour) ? 1 : 0.35; + const count = Math.round(c.width * horizon * 0.0016 * strength); + for (let index = 0; index < count; index++) { + const x = rand() * c.width; + const y = rand() * horizon * 0.85; + const bright = rand(); + c.set(x, y, bright > 0.85 ? '#ffffff' : bright > 0.4 ? '#c7d4ef' : '#7f93bf'); + if (bright > 0.95) { + c.set(x - 1, y, '#8ea2cc'); + c.set(x + 1, y, '#8ea2cc'); + c.set(x, y - 1, '#8ea2cc'); + c.set(x, y + 1, '#8ea2cc'); + } + } + } + const tint = isDark(hour) ? '#33487f' : hour > 16 || hour < 8 ? '#ffd9c2' : '#ffffff'; + for (let index = 0; index < Math.max(2, Math.round(c.width / 28)); index++) { + const cx = rand() * c.width; + const cy = horizon * (0.2 + rand() * 0.5); + const rx = c.width * (0.05 + rand() * 0.07); + const ry = Math.max(1.5, horizon * (0.025 + rand() * 0.03)); + c.blob(cx, cy, rx, ry, (x, y, edge) => mix(c.get(x, y) ?? low, tint, 0.5 * (1 - edge ** 2))); + c.blob(cx - rx * 0.25, cy - ry * 0.7, rx * 0.5, ry * 0.8, (x, y, edge) => + mix(c.get(x, y) ?? low, tint, 0.45 * (1 - edge ** 2)), + ); + } + if (!isDark(hour)) { + for (let index = 0; index < Math.round(c.width / 60); index++) { + const x = c.width * (0.15 + rand() * 0.6); + const y = horizon * (0.15 + rand() * 0.3); + for (const side of [-1, 1]) c.line(x, y, x + side * 2, y - 1, '#1d2b4a'); + } + } +} + +function drawBody( + c: Canvas, + horizon: number, + hour: number, + date: Date, +): { x: number; above: boolean } { + const night = hour < 6 || hour >= 18; + const t = night ? (((hour + 6) % 24) % 12) / 12 : (hour - 6) / 12; + const cx = c.width * (0.1 + 0.8 * t); + const cy = horizon - Math.sin(Math.PI * t) * horizon * 0.8; + const radius = Math.max(3, c.height * 0.055); + const { horizon: low } = skyColors(hour); + const glow = night ? '#3b4f8a' : '#f6b36a'; + c.blob(cx, cy, radius * 3.2, radius * 3.2, (x, y, edge) => + y < horizon ? mix(c.get(x, y) ?? low, glow, 0.5 * (1 - edge) ** 2) : null, + ); + if (!night) { + c.blob(cx, cy, radius, radius, (_x, y, edge) => + y < horizon ? mix('#fff1bd', '#ffc46b', edge ** 2) : null, + ); + return { x: cx, above: cy < horizon }; + } + const phase = moonPhase(date); + const k = Math.cos(2 * Math.PI * phase); + c.blob(cx, cy, radius, radius, (x, y) => { + if (y >= horizon) return null; + const nx = (x - cx) / radius; + const ny = (y - cy) / radius; + const half = Math.sqrt(Math.max(0, 1 - ny * ny)); + const lit = phase < 0.5 ? nx > k * half : nx < -k * half; + return lit ? mix('#f4f7ff', '#c9d4ee', Math.hypot(nx, ny) ** 3) : '#141e3d'; + }); + return { x: cx, above: cy < horizon }; +} + +function drawSea( + c: Canvas, + horizon: number, + hour: number, + rand: () => number, + body: { x: number; above: boolean }, +): void { + const { top, horizon: low } = skyColors(hour); + const shallow = mix(low, '#1f2c55', 0.55); + const deep = mix(top, '#050d1c', 0.75); + const depth = c.height - horizon; + for (let y = horizon; y < c.height; y++) + c.rect(0, y, c.width - 1, y, mix(shallow, deep, ((y - horizon) / depth) ** 0.8)); + const glint = isDark(hour) ? '#dfe8ff' : '#ffd08a'; + if (body.above) { + for (let y = horizon + 1; y < c.height - 2; y++) { + const progress = (y - horizon) / depth; + const half = Math.max(1, c.width * 0.07 * (1 - progress * 0.55)); + const wobble = Math.sin(y * 1.7) * c.width * 0.012; + if (rand() < 0.35) continue; + c.rect( + body.x + wobble - half, + y, + body.x + wobble + half, + y, + mix(glint, deep, progress * 0.8), + ); + } + } + const waves = Math.round(c.width * depth * 0.012); + for (let index = 0; index < waves; index++) { + const y = horizon + 1 + Math.floor(rand() * (depth - 3)); + const x = rand() * c.width; + const length = 1 + Math.floor(rand() * 4) * (1 + (y - horizon) / depth); + c.rect(x, y, x + length, y, mix(c.get(x, y) ?? shallow, '#ffffff', 0.12 + 0.1 * rand())); + } +} + +function drawShore(c: Canvas, horizon: number, hour: number, rand: () => number): void { + const shade = isDark(hour) ? '#02060f' : '#06101f'; + const haze = mix(skyColors(hour).horizon, shade, 0.7); + const lit = isDark(hour) || hour > 18 || hour < 6; + let x = Math.round(c.width * 0.36); + while (x < c.width * 0.62) { + const w = 2 + Math.floor(rand() * 4); + const h = Math.max(2, Math.round(horizon * (0.04 + rand() * 0.1))); + c.rect(x, horizon - h, x + w - 1, horizon, haze); + if (lit) { + for (let wy = horizon - h + 1; wy < horizon - 1; wy += 2) { + for (let wx = x + 1; wx < x + w - 1; wx += 2) if (rand() > 0.45) c.set(wx, wy, '#ffd27a'); + } + } + x += w + (rand() > 0.7 ? 1 : 0); + } + const bump = ( + from: number, + to: number, + peak: number, + mid: number, + spread: number, + color: string, + ) => { + for (let px = Math.round(from * c.width); px < Math.round(to * c.width); px++) { + const h = Math.round( + peak * Math.exp(-(((px - mid * c.width) / (spread * c.width)) ** 2)) + 1, + ); + c.rect(px, horizon - h + 1, px, horizon + 1, color); + } + }; + bump(0, 0.4, horizon * 0.3, 0.2, 0.14, mix(haze, shade, 0.45)); + bump(0, 0.3, horizon * 0.3, 0.1, 0.1, shade); + bump(0.62, 1, horizon * 0.24, 0.9, 0.11, shade); + const tx = Math.round(c.width * 0.9); + const tower = Math.round(horizon * 0.3); + const base = horizon - Math.round(horizon * 0.14); + c.rect(tx, base - tower, tx + 2, base, shade); + c.rect(tx - 1, base - tower, tx + 3, base - tower, shade); + c.rect(tx, base - tower - 2, tx + 2, base - tower - 1, lit ? '#ffe08a' : '#8a6a3a'); + if (lit) { + const reach = c.width * 0.3; + for (let step = 1; step < reach; step++) { + const spread = step * 0.12; + for (let dy = -spread; dy <= spread; dy++) { + const y = base - tower - 1 + dy; + const cell = c.get(tx - step, y); + if (cell !== null && y < horizon - 1) + c.set(tx - step, y, mix(cell, '#ffe9a8', 0.1 * (1 - step / reach))); + } + } + } + const boatX = Math.round(c.width * 0.27); + const boatY = horizon + Math.round((c.height - horizon) * 0.28); + const size = Math.max(3, Math.round(c.width * 0.035)); + for (let dy = 0; dy < size; dy++) { + c.rect( + boatX, + boatY - dy - 1, + boatX + Math.round(size * 0.55) - Math.round(dy * 0.5), + boatY - dy - 1, + lit ? '#9fb0d6' : '#f1f5f9', + ); + } + c.rect(boatX - 1, boatY, boatX + size, boatY + 1, shade); + c.rect(0, c.height - 3, c.width - 1, c.height - 1, shade); +} + +/** A seascape whose sky, sun or moon (with its real phase), stars, city, and lighthouse follow the time. */ +export function skyScene(width: number, height: number, now: Date): Bitmap { + const hour = now.getHours() + now.getMinutes() / 60; + const c = new Canvas(width, height); + const horizon = Math.round(height * 0.62); + const rand = random(11); + drawSky(c, horizon, hour, rand); + const body = drawBody(c, horizon, hour, now); + drawSea(c, horizon, hour, rand, body); + drawShore(c, horizon, hour, rand); + return c.toBitmap(); +} + +/** Ridges with a trail and a flag that climbs it as `progress` goes from 0 to 1, one tick per week. */ +export function mountainScene( + width: number, + height: number, + progress: number, + weeks: number, + now: Date, +): Bitmap { + const hour = Math.min(17, Math.max(8, now.getHours() + now.getMinutes() / 60)); + const c = new Canvas(width, height); + const { top, horizon: low } = skyColors(hour); + for (let y = 0; y < height; y++) c.rect(0, y, width - 1, y, mix(top, low, (y / height) ** 1.3)); + const rand = random(5); + for (let index = 0; index < Math.max(2, Math.round(width / 28)); index++) { + const cx = rand() * width; + const cy = height * (0.1 + rand() * 0.35); + const rx = width * (0.05 + rand() * 0.06); + const ry = Math.max(1.5, height * 0.025); + c.blob(cx, cy, rx, ry, (x, y, edge) => + mix(c.get(x, y) ?? low, '#ffffff', 0.5 * (1 - edge ** 2)), + ); + } + c.blob(width * 0.82, height * 0.2, height * 0.05, height * 0.05, (_x, _y, edge) => + mix('#fff4c9', '#ffd27a', edge ** 2), + ); + const base = height - 3; + const ridge = (x: number, scale: number, phase: number, rough: number) => { + const u = x / width; + const sway = + 0.55 + + 0.3 * Math.sin(u * Math.PI * 2.1 + phase) + + 0.15 * Math.sin(u * Math.PI * rough + phase * 2); + return ( + base - + height * scale * sway * Math.sin(Math.PI * Math.min(1, Math.max(0, u * 0.9 + 0.05))) ** 0.5 + ); + }; + const layers: [number, number, number, string][] = [ + [0.7, 1.4, 11, mix(low, '#3a4f86', 0.55)], + [0.58, 3.1, 17, mix(low, '#1f3466', 0.7)], + [0.46, 0, 23, '#0b1630'], + ]; + for (const [scale, phase, rough, color] of layers) { + for (let x = 0; x < width; x++) c.rect(x, ridge(x, scale, phase, rough), x, base + 2, color); + } + const near = (x: number) => ridge(x, 0.46, 0, 23); + const start = Math.round(width * 0.1); + const span = width * 0.8; + const reached = Math.min(1, Math.max(0, progress)); + for (let x = start; x < start + span * reached; x += 2) c.set(x, near(x) - 1, '#f8d98a'); + for (let week = 0; week <= Math.max(1, weeks); week++) { + const x = Math.round(start + (span * week) / Math.max(1, weeks)); + const past = week / Math.max(1, weeks) <= progress; + c.rect(x, near(x) - 3, x, near(x) - 2, past ? '#f8d98a' : '#5b6f9e'); + } + for (let index = 0; index < Math.round(width / 5); index++) { + const x = Math.round(rand() * width); + const y = Math.round(near(x)); + if (x > start - 4 && x < start + span + 4 && rand() > 0.4) continue; + const h = 3 + Math.floor(rand() * 4); + for (let row = 0; row < h; row++) + c.rect(x - Math.floor(row / 2), y - h + row, x + Math.floor(row / 2), y - h + row, '#0a1f2a'); + } + const fx = Math.round(start + span * reached); + const fy = Math.round(near(fx)); + const pole = Math.max(5, Math.round(height * 0.2)); + c.rect(fx, fy - pole, fx, fy - 1, '#e2e8f0'); + const length = Math.max(3, Math.round(pole * 0.7)); + for (let row = 0; row < Math.ceil(length * 0.6); row++) { + c.rect( + fx + 1, + fy - pole + row, + fx + length - Math.round(row * 1.4), + fy - pole + row, + '#f59e0b', + ); + } + c.rect(0, base + 1, width - 1, height - 1, '#050d1c'); + return c.toBitmap(); +} + +/** A bookcase with a cat on the top shelf; more books and shelves appear as the space grows. */ +export function shelfScene(width: number, height: number): Bitmap { + const c = new Canvas(width, height); + const wood = '#3a2718'; + const woodLight = '#5a3d27'; + const back = '#17100b'; + for (let y = 0; y < height; y++) c.rect(0, y, width - 1, y, mix('#1d140e', back, y / height)); + c.blob(width * 0.12, height * 0.2, width * 0.28, height * 0.55, (x, y, edge) => + mix(c.get(x, y) ?? back, '#ffb86b', 0.12 * (1 - edge)), + ); + const rand = random(23); + const shelfHeight = Math.max(14, Math.round(height / Math.max(2, Math.round(height / 22)))); + const spines = [ + '#0ea5e9', + '#06b6d4', + '#bae6fd', + '#f59e0b', + '#fb7185', + '#a78bfa', + '#4ade80', + '#94a3b8', + '#e11d48', + '#2dd4bf', + ]; + const catX = Math.round(width * 0.62); + let shelfIndex = 0; + for (let top = 0; top + shelfHeight <= height; top += shelfHeight, shelfIndex++) { + const floor = top + shelfHeight - 2; + c.rect(0, floor, width - 1, floor + 1, wood); + c.rect(0, floor, width - 1, floor, woodLight); + let x = 2; + while (x < width - 3) { + if (shelfIndex === 0 && x >= catX - 3 && x < catX + 16) { + x = catX + 16; + continue; + } + if (rand() > 0.93) x += 2 + Math.floor(rand() * 5); + const bookWidth = 3 + Math.floor(rand() * 4); + const bookHeight = Math.round((shelfHeight - 4) * (0.62 + rand() * 0.38)); + if (x + bookWidth >= width - 2) break; + const color = spines[Math.floor(rand() * spines.length)] ?? '#0ea5e9'; + const lean = rand() > 0.96; + for (let row = 0; row < bookHeight; row++) { + const shift = lean ? Math.round((row / bookHeight) * 2) : 0; + c.rect( + x + shift, + floor - 1 - row, + x + shift + bookWidth - 1, + floor - 1 - row, + mix(color, '#000000', 0.12 * (x % 3)), + ); + } + c.rect( + x, + floor - 1 - bookHeight + 2, + x + bookWidth - 1, + floor - 1 - bookHeight + 2, + mix(color, '#ffffff', 0.5), + ); + c.rect(x, floor - 4, x + bookWidth - 1, floor - 4, mix(color, '#000000', 0.4)); + if (bookHeight > 9) { + const mark = floor - 1 - Math.round(bookHeight * 0.55); + c.rect(x + 1, mark, x + bookWidth - 2, mark, mix(color, '#ffffff', 0.7)); + } + x += bookWidth; + } + if (shelfIndex === 1 && width > 40) { + const px = Math.round(width * 0.85); + c.rect(px, floor - 5, px + 5, floor - 1, '#9a4a2c'); + for (let leaf = 0; leaf < 7; leaf++) + c.line(px + 2.5, floor - 5, px + leaf - 1, floor - 11 - (leaf % 3) * 2, '#3fae5a'); + } + } + const sit = shelfHeight - 3; + const cx = catX + 6; + const fur = '#1c1c24'; + c.blob(cx, sit - 5, 6, 5, () => fur); + c.blob(cx + 5, sit - 11, 3.4, 3.4, () => fur); + c.rect(cx + 2, sit - 16, cx + 3, sit - 14, fur); + c.rect(cx + 7, sit - 16, cx + 8, sit - 14, fur); + c.set(cx + 4, sit - 11, '#fbbf24'); + c.set(cx + 7, sit - 11, '#fbbf24'); + c.line(cx - 6, sit - 2, cx - 11, sit - 6, fur); + c.line(cx - 11, sit - 6, cx - 11, sit - 10, fur); + c.rect(0, 0, 1, height - 1, wood); + c.rect(width - 2, 0, width - 1, height - 1, wood); + return c.toBitmap(); +} + +/** Enlarges a bitmap by whole pixels so a small icon can fill a roomy window. */ +export function scaleBitmap(bitmap: Bitmap, factor: number): Bitmap { + const rows = bitmap.rows.flatMap((row) => { + const wide = Array.from(row, (symbol) => symbol.repeat(factor)).join(''); + return Array.from({ length: factor }, () => wide); + }); + return { palette: bitmap.palette, rows }; +} diff --git a/src/config/preferences.ts b/src/config/preferences.ts index c21dcff..07b5dd3 100644 --- a/src/config/preferences.ts +++ b/src/config/preferences.ts @@ -5,15 +5,18 @@ import { getConfigDir, getWritableConfigDir } from './paths.js'; export type IconMode = 'auto' | 'ascii' | 'unicode'; export type ColorMode = 'auto' | 'on' | 'off'; +export type PictureDetail = 'ascii' | 'fine'; export interface Preferences { iconMode: IconMode; colorMode: ColorMode; + pictureDetail: PictureDetail; } const DEFAULT_PREFERENCES: Preferences = { iconMode: 'auto', colorMode: 'auto', + pictureDetail: 'ascii', }; const detectedColorLevel = chalk.level; const inheritedNoColor = process.env['NO_COLOR']; @@ -39,7 +42,9 @@ export function loadPreferences(): Preferences { raw.colorMode === 'on' || raw.colorMode === 'off' || raw.colorMode === 'auto' ? raw.colorMode : DEFAULT_PREFERENCES.colorMode; - return { iconMode, colorMode }; + const pictureDetail: PictureDetail = + raw.pictureDetail === 'fine' ? 'fine' : DEFAULT_PREFERENCES.pictureDetail; + return { iconMode, colorMode, pictureDetail }; } catch { return { ...DEFAULT_PREFERENCES }; } @@ -66,6 +71,12 @@ export function setColorMode(mode: ColorMode): boolean { return savePreferences(prefs); } +export function setPictureDetail(detail: PictureDetail): boolean { + const prefs = loadPreferences(); + prefs.pictureDetail = detail; + return savePreferences(prefs); +} + export function resetPreferences(): boolean { return savePreferences({ ...DEFAULT_PREFERENCES }); } diff --git a/src/core/components/empty-state.test.ts b/src/core/components/empty-state.test.ts index c860228..46a2f29 100644 --- a/src/core/components/empty-state.test.ts +++ b/src/core/components/empty-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { emptyStateLines } from './empty-state.js'; +import { emptyStateLines, loadingStateLines, offlineContext } from './empty-state.js'; import { stripAnsi, visualWidth } from '../text.js'; describe('emptyStateLines', () => { @@ -30,4 +30,38 @@ describe('emptyStateLines', () => { expect(lines.every((line) => visualWidth(line) <= cols)).toBe(true); }, ); + + it.each(['calendar', 'search'] as const)( + 'draws the larger %s picture when there are six rows to spare', + (kind) => { + const lines = emptyStateLines(kind, 'No upcoming events', 80, 8); + expect(lines).toHaveLength(6); + expect(lines.map(stripAnsi).join('\n')).toContain('No upcoming events'); + expect(lines.every((line) => visualWidth(line) <= 80)).toBe(true); + }, + ); + + it('falls back to the three-line sketch below six rows', () => { + expect(emptyStateLines('calendar', 'No upcoming events', 80, 5)).toHaveLength(3); + }); + + it('animates the loading picture and falls back to the spinner line when cramped', () => { + const first = loadingStateLines('Loading...', 80, 8, 0); + const later = loadingStateLines('Loading...', 80, 8, 1600); + expect(first).toHaveLength(6); + expect(first.map(stripAnsi).join('\n')).toContain('Loading...'); + expect(first).not.toEqual(later); + expect(loadingStateLines('Loading...', 80, 3, 0)).toHaveLength(1); + expect(loadingStateLines('Loading...', 30, 20, 0)).toHaveLength(1); + }); + + it('keeps the hint, and any URL in it, whole beneath the offline picture', () => { + const hint = [' Try again later, or read them at https://docs.nbtca.space']; + const lines = offlineContext('Could not load.', hint, ['plain'], 80, 12); + const text = lines.map(stripAnsi); + expect(text.some((line) => line.includes('Could not load.'))).toBe(true); + expect(text).toContain(hint[0]); + expect(lines.every((line) => visualWidth(line) <= 80)).toBe(true); + expect(offlineContext('Could not load.', hint, ['plain'], 80, 6)).toEqual(['plain']); + }); }); diff --git a/src/core/components/empty-state.ts b/src/core/components/empty-state.ts index 543cd10..7296697 100644 --- a/src/core/components/empty-state.ts +++ b/src/core/components/empty-state.ts @@ -1,11 +1,16 @@ import { c, space, type } from '../theme.js'; import { padEndV, visualWidth, wrapAnsiWithIndent } from '../text.js'; +import { magnifier, moonSky, orbit, signalLost } from '../../art/motifs.js'; +import { pictureBeside } from './picture.js'; +import { loadingLines } from './spinner.js'; const ART = { calendar: [' .-.-. ', ' | | ', " '---' "], search: [' .--. ', '( ) ', " '--'\\_"], } as const; +const PICTURE = { calendar: moonSky, search: magnifier } as const; + export function emptyStateLines( kind: keyof typeof ART, label: string, @@ -13,22 +18,60 @@ export function emptyStateLines( rows = Number.POSITIVE_INFINITY, ): string[] { const text = wrapAnsiWithIndent(type.hint(label), cols, space.indent); + const picture = PICTURE[kind](); + const lines = + cols >= 44 && rows >= picture.length ? beside(picture, label, cols, false) : undefined; + if (lines) return lines; const art = ART[kind]; if (cols < 40 || rows < art.length) return text; + return beside([...art], label, cols, true) ?? text; +} +function beside(art: string[], label: string, cols: number, subtle: boolean): string[] | undefined { const artWidth = Math.max(...art.map(visualWidth)); const message = wrapAnsiWithIndent( type.hint(label), cols - visualWidth(space.indent) - artWidth - 2, '', ); - if (message.length > art.length) return text; + if (message.length > art.length) return undefined; const offset = Math.floor((art.length - message.length) / 2); return art.map( (line, index) => space.indent + - c.subtle(padEndV(line, artWidth)) + + (subtle ? c.subtle(padEndV(line, artWidth)) : padEndV(line, artWidth)) + (message[index - offset] ? ` ${message[index - offset]}` : ''), ); } + +const MIN_PICTURE_COLS = 44; + +/** A small planet with an orbiting moon beside the label, or the plain spinner line when there is no room. */ +export function loadingStateLines( + label: string, + cols = Number.POSITIVE_INFINITY, + rows = Number.POSITIVE_INFINITY, + at = Date.now(), +): string[] { + const picture = orbit(at); + if (cols < MIN_PICTURE_COLS || rows < picture.length) return loadingLines(label, cols, at); + return beside(picture, label, cols, false) ?? loadingLines(label, cols, at); +} + +/** + * The error headline beside a dropped-signal tower, with the hint below at full width so a URL never wraps. + * Falls back to the plain lines when there is no room. + */ +export function offlineContext( + headline: string, + hint: readonly string[], + fallback: string[], + cols = Number.POSITIVE_INFINITY, + rows = Number.POSITIVE_INFINITY, +): string[] { + const picture = signalLost(); + if (cols < MIN_PICTURE_COLS || rows < picture.length + hint.length + 1) return fallback; + const lines = pictureBeside(picture, [headline], cols, 24); + return lines ? [...lines, ...hint, ''] : fallback; +} diff --git a/src/core/components/picture.test.ts b/src/core/components/picture.test.ts new file mode 100644 index 0000000..29080d9 --- /dev/null +++ b/src/core/components/picture.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { pictureBeside } from './picture.js'; +import { stripAnsi, visualWidth } from '../text.js'; + +const picture = ['####', '####', '####']; + +describe('pictureBeside', () => { + it('centres short text against the picture', () => { + const lines = pictureBeside(picture, ['A title'], 60)?.map(stripAnsi); + expect(lines).toHaveLength(3); + expect(lines?.[1]).toContain('A title'); + expect(lines?.[0]).not.toContain('A title'); + }); + + it('keeps the picture on top and runs on when the text is taller', () => { + const lines = pictureBeside(picture, ['one', 'two', 'three', 'four', 'five'], 60)?.map( + stripAnsi, + ); + expect(lines).toHaveLength(5); + expect(lines?.[0]).toContain('####'); + expect(lines?.[4]).not.toContain('####'); + expect(lines?.[4]).toContain('five'); + }); + + it('declines when the text column would be too narrow', () => { + expect(pictureBeside(picture, ['title'], 24)).toBeUndefined(); + expect(pictureBeside(picture, ['title'], 60, 60)).toBeUndefined(); + }); + + it('never exceeds the available width', () => { + const long = 'a long title that has to wrap inside the column next to the picture'; + expect(pictureBeside(picture, [long], 50)?.every((line) => visualWidth(line) <= 50)).toBe(true); + }); +}); diff --git a/src/core/components/picture.ts b/src/core/components/picture.ts new file mode 100644 index 0000000..055d70b --- /dev/null +++ b/src/core/components/picture.ts @@ -0,0 +1,24 @@ +import { space } from '../theme.js'; +import { padEndV, visualWidth, wrapAnsiWithIndent } from '../text.js'; + +const GAP = 2; + +/** Lays a picture beside styled text; undefined when there is no room for the text next to it. */ +export function pictureBeside( + picture: readonly string[], + text: readonly string[], + cols: number, + minTextWidth = 20, +): string[] | undefined { + const pictureWidth = Math.max(0, ...picture.map(visualWidth)); + const textWidth = cols - visualWidth(space.indent) - pictureWidth - GAP; + if (textWidth < minTextWidth) return undefined; + const wrapped = text.flatMap((line) => wrapAnsiWithIndent(line, textWidth, '')); + const rows = Math.max(picture.length, wrapped.length); + const offset = Math.max(0, Math.floor((picture.length - wrapped.length) / 2)); + return Array.from({ length: rows }, (_, index) => { + const line = picture[index] ?? ''; + const message = wrapped[index - offset]; + return `${space.indent}${padEndV(line, pictureWidth)}${message === undefined ? '' : ' '.repeat(GAP) + message}`; + }); +} diff --git a/src/core/logo.test.ts b/src/core/logo.test.ts index 72dab6d..0d54562 100644 --- a/src/core/logo.test.ts +++ b/src/core/logo.test.ts @@ -95,26 +95,36 @@ describe('buildLogoLines display capabilities', () => { expect(lines.join('')).toContain('@nbtca/prompt'); }); - it('draws the same emblem as the Unicode badge when an ASCII terminal has room', () => { + it.each([ + [80, 24, 30], + [80, 34, 44], + ])('shades the emblem with ASCII at %i x %i, %i columns wide', (cols, rows, width) => { process.env['NBTCA_ICON_MODE'] = 'ascii'; - process.stdout.columns = 80; - process.stdout.rows = 24; + process.stdout.columns = cols; + process.stdout.rows = rows; resetIconCache(); const lines = buildLogoLines().map(stripAnsi); - expect(lines.join('\n')).toContain('(@)'); - expect(lines.join('')).not.toContain('|\\ |'); + const art = lines.filter((line) => /[@%#*]/.test(line) && !line.includes('prompt')); + expect(art.length).toBeGreaterThan(10); + expect(Math.max(...art.map(visualWidth))).toBeLessThanOrEqual(width); + expect(Math.max(...art.map(visualWidth))).toBeGreaterThan(width - 6); + expect(lines.every((line) => /^[\x20-\x7e]*$/.test(line))).toBe(true); + expect(lines.length).toBeLessThanOrEqual(rows); }); - it('falls back from the ASCII emblem to the wordmark when the terminal is too short', () => { + it('draws the line emblem when the shaded ones do not fit, and the wordmark below that', () => { process.env['NBTCA_ICON_MODE'] = 'ascii'; process.stdout.columns = 80; - process.stdout.rows = 10; + process.stdout.rows = 17; resetIconCache(); + expect(buildLogoLines().map(stripAnsi).join('\n')).toContain('(@)'); - const lines = buildLogoLines().map(stripAnsi); - expect(lines.join('\n')).not.toContain('(@)'); - expect(lines.join('\n')).toContain('|\\ |'); + process.stdout.rows = 10; + resetIconCache(); + const lines = buildLogoLines().map(stripAnsi).join('\n'); + expect(lines).not.toContain('(@)'); + expect(lines).toContain('|\\ |'); }); it('uses a readable wordmark when the ASCII artwork is too wide', () => { diff --git a/src/core/logo.ts b/src/core/logo.ts index 96c06cd..604ec35 100644 --- a/src/core/logo.ts +++ b/src/core/logo.ts @@ -48,7 +48,9 @@ function logoContent(): { art: string; metadata: string[] } | undefined { (tier) => tier.file, ) : []), - ...(useUnicodeIcons() ? [] : ['ascii-emblem.txt']), + ...(useUnicodeIcons() + ? [] + : ['ascii-emblem-large.txt', 'ascii-emblem-medium.txt', 'ascii-emblem.txt']), 'ascii-logo.txt', undefined, ]; @@ -123,3 +125,22 @@ export async function runStartup(): Promise { skip.release(); } } + +const EMBLEM_FILES = { + unicode: ['ca-dotmatrix-large.txt', 'ca-dotmatrix.txt', 'ca-dotmatrix-small.txt'], + ascii: ['ascii-emblem-large.txt', 'ascii-emblem-medium.txt', 'ascii-emblem.txt'], +} as const; + +/** The largest emblem that fits the box, painted with the brand gradient; undefined when none fits. */ +export function emblemLines(maxCols: number, maxRows: number): string[] | undefined { + const files = EMBLEM_FILES[useUnicodeIcons() ? 'unicode' : 'ascii']; + for (const file of files) { + const art = readArt(file); + if (!art) continue; + const lines = art.split('\n'); + if (lines.length <= maxRows && lines.every((line) => visualWidth(line) <= maxCols)) { + return paint(art, !process.env['NO_COLOR']).split('\n'); + } + } + return undefined; +} diff --git a/src/core/pixel-art-fine.ts b/src/core/pixel-art-fine.ts new file mode 100644 index 0000000..c540804 --- /dev/null +++ b/src/core/pixel-art-fine.ts @@ -0,0 +1,180 @@ +import chalk from 'chalk'; +import type { Bitmap } from './pixel-art.js'; + +const HALF_BLOCK_DENSITY = { x: 1, y: 2 } as const; +const BRAILLE_ROWS = 4; +const BRAILLE_BASE = 0x2800; +// Dot order of a Braille cell as [column, row]: bits 0 to 7. +const BRAILLE_DOTS: readonly (readonly [number, number])[] = [ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + [1, 1], + [1, 2], + [0, 3], + [1, 3], +]; + +type Rgb = [number, number, number]; +type Pixels = (Rgb | undefined)[][]; + +function rgb(hex: string): Rgb { + return [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)) as Rgb; +} + +function hex(value: Rgb): string { + return `#${value.map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`; +} + +function luminance(color: Rgb): number { + return (0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]) / 255; +} + +function average(colors: readonly Rgb[]): Rgb { + const sum = colors.reduce( + (total, color) => [total[0] + color[0], total[1] + color[1], total[2] + color[2]], + [0, 0, 0], + ); + return [sum[0] / colors.length, sum[1] / colors.length, sum[2] / colors.length]; +} + +function distance(a: Rgb, b: Rgb): number { + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; +} + +function solid(pixels: readonly (Rgb | undefined)[]): Rgb[] { + return pixels.filter((pixel): pixel is Rgb => pixel !== undefined); +} + +function pixelAt(bitmap: Bitmap, x: number, y: number): Rgb | undefined { + const symbol = bitmap.rows[y]?.[x]; + const color = symbol === undefined || symbol === ' ' ? undefined : bitmap.palette[symbol]; + return color === undefined ? undefined : rgb(color); +} + +function cellPixels(bitmap: Bitmap, col: number, row: number): Pixels { + const { x, y } = bitmap.density ?? HALF_BLOCK_DENSITY; + return Array.from({ length: y }, (_, dy) => + Array.from({ length: x }, (_, dx) => pixelAt(bitmap, col * x + dx, row * y + dy)), + ); +} + +function blend(pixels: readonly (Rgb | undefined)[]): Rgb | undefined { + const present = solid(pixels); + return present.length > 0 && present.length * 2 >= pixels.length ? average(present) : undefined; +} + +function halfBlockCell(pixels: Pixels): string { + const middle = Math.ceil(pixels.length / 2); + const top = blend(pixels.slice(0, middle).flat()); + const bottom = blend(pixels.slice(middle).flat()); + if (top === undefined && bottom === undefined) return ' '; + if (bottom === undefined) return chalk.hex(hex(top ?? [0, 0, 0]))('▀'); + if (top === undefined) return chalk.hex(hex(bottom))('▄'); + return chalk.hex(hex(top)).bgHex(hex(bottom))('▀'); +} + +const QUADRANTS = [ + ' ', + '▘', + '▝', + '▀', + '▖', + '▌', + '▞', + '▛', + '▗', + '▚', + '▐', + '▜', + '▄', + '▙', + '▟', + '█', +] as const; +// Cells whose two colours differ by less than this are drawn as one smooth colour. +const FLAT_CONTRAST = 0.07; +// A solid quadrant glyph is kept over Braille dots while its error stays within this factor of theirs. +const QUADRANT_TOLERANCE = 1.8; + +function splitTwoColors(pixels: readonly Rgb[]): { light: Rgb; dark: Rgb; lit: boolean[] } { + let dark = pixels.reduce((best, pixel) => (luminance(pixel) < luminance(best) ? pixel : best)); + let light = pixels.reduce((best, pixel) => (luminance(pixel) > luminance(best) ? pixel : best)); + let lit = pixels.map(() => false); + for (let round = 0; round < 3; round++) { + lit = pixels.map((pixel) => distance(pixel, light) < distance(pixel, dark)); + const lights = pixels.filter((_, index) => lit[index] === true); + const darks = pixels.filter((_, index) => lit[index] === false); + if (lights.length > 0) light = average(lights); + if (darks.length > 0) dark = average(darks); + } + return { light, dark, lit }; +} + +/** + * One cell of a fine bitmap. Flat areas become a single smooth colour, edges that follow the 2 × 2 grid + * become solid quadrant blocks, and everything else, such as thin lines and slopes, uses Braille dots. + */ +function fineCell(pixels: Pixels): string { + const grid = BRAILLE_DOTS.map(([column, row]) => { + const source = + pixels[Math.min(pixels.length - 1, Math.floor((row * pixels.length) / BRAILLE_ROWS))]; + return source?.[Math.min(source.length - 1, Math.floor((column * source.length) / 2))]; + }); + const present = solid(grid); + if (present.length === 0) return ' '; + const hasGap = present.length < grid.length; + const { light, dark } = splitTwoColors(present); + const lit = grid.map( + (pixel) => pixel !== undefined && distance(pixel, light) < distance(pixel, dark), + ); + if (!hasGap && Math.abs(luminance(light) - luminance(dark)) < FLAT_CONTRAST) { + return chalk.hex(hex(average(present)))('█'); + } + + if (!hasGap) { + const quadrantOf = (index: number) => { + const [column, row] = BRAILLE_DOTS[index] ?? [0, 0]; + return (row >= 2 ? 2 : 0) + column; + }; + const mask = grid.reduce((bits, pixel, index) => { + if (pixel === undefined) return bits; + const nearLight = distance(pixel, light) < distance(pixel, dark); + return nearLight ? bits | (1 << quadrantOf(index)) : bits; + }, 0); + const quadrantLit = (index: number) => ((mask >> quadrantOf(index)) & 1) === 1; + const error = (isLit: (index: number) => boolean) => + grid.reduce( + (sum, pixel, index) => + pixel === undefined ? sum : sum + distance(pixel, isLit(index) ? light : dark), + 0, + ); + if (error(quadrantLit) <= error((index) => lit[index] === true) * QUADRANT_TOLERANCE + 1) { + const glyph = QUADRANTS[mask] ?? '█'; + if (mask === 0) return chalk.hex(hex(dark))('█'); + if (mask === 15) return chalk.hex(hex(light))('█'); + return chalk.hex(hex(light)).bgHex(hex(dark))(glyph); + } + } + + const mask = lit.reduce((bits, on, index) => (on ? bits | (1 << index) : bits), 0); + const glyph = String.fromCodePoint(BRAILLE_BASE + mask); + const hasDark = grid.some((pixel, index) => pixel !== undefined && !lit[index]); + if (!lit.some(Boolean)) return hasGap ? ' ' : chalk.hex(hex(dark))('█'); + if (!hasDark || hasGap) return chalk.hex(hex(light))(glyph); + return chalk.hex(hex(light)).bgHex(hex(dark))(glyph); +} + +/** Braille dots for fine 2 × 4 bitmaps, half blocks for chunky 1 × 2 ones. */ +export function renderFine(bitmap: Bitmap, cols: number, rows: number): string[] { + const fine = (bitmap.density?.y ?? 2) >= BRAILLE_ROWS; + return Array.from({ length: rows }, (_, row) => { + let line = ''; + for (let col = 0; col < cols; col++) { + const pixels = cellPixels(bitmap, col, row); + line += fine ? fineCell(pixels) : halfBlockCell(pixels); + } + return line; + }); +} diff --git a/src/core/pixel-art.test.ts b/src/core/pixel-art.test.ts new file mode 100644 index 0000000..7517958 --- /dev/null +++ b/src/core/pixel-art.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import chalk from 'chalk'; +import { bitmapSize, renderBitmap, type Bitmap } from './pixel-art.js'; +import { stripAnsi, visualWidth } from './text.js'; +import * as art from '../art/bitmaps.js'; + +const sample: Bitmap = { + palette: { a: '#ffffff', b: '#000000', c: '#0ea5e9' }, + rows: ['ab c', 'ba c', ' ', 'aa '], +}; + +describe('renderBitmap', () => { + const level = chalk.level; + afterEach(() => { + chalk.level = level; + }); + + it('maps two pixel rows to one terminal row', () => { + expect(bitmapSize(sample)).toEqual({ cols: 4, rows: 2 }); + expect(renderBitmap(sample, 'tone')).toHaveLength(2); + }); + + it('draws tone mode with ASCII only, denser for brighter pixels', () => { + const lines = renderBitmap(sample, 'tone').map(stripAnsi); + expect(lines.join('')).toMatch(/^[\x20-\x7e]*$/); + expect(lines[0]?.[0]).not.toBe(' '); + expect(lines[0]?.[2]).toBe(' '); + }); + + it('draws colour mode as ASCII characters on tinted backgrounds', () => { + chalk.level = 3; + const [first] = renderBitmap(sample, 'color'); + expect(first).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/); + expect(first).toMatch(/\x1b\[48;2;\d+;\d+;\d+m/); + expect(stripAnsi(first ?? '')).toMatch(/^[\x20-\x7e]*$/); + }); + + it('drops the background tint below truecolor', () => { + chalk.level = 2; + const [first] = renderBitmap(sample, 'color'); + expect(first).toMatch(/\x1b\[38;5;\d+m/); + expect(first).not.toContain('\x1b[48;'); + }); + + it('draws the fine tier with Unicode block characters', () => { + chalk.level = 3; + const lines = renderBitmap(sample, 'fine'); + expect(lines).toHaveLength(2); + expect(stripAnsi(lines.join(''))).toMatch(/[\u2580-\u259f]/u); + }); + + it('mixes neighbouring densities across a flat tone instead of stepping', () => { + const grey: Bitmap = { + palette: { g: '#6b6b6b' }, + rows: Array.from({ length: 16 }, () => 'g'.repeat(40)), + }; + const lines = renderBitmap(grey, 'tone'); + const used = new Set(lines.join('').replace(/ /g, '')); + expect(used.size).toBeGreaterThanOrEqual(2); + expect(lines.join('')).toBe(renderBitmap(grey, 'tone').join('')); + }); + + it('emits no escape sequences when colour is off', () => { + chalk.level = 0; + for (const mode of ['tone', 'color'] as const) { + expect(renderBitmap(sample, mode).join('')).not.toContain('\x1b'); + } + }); + + it('keeps every shipped picture rectangular and inside its palette', () => { + for (const [name, bitmap] of Object.entries(art)) { + const widths = new Set(bitmap.rows.map((row) => row.length)); + expect(widths.size, name).toBe(1); + const used = new Set(bitmap.rows.join('').replace(/ /g, '')); + for (const symbol of used) expect(bitmap.palette[symbol], `${name}:${symbol}`).toBeDefined(); + const lines = renderBitmap(bitmap, 'color'); + expect(lines.every((line) => visualWidth(line) === bitmapSize(bitmap).cols)).toBe(true); + } + }); +}); diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts new file mode 100644 index 0000000..f16ed83 --- /dev/null +++ b/src/core/pixel-art.ts @@ -0,0 +1,293 @@ +import chalk from 'chalk'; +import { GLYPHS, GLYPH_COLS, GLYPH_ROWS } from '../art/glyphs.js'; +import { getCapabilities } from './capabilities.js'; +import { loadPreferences } from '../config/preferences.js'; +import { renderFine } from './pixel-art-fine.js'; + +export interface Bitmap { + /** Maps a row character to a #rrggbb colour; a space is transparent. */ + palette: Readonly>; + rows: readonly string[]; + /** Pixels per terminal cell, horizontally and vertically. Defaults to 1 × 2. */ + density?: { x: number; y: number }; + /** Interpolate between pixels, for scenes; icons keep their hard pixel-art edges. */ + smooth?: boolean; +} + +/** fine: Unicode blocks (opt-in); color: coloured ASCII; tone: plain ASCII. */ +export type PictureMode = 'fine' | 'color' | 'tone'; + +const DEFAULT_DENSITY = { x: 1, y: 2 } as const; +const SAMPLES = GLYPH_COLS * GLYPH_ROWS; +// Terminals are mostly dark; transparent pixels count as this colour when a cell is only partly covered. +const BACKDROP = [20, 22, 28] as const; +// Weight of matching a cell's average brightness against matching its shape. +const TONE_WEIGHT = 8; +// The densest glyph covers about this much of its cell, so full brightness maps to it. +const MAX_INK = 0.32; +// Share of a cell's average colour kept as its background tint. +const TINT = 0.3; + +interface Glyph { + char: string; + cover: Float32Array; + ink: number; +} + +// Punctuation and shapes only: letters and digits read as noise inside a picture. +const ART_CHARS = ' .,:;-_~=+*^"|/\\()[]{}<>?#%&@$'; + +// Glyphs that differ mainly in density: flat areas switch between these without looking noisy. +const RAMP_CHARS = ' .:-=+*#%@'; +// Brightness variance below which a cell counts as flat. +const FLAT_VARIANCE = 0.0004; + +const GLYPH_TABLE: readonly Glyph[] = GLYPHS.map((hex, index) => { + const cover = Float32Array.from( + { length: SAMPLES }, + (_, sample) => Number.parseInt(hex.slice(sample * 2, sample * 2 + 2), 16) / 255, + ); + return { + char: String.fromCharCode(0x20 + index), + cover, + ink: cover.reduce((sum, value) => sum + value, 0), + }; +}).filter((glyph) => ART_CHARS.includes(glyph.char)); + +const RAMP_TABLE: readonly Glyph[] = GLYPH_TABLE.filter((glyph) => RAMP_CHARS.includes(glyph.char)); + +export function bitmapSize(bitmap: Bitmap): { cols: number; rows: number } { + const { x, y } = bitmap.density ?? DEFAULT_DENSITY; + return { + cols: Math.ceil(Math.max(0, ...bitmap.rows.map((row) => row.length)) / x), + rows: Math.ceil(bitmap.rows.length / y), + }; +} + +/** The finest picture the terminal can show and the user allows, falling back to plain ASCII. */ +export function pictureMode(): PictureMode { + const caps = getCapabilities(); + if (!caps.color || chalk.level === 0) return 'tone'; + if (caps.unicode && chalk.level >= 3 && loadPreferences().pictureDetail === 'fine') return 'fine'; + return 'color'; +} + +type Rgb = [number, number, number]; + +interface Cell { + r: Float32Array; + g: Float32Array; + b: Float32Array; + filled: number; +} + +const parsed = new Map(); + +function rgbOf(hex: string): Rgb { + let value = parsed.get(hex); + if (!value) { + value = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)) as Rgb; + parsed.set(hex, value); + } + return value; +} + +function colorAt(bitmap: Bitmap, x: number, y: number): Rgb | undefined { + const symbol = bitmap.rows[y]?.[x]; + const color = symbol === undefined || symbol === ' ' ? undefined : bitmap.palette[symbol]; + return color === undefined ? undefined : rgbOf(color); +} + +/** The colour at a fractional position, blended from the four nearest pixels. */ +function smoothAt(bitmap: Bitmap, fx: number, fy: number): Rgb | undefined { + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = fx - x0; + const ty = fy - y0; + const corners = [ + [colorAt(bitmap, x0, y0), (1 - tx) * (1 - ty)], + [colorAt(bitmap, x0 + 1, y0), tx * (1 - ty)], + [colorAt(bitmap, x0, y0 + 1), (1 - tx) * ty], + [colorAt(bitmap, x0 + 1, y0 + 1), tx * ty], + ] as const; + if (corners.some(([color, weight]) => color === undefined && weight > 0.05)) { + return colorAt(bitmap, Math.round(fx), Math.round(fy)); + } + const out: Rgb = [0, 0, 0]; + let total = 0; + for (const [color, weight] of corners) { + if (color === undefined) continue; + out[0] += color[0] * weight; + out[1] += color[1] * weight; + out[2] += color[2] * weight; + total += weight; + } + return total === 0 ? undefined : [out[0] / total, out[1] / total, out[2] / total]; +} + +/** The pixels of one cell, sampled onto the 4 × 8 grid the glyph table uses. */ +function sampleCell(bitmap: Bitmap, col: number, row: number): Cell { + const { x: dx, y: dy } = bitmap.density ?? DEFAULT_DENSITY; + const cell: Cell = { + r: new Float32Array(SAMPLES), + g: new Float32Array(SAMPLES), + b: new Float32Array(SAMPLES), + filled: 0, + }; + for (let j = 0; j < GLYPH_ROWS; j++) { + for (let i = 0; i < GLYPH_COLS; i++) { + const color = bitmap.smooth + ? smoothAt( + bitmap, + col * dx + ((i + 0.5) * dx) / GLYPH_COLS - 0.5, + row * dy + ((j + 0.5) * dy) / GLYPH_ROWS - 0.5, + ) + : colorAt( + bitmap, + col * dx + Math.min(dx - 1, Math.floor((i * dx) / GLYPH_COLS)), + row * dy + Math.min(dy - 1, Math.floor((j * dy) / GLYPH_ROWS)), + ); + const index = j * GLYPH_COLS + i; + const [r, g, b] = color ?? BACKDROP; + cell.r[index] = r; + cell.g[index] = g; + cell.b[index] = b; + if (color !== undefined) cell.filled++; + } + } + return cell; +} + +const luma = (r: number, g: number, b: number): number => + (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +const clamp = (value: number): number => Math.round(Math.min(255, Math.max(0, value))); + +interface Pick { + glyph: Glyph; + /** Brightness the glyph failed to deliver, as a share of the cell: what error diffusion passes on. */ + error: number; +} + +/** + * The glyph whose ink best matches the brightness of each part of the cell. `carry` is error diffused in from + * neighbouring cells; it shifts the target brightness, not the shape. + */ +function pickGlyph(cell: Cell, gamma: number, carry: number): Pick | undefined { + const bright = new Float32Array(SAMPLES); + let total = 0; + for (let index = 0; index < SAMPLES; index++) { + const value = + MAX_INK * Math.pow(luma(cell.r[index] ?? 0, cell.g[index] ?? 0, cell.b[index] ?? 0), gamma); + bright[index] = value; + total += value; + } + if (total / SAMPLES < 0.004) return undefined; + const mean = Math.max(0, total / SAMPLES + carry); + const shift = mean - total / SAMPLES; + let variance = 0; + for (let index = 0; index < SAMPLES; index++) + variance += ((bright[index] ?? 0) - total / SAMPLES) ** 2 / SAMPLES; + const candidates = variance < FLAT_VARIANCE ? RAMP_TABLE : GLYPH_TABLE; + let best: Glyph | undefined; + let bestCost = Number.POSITIVE_INFINITY; + for (const glyph of candidates) { + const glyphMean = glyph.ink / SAMPLES; + let cost = TONE_WEIGHT * SAMPLES * (mean - glyphMean) ** 2; + for (let index = 0; index < SAMPLES; index++) + cost += ((bright[index] ?? 0) + shift - mean - ((glyph.cover[index] ?? 0) - glyphMean)) ** 2; + if (cost < bestCost) { + bestCost = cost; + best = glyph; + } + } + return best && { glyph: best, error: mean - best.ink / SAMPLES }; +} + +interface Drawn { + text: string; + error: number; +} + +/** Plain characters only. */ +function toneCell(cell: Cell, gamma: number, carry: number): Drawn { + const pick = pickGlyph(cell, gamma, carry); + return { text: pick?.glyph.char ?? ' ', error: pick?.error ?? 0 }; +} + +/** + * Characters follow the brightness and shape of the picture; the colour lives in the characters themselves, on + * the terminal's own dark background with only a faint tint. + */ +function colorCell(cell: Cell, gamma: number, carry: number): Drawn { + const pick = pickGlyph(cell, gamma, carry); + if (!pick || pick.glyph.char === ' ') return { text: ' ', error: pick?.error ?? 0 }; + const { glyph, error } = pick; + const mean: Rgb = [0, 0, 0]; + const ink: Rgb = [0, 0, 0]; + for (let index = 0; index < SAMPLES; index++) { + const weight = glyph.cover[index] ?? 0; + const pixel: Rgb = [cell.r[index] ?? 0, cell.g[index] ?? 0, cell.b[index] ?? 0]; + for (let channel = 0; channel < 3; channel++) { + mean[channel] = (mean[channel] ?? 0) + (pixel[channel] ?? 0) / SAMPLES; + ink[channel] = (ink[channel] ?? 0) + (weight * (pixel[channel] ?? 0)) / glyph.ink; + } + } + const peak = Math.max(1, ...ink); + const level = 0.4 + 0.6 * Math.pow(luma(...ink), 0.6); + const fg = ink.map((value) => clamp((value / peak) * 255 * level)); + const text = chalk.rgb(fg[0] ?? 0, fg[1] ?? 0, fg[2] ?? 0); + if (chalk.level < 3) return { text: text(glyph.char), error }; + const bg = mean.map((value) => clamp(value * TINT)); + return { text: text.bgRgb(bg[0] ?? 0, bg[1] ?? 0, bg[2] ?? 0)(glyph.char), error }; +} + +// Floyd–Steinberg weights for the cell to the right, and below-behind, below, below-ahead. Passing on only +// part of the error, as Atkinson dithering does, keeps flat areas calm. +const DIFFUSION = 0.8; +const DIFFUSE = [7 / 16, 3 / 16, 5 / 16, 1 / 16].map((weight) => weight * DIFFUSION) as [ + number, + number, + number, + number, +]; +const MAX_CARRY = 0.12; + +/** + * Draws a bitmap as ASCII art, one character per terminal cell. With colour the characters carry the picture's + * hue on a tinted background; without it they carry only brightness, like a classic density ramp. + * Brightness error is diffused to neighbouring cells in serpentine order, as libcaca and AAlib do, so gradients + * mix the two nearest densities instead of stepping. + */ +export function renderBitmap( + bitmap: Bitmap, + mode: PictureMode = pictureMode(), + /** Below 1 lifts dark pixels so small icons stay visible; above 1 leaves broad backgrounds empty. */ + gamma = 0.7, +): string[] { + const { cols, rows } = bitmapSize(bitmap); + if (mode === 'fine') return renderFine(bitmap, cols, rows); + let carry = new Float32Array(cols + 2); + let ahead = new Float32Array(cols + 2); + return Array.from({ length: rows }, (_, row) => { + const forward = row % 2 === 0; + const direction = forward ? 1 : -1; + const cells = Array.from({ length: cols }, () => ' '); + for (let step = 0; step < cols; step++) { + const col = forward ? step : cols - 1 - step; + const cell = sampleCell(bitmap, col, row); + if (cell.filled === 0) continue; + const carried = Math.max(-MAX_CARRY, Math.min(MAX_CARRY, carry[col + 1] ?? 0)); + const drawn = + mode === 'color' ? colorCell(cell, gamma, carried) : toneCell(cell, gamma, carried); + cells[col] = drawn.text; + const error = drawn.error; + carry[col + 1 + direction] = (carry[col + 1 + direction] ?? 0) + error * DIFFUSE[0]; + ahead[col + 1 - direction] = (ahead[col + 1 - direction] ?? 0) + error * DIFFUSE[1]; + ahead[col + 1] = (ahead[col + 1] ?? 0) + error * DIFFUSE[2]; + ahead[col + 1 + direction] = (ahead[col + 1 + direction] ?? 0) + error * DIFFUSE[3]; + } + [carry, ahead] = [ahead, new Float32Array(cols + 2)]; + const line = cells.join(''); + return mode === 'tone' ? line.replace(/\s+$/, '') : line; + }); +} diff --git a/src/features/event-art.test.ts b/src/features/event-art.test.ts new file mode 100644 index 0000000..ae15d67 --- /dev/null +++ b/src/features/event-art.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { eventIcon } from './event-art.js'; +import { + eventIconConference, + eventIconDefault, + eventIconMeeting, + eventIconRepair, + eventIconWorkshop, +} from '../art/bitmaps.js'; + +describe('eventIcon', () => { + it.each([ + ['电脑义诊', eventIconRepair], + ['Git 工作坊', eventIconWorkshop], + ['NWDC 杭州线下活动', eventIconConference], + ['开发部例会:统计社团基础设施', eventIconMeeting], + ['Weekly meeting', eventIconMeeting], + ['新生交流', eventIconDefault], + ])('maps %s', (title, icon) => { + expect(eventIcon(title)).toBe(icon); + }); + + it('reads the description only when the title says nothing', () => { + expect(eventIcon('周六活动', '现场维修笔记本')).toBe(eventIconRepair); + expect(eventIcon('Git 工作坊', '会后有例会')).toBe(eventIconWorkshop); + }); +}); diff --git a/src/features/event-art.ts b/src/features/event-art.ts new file mode 100644 index 0000000..0fcf8b6 --- /dev/null +++ b/src/features/event-art.ts @@ -0,0 +1,24 @@ +import type { Bitmap } from '../core/pixel-art.js'; +import { + eventIconConference, + eventIconDefault, + eventIconMeeting, + eventIconRepair, + eventIconWorkshop, +} from '../art/bitmaps.js'; + +const RULES: readonly (readonly [RegExp, Bitmap])[] = [ + [/维修|义诊|修电脑|repair/i, eventIconRepair], + [/工作坊|培训|workshop|training|hands-?on/i, eventIconWorkshop], + [/大会|conference|nwdc|summit|meetup/i, eventIconConference], + [/例会|周会|会议|meeting|standup/i, eventIconMeeting], +]; + +/** Picks the picture from the title first, then the description, so a description cannot override the title. */ +export function eventIcon(title: string, description = ''): Bitmap { + for (const text of [title, description]) { + const rule = RULES.find(([pattern]) => pattern.test(text)); + if (rule) return rule[1]; + } + return eventIconDefault; +} diff --git a/src/features/schedule-view.ts b/src/features/schedule-view.ts index ac505ca..b331d58 100644 --- a/src/features/schedule-view.ts +++ b/src/features/schedule-view.ts @@ -79,6 +79,16 @@ export function todayLines( } } +/** True when the saved timetable has no class today; false on any other answer, including a broken cache. */ +export function isRestDay(cached: CachedSchedule, now: Date = new Date()): boolean { + try { + const schedule = scheduleOf(cached); + return schedule.meetingsOnDay(schedule.weekAt(now), schedule.weekdayAt(now)).length === 0; + } catch { + return false; + } +} + export interface WeekAheadInfo { weekStart: string; classDays: boolean[]; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index d827ae5..93a7b3a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -371,6 +371,10 @@ export interface Translations { backToMenu: string; iconMode: string; colorMode: string; + pictureDetail: string; + modeFine: string; + pictureUpdated: string; + pictureUpdatedSessionOnly: string; updated: string; updatedSessionOnly: string; reset: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 21b6ee6..165003d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -419,12 +419,16 @@ "backToMenu": "Back to menu", "iconMode": "Icon Mode", "colorMode": "Color Mode", + "pictureDetail": "Picture Detail", + "modeFine": "Fine (Unicode blocks)", + "pictureUpdated": "Picture detail updated", + "pictureUpdatedSessionOnly": "Picture detail changed for this session only (failed to save config)", "updated": "Theme setting updated", "updatedSessionOnly": "Theme setting updated for this session only (failed to save config)", "reset": "Theme settings reset", "resetLabel": "Reset to Defaults", "resetSessionOnly": "Theme settings reset for this session only (failed to save config)", - "resetConfirm": "Reset icon mode and color mode to their defaults?", + "resetConfirm": "Reset icon mode, color mode, and picture detail to their defaults?", "iconUpdated": "Icon mode updated", "iconUpdatedSessionOnly": "Icon mode changed for this session only (failed to save config)", "colorUpdated": "Color mode updated", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6f65eb5..f93b7d2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -419,6 +419,10 @@ "backToMenu": "返回菜单", "iconMode": "图标模式", "colorMode": "颜色模式", + "pictureDetail": "图片精细度", + "modeFine": "精细(Unicode 块字符)", + "pictureUpdated": "图片精细度已更新", + "pictureUpdatedSessionOnly": "图片精细度仅在本次会话中更改(配置保存失败)", "updated": "主题设置已更新", "updatedSessionOnly": "主题设置仅在当前会话生效(无法写入配置文件)", "reset": "主题设置已重置", diff --git a/src/logo/ascii-emblem-large.txt b/src/logo/ascii-emblem-large.txt new file mode 100644 index 0000000..461f827 --- /dev/null +++ b/src/logo/ascii-emblem-large.txt @@ -0,0 +1,22 @@ + .-=+*####***###**+=:. + .:+***+*+++***++++++=+***+-. + :=***+=:-++**********++**+++**+:. + .=**+******+=::. ..:-=+**+*+=*#+: + :+*+=+#*+=:. :-+**+++**=. + =#*=+***=---:.. ..:--::-+***++*+. +.-**+**##+++++=++=:. .-=++=++=++==****+#+. +-**+*##*- ..::..:-==:.=#*=: ..::..-+***++=#= +*#++#@@@#+++====+=:=+=%@@%*+++==++=-=*#+++** +%*+**%%%*-: :==++-#%%#+-. :-++***++*# +%+++*+.. .... ..-#+*+# +%+==*+ -#+*+# +%*=*#*-+: .=- =*=+*% +*#+***=+=. -*-:*+::*# +=#+=++*=+-. -*+.+*++*#+ +.=#++***+*=: .-++-***++**: + .=#*+++*++++-. :=++-=*+**+#+: + .-**+++**+++++=-:......:-=+++-=*#*+-+#+: + :+#*+==+**+++++******++===+*++=++**-. + .:+**+++=++***++++++***+++*+***+=. + :=***+++++=+++++++*++=+***+:. + .:=+***************+-: diff --git a/src/logo/ascii-emblem-medium.txt b/src/logo/ascii-emblem-medium.txt new file mode 100644 index 0000000..ea2ce6c --- /dev/null +++ b/src/logo/ascii-emblem-medium.txt @@ -0,0 +1,15 @@ + .-+***#******+=: + .=+*+-++*******=**++=: + .=*+***+=-:....:-=+**++*+. + .++++*=:. ...=***+*: +:****#+=====-. .-=======+****- ++*+%%#=+===--+-#%*=====-=**=+* +#++##*-: .-==-*#*=:...-=+*++# +#=+*: +**# +#=*#=- :=.*++# +**+*++: =+=*=+* +:*++*++-. :=+=*++*- + :**++*++=:. .-=+=+*++*- + .+*+++++++++++++++++*+**+: + :+**+=+*+++++**+=+**+- + .:=++********+++-. diff --git a/vitest.config.js b/vitest.config.js index a767912..61a4ba0 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -4,5 +4,10 @@ import { join } from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { env: { XDG_STATE_HOME: mkdtempSync(join(tmpdir(), 'nbtca-test-state-')) } }, + test: { + env: { + XDG_STATE_HOME: mkdtempSync(join(tmpdir(), 'nbtca-test-state-')), + XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'nbtca-test-config-')), + }, + }, });