From 7d33de5f063bc9e0945d3d2e7a451c653bc1bb14 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 16:03:12 +0800 Subject: [PATCH 01/15] feat(tui): add shaded and block-colour pictures --- scripts/generate-art.mjs | 351 ++++++++++++++++++++++++ src/app/views/events-render.ts | 23 +- src/app/views/events.test.ts | 15 + src/app/views/settings-render.ts | 16 +- src/app/views/settings.test.ts | 20 ++ src/art/bitmaps.ts | 163 +++++++++++ src/core/components/empty-state.test.ts | 14 + src/core/components/empty-state.ts | 15 +- src/core/components/picture.test.ts | 34 +++ src/core/components/picture.ts | 24 ++ src/core/logo.test.ts | 30 +- src/core/logo.ts | 4 +- src/core/pixel-art.test.ts | 55 ++++ src/core/pixel-art.ts | 79 ++++++ src/features/event-art.test.ts | 27 ++ src/features/event-art.ts | 24 ++ src/logo/ascii-emblem-large.txt | 22 ++ src/logo/ascii-emblem-medium.txt | 15 + 18 files changed, 912 insertions(+), 19 deletions(-) create mode 100644 scripts/generate-art.mjs create mode 100644 src/art/bitmaps.ts create mode 100644 src/core/components/picture.test.ts create mode 100644 src/core/components/picture.ts create mode 100644 src/core/pixel-art.test.ts create mode 100644 src/core/pixel-art.ts create mode 100644 src/features/event-art.test.ts create mode 100644 src/features/event-art.ts create mode 100644 src/logo/ascii-emblem-large.txt create mode 100644 src/logo/ascii-emblem-medium.txt diff --git a/scripts/generate-art.mjs b/scripts/generate-art.mjs new file mode 100644 index 0000000..bcee529 --- /dev/null +++ b/scripts/generate-art.mjs @@ -0,0 +1,351 @@ +// 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 }; + } +} + +function seeded(seed) { + let state = seed; + return () => (state = (state * 16807) % 2147483647) / 2147483647; +} + +function scene(width, height) { + const c = new Canvas(width, height); + const k = width / 60; + const horizon = Math.round(height * 0.55); + const sky = ['#081a33', '#0b2a52', '#124689', '#1d5fa8', '#5b4b9a', '#a0508c', '#e0705f']; + const bandRows = Math.max(1, Math.round(horizon / sky.length)); + for (let y = 0; y < horizon; y++) + c.rect(0, y, width - 1, y, sky[Math.min(sky.length - 1, Math.floor(y / bandRows))]); + const random = seeded(7); + for (let i = 0; i < Math.round(16 * k); i++) + c.set(Math.floor(random() * width), Math.floor(random() * horizon * 0.4), '#f8fafc'); + const cx = width / 2; + c.circle(cx, horizon, 9 * k, (x, y) => (y < horizon ? '#e8885a' : null)); + c.circle(cx, horizon, 6.5 * k, (x, y) => (y < horizon ? '#ffd27a' : null)); + const cloud = [ + [6, 0.38, 18, '#7c6bc4'], + [38, 0.3, 16, '#7c6bc4'], + [12, 0.62, 22, '#d98aa8'], + [34, 0.7, 20, '#d98aa8'], + ]; + cloud.forEach(([x, row, w, color]) => + c.rect( + Math.round(x * k), + Math.round(horizon * row), + Math.round((x + w) * k) - 1, + Math.round(horizon * row), + color, + ), + ); + const sea = ['#3a3f7a', '#2b3768', '#1f2c55', '#16224a', '#0f1a3a']; + const seaBand = Math.max(1, Math.round((height - horizon) / sea.length)); + for (let y = horizon; y < height; y++) + c.rect(0, y, width - 1, y, sea[Math.min(sea.length - 1, Math.floor((y - horizon) / seaBand))]); + for (let y = horizon + 1; y < height - 2; y++) { + const half = Math.max(1, Math.round((10 - (y - horizon) * 0.8) * k)); + if ((y - horizon) % 2 === 1) + c.rect( + Math.round(cx - half), + y, + Math.round(cx + half), + y, + y < horizon + 5 ? '#f0b266' : '#b9803f', + ); + } + const hill = '#050d1c'; + const bump = (x0, x1, peak, mid, spread) => { + for (let x = Math.round(x0 * k); x < Math.round(x1 * k); x++) { + const h = Math.round(peak * Math.exp(-Math.pow((x - mid * k) / (spread * k), 2)) + 1); + c.rect(x, horizon - h + 1, x, horizon, hill); + } + }; + bump(0, 22, Math.round(horizon * 0.3), 9, 7); + bump(40, 60, Math.round(horizon * 0.22), 52, 6); + const tx = Math.round(51 * k); + c.rect(tx, horizon - 9, tx + 1, horizon - 5, hill); + c.rect(tx - 1, horizon - 5, tx + 2, horizon - 5, hill); + c.set(tx, horizon - 10, '#ffe08a'); + c.set(tx + 1, horizon - 10, '#ffe08a'); + c.rect(0, height - 2, width - 1, height - 1, hill); + return c.toBitmap(); +} + +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 emptyArt() { + const calendar = new Canvas(14, 12); + calendarPage(calendar, 1, 0, 12, 11, null); + calendar.rect(5, 6, 8, 7, '#e2e8f0'); + const search = new Canvas(14, 12); + search.circle( + 5.5, + 5, + 4.6, + (x, y, d) => (d < 3.2 ? (x + y < 9 ? '#e0f2fe' : '#7dd3fc') : ICON.cyan), + 0, + ); + search.circle(5.5, 5, 4.6, ICON.cyan, 3.2); + search.line(9, 8, 12, 11, ICON.amber); + search.line(10, 8, 13, 11, ICON.amber); + search.set(3, 3, ICON.white); + search.set(4, 3, ICON.white); + search.set(3, 4, ICON.white); + return { calendar: calendar.toBitmap(), search: search.toBitmap() }; +} + +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.', + '', + emitBitmap('scene', scene(44, 18)), +]; +const iconSet = icons(); +for (const [name, bitmap] of Object.entries(iconSet)) + bitmaps.push(emitBitmap(`eventIcon${name[0].toUpperCase()}${name.slice(1)}`, bitmap)); +const empty = emptyArt(); +for (const [name, bitmap] of Object.entries(empty)) + bitmaps.push(emitBitmap(`empty${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/src/app/views/events-render.ts b/src/app/views/events-render.ts index 7556d75..9bac065 100644 --- a/src/app/views/events-render.ts +++ b/src/app/views/events-render.ts @@ -12,6 +12,9 @@ import { } from '../../features/calendar.js'; import { renderHeatmap } from '../../features/calendar-heatmap.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; +import { pictureBeside } from '../../core/components/picture.js'; +import { renderBitmap } from '../../core/pixel-art.js'; +import { eventIcon } from '../../features/event-art.js'; import { loadingLines } from '../../core/components/spinner.js'; import { emptyStateLines } from '../../core/components/empty-state.js'; @@ -197,10 +200,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 +216,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) 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/settings-render.ts b/src/app/views/settings-render.ts index 1527c18..ee0c8cd 100644 --- a/src/app/views/settings-render.ts +++ b/src/app/views/settings-render.ts @@ -1,5 +1,8 @@ import { space, type } from '../../core/theme.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; +import { pictureBeside } from '../../core/components/picture.js'; +import { renderBitmap } from '../../core/pixel-art.js'; +import { scene } from '../../art/bitmaps.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'reset' | 'about'; @@ -43,12 +46,21 @@ export function renderSettings( 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 = renderBitmap(scene); + 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..7b01bdc 100644 --- a/src/app/views/settings.test.ts +++ b/src/app/views/settings.test.ts @@ -139,4 +139,24 @@ describe('settingsView', () => { expect(stripAnsi(settingsView.render(ctx).join('\n'))).toMatch(/Icon mode (updated|changed)/u); expect(selectedLabel(ctx)).toBe('Icon Mode'); }); + + 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); + }); }); diff --git a/src/art/bitmaps.ts b/src/art/bitmaps.ts new file mode 100644 index 0000000..b0e4821 --- /dev/null +++ b/src/art/bitmaps.ts @@ -0,0 +1,163 @@ +import type { Bitmap } from '../core/pixel-art.js'; + +// Generated by scripts/generate-art.mjs; edit the script, not this file. + +export const scene: Bitmap = { + palette: { + '0': '#081a33', + '1': '#f8fafc', + '2': '#ffe08a', + '3': '#0b2a52', + '4': '#050d1c', + '5': '#124689', + '6': '#1d5fa8', + '7': '#7c6bc4', + '8': '#5b4b9a', + '9': '#e8885a', + a: '#a0508c', + b: '#e0705f', + c: '#d98aa8', + d: '#ffd27a', + e: '#3a3f7a', + f: '#f0b266', + g: '#2b3768', + h: '#1f2c55', + i: '#b9803f', + }, + rows: [ + '00000000000010000000000000000010000102200000', + '33333333333333333333333333333313333334433333', + '55555155555555551555555555555515115554455555', + '16666666666666166666666666667777777774471666', + '88887777777777777788999998888888888884488888', + 'aaaaaaaaaaaaaaaaaa999999999aaaaaaaaa4444aaaa', + 'bbbbbbbbbcccccccccccccccc999bbbbbbbbbbbbbbbb', + 'bbbbb4444bbbbbbbb99ddddddcccccccccccccccbbbb', + 'bbb44444444bbbbb99ddddddddd99bbbbbbb44444bbb', + '44444444444444bb99ddddddddd99bbbb44444444444', + '4444444444444444eeeeeeeeeeeee444444444444444', + 'eeeeeeeeeeeeeeefffffffffffffffeeeeeeeeeeeeee', + 'gggggggggggggggggggggggggggggggggggggggggggg', + 'ggggggggggggggggfffffffffffffggggggggggggggg', + 'hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh', + 'hhhhhhhhhhhhhhhhhhiiiiiiiiihhhhhhhhhhhhhhhhh', + '44444444444444444444444444444444444444444444', + '44444444444444444444444444444444444444444444', + ], +}; + +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 ', + ], +}; + +export const emptyCalendar: Bitmap = { + palette: { '0': '#94a3b8', '1': '#0ea5e9', '2': '#f1f5f9', '3': '#bae6fd', '4': '#e2e8f0' }, + rows: [ + ' 0 0 ', + ' 111111111111 ', + ' 111111111111 ', + ' 222222222222 ', + ' 223232323232 ', + ' 222222222222 ', + ' 223244443232 ', + ' 222244442222 ', + ' 223232323232 ', + ' 222222222222 ', + ' 223232323232 ', + ' 222222222222 ', + ], +}; + +export const emptySearch: Bitmap = { + palette: { '0': '#06b6d4', '1': '#e0f2fe', '2': '#f1f5f9', '3': '#7dd3fc', '4': '#f59e0b' }, + rows: [ + ' ', + ' 0000 ', + ' 001100 ', + ' 02213300 ', + ' 02133330 ', + ' 0013333300 ', + ' 03333330 ', + ' 00333300 ', + ' 00330044 ', + ' 0000 44 ', + ' 44 ', + ' 44', + ], +}; diff --git a/src/core/components/empty-state.test.ts b/src/core/components/empty-state.test.ts index c860228..270a283 100644 --- a/src/core/components/empty-state.test.ts +++ b/src/core/components/empty-state.test.ts @@ -30,4 +30,18 @@ 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); + }); }); diff --git a/src/core/components/empty-state.ts b/src/core/components/empty-state.ts index 543cd10..6eb87f9 100644 --- a/src/core/components/empty-state.ts +++ b/src/core/components/empty-state.ts @@ -1,11 +1,15 @@ import { c, space, type } from '../theme.js'; import { padEndV, visualWidth, wrapAnsiWithIndent } from '../text.js'; +import { emptyCalendar, emptySearch } from '../../art/bitmaps.js'; +import { renderBitmap } from '../pixel-art.js'; const ART = { calendar: [' .-.-. ', ' | | ', " '---' "], search: [' .--. ', '( ) ', " '--'\\_"], } as const; +const PICTURE = { calendar: emptyCalendar, search: emptySearch } as const; + export function emptyStateLines( kind: keyof typeof ART, label: string, @@ -13,22 +17,29 @@ export function emptyStateLines( rows = Number.POSITIVE_INFINITY, ): string[] { const text = wrapAnsiWithIndent(type.hint(label), cols, space.indent); + const picture = renderBitmap(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]}` : ''), ); } 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..2a4ece0 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, ]; diff --git a/src/core/pixel-art.test.ts b/src/core/pixel-art.test.ts new file mode 100644 index 0000000..cb18579 --- /dev/null +++ b/src/core/pixel-art.test.ts @@ -0,0 +1,55 @@ +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('packs two pixel rows into 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('uses half blocks with foreground and background colours in colour mode', () => { + chalk.level = 3; + const [first] = renderBitmap(sample, 'color'); + expect(first).toContain('\x1b[38;2;255;255;255m'); + expect(first).toContain('\x1b[48;2;0;0;0m'); + expect(stripAnsi(first ?? '')).toContain('▀'); + }); + + 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..e465cd1 --- /dev/null +++ b/src/core/pixel-art.ts @@ -0,0 +1,79 @@ +import chalk from 'chalk'; +import { getCapabilities } from './capabilities.js'; +import { useUnicodeIcons } from './icons.js'; + +export interface Bitmap { + /** Maps a row character to a #rrggbb colour; a space is transparent. */ + palette: Readonly>; + rows: readonly string[]; +} + +export type PictureMode = 'color' | 'tone'; + +// Light-on-dark density order, measured from JetBrains Mono ink coverage. +const TONE_RAMP = ' .:-=+*#%@'; + +export function bitmapSize(bitmap: Bitmap): { cols: number; rows: number } { + return { + cols: Math.max(0, ...bitmap.rows.map((row) => row.length)), + rows: Math.ceil(bitmap.rows.length / 2), + }; +} + +export function pictureMode(): PictureMode { + return getCapabilities().color && chalk.level > 0 && useUnicodeIcons() ? 'color' : 'tone'; +} + +function rgb(hex: string): [number, number, number] { + return [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)) as [ + number, + number, + number, + ]; +} + +function luminance(hex: string): number { + const [r, g, b] = rgb(hex); + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +function pixel(bitmap: Bitmap, x: number, y: number): string | undefined { + const symbol = bitmap.rows[y]?.[x]; + return symbol === undefined || symbol === ' ' ? undefined : bitmap.palette[symbol]; +} + +function halfBlockCell(top: string | undefined, bottom: string | undefined): string { + if (top === undefined && bottom === undefined) return ' '; + if (top === bottom && top !== undefined) return chalk.hex(top)('█'); + if (bottom === undefined) return chalk.hex(top ?? '#000000')('▀'); + if (top === undefined) return chalk.hex(bottom)('▄'); + return chalk.hex(top).bgHex(bottom)('▀'); +} + +function toneCell(top: string | undefined, bottom: string | undefined, color: boolean): string { + const colors = [top, bottom].filter((value): value is string => value !== undefined); + if (colors.length === 0) return ' '; + const average = Math.pow(colors.reduce((sum, value) => sum + luminance(value), 0) / 2, 0.7); + const char = + TONE_RAMP[Math.min(TONE_RAMP.length - 1, Math.round(average * (TONE_RAMP.length - 1)))] ?? ' '; + if (!color || char === ' ') return char; + const brightest = colors.reduce((best, value) => + luminance(value) > luminance(best) ? value : best, + ); + return chalk.hex(brightest)(char); +} + +/** Two pixel rows share one terminal row, matching the 1:2 shape of a cell. */ +export function renderBitmap(bitmap: Bitmap, mode: PictureMode = pictureMode()): string[] { + const { cols, rows } = bitmapSize(bitmap); + const color = getCapabilities().color && chalk.level > 0; + return Array.from({ length: rows }, (_, row) => { + let line = ''; + for (let x = 0; x < cols; x++) { + const top = pixel(bitmap, x, row * 2); + const bottom = pixel(bitmap, x, row * 2 + 1); + line += mode === 'color' ? halfBlockCell(top, bottom) : toneCell(top, bottom, color); + } + 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/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 @@ + .-+***#******+=: + .=+*+-++*******=**++=: + .=*+***+=-:....:-=+**++*+. + .++++*=:. ...=***+*: +:****#+=====-. .-=======+****- ++*+%%#=+===--+-#%*=====-=**=+* +#++##*-: .-==-*#*=:...-=+*++# +#=+*: +**# +#=*#=- :=.*++# +**+*++: =+=*=+* +:*++*++-. :=+=*++*- + :**++*++=:. .-=+=+*++*- + .+*+++++++++++++++++*+**+: + :+**+=+*+++++**+=+**+- + .:=++********+++-. From 5b94447ac847e3aa66954974af1b66a0fbbd8008 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 16:22:37 +0800 Subject: [PATCH 02/15] feat(tui): fill empty space with pictures that grow with the window --- src/app/ambient.test.ts | 49 ++++ src/app/ambient.ts | 80 +++++++ src/app/app.ts | 3 +- src/app/view.ts | 3 + src/app/views/docs.ts | 5 + src/app/views/events.ts | 12 + src/app/views/home.test.ts | 6 + src/app/views/home.ts | 5 + src/app/views/schedule-render.test.ts | 19 ++ src/app/views/schedule-render.ts | 9 + src/app/views/schedule.ts | 14 +- src/app/views/settings.test.ts | 8 + src/app/views/settings.ts | 5 + src/art/scene.test.ts | 71 ++++++ src/art/scene.ts | 324 ++++++++++++++++++++++++++ src/core/logo.ts | 19 ++ src/core/pixel-art.ts | 18 +- 17 files changed, 644 insertions(+), 6 deletions(-) create mode 100644 src/app/ambient.test.ts create mode 100644 src/app/ambient.ts create mode 100644 src/art/scene.test.ts create mode 100644 src/art/scene.ts diff --git a/src/app/ambient.test.ts b/src/app/ambient.test.ts new file mode 100644 index 0000000..da7406c --- /dev/null +++ b/src/app/ambient.test.ts @@ -0,0 +1,49 @@ +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 }], + ['shelf', { kind: 'shelf' }], + ['emblem', { kind: 'emblem' }], + ['icon', { kind: 'icon', icon: eventIconConference }], +]; + +describe('withAmbient', () => { + it.each(specs)('draws the %s beside the page in a big window', (_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(stripAnsi(lines[0] ?? '')).toContain('line 0 of the page content'); + expect(visualWidth(lines[0] ?? '')).toBeGreaterThan(visualWidth(body[0] ?? '')); + }); + + it.each(specs)('leaves the %s out of an ordinary 80 x 24 window', (_name, spec) => { + expect(withAmbient(body, spec, 80, 19, noon)).toEqual(body); + }); + + 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]*$/); + }); +}); diff --git a/src/app/ambient.ts b/src/app/ambient.ts new file mode 100644 index 0000000..2587eb4 --- /dev/null +++ b/src/app/ambient.ts @@ -0,0 +1,80 @@ +import { emblemLines } from '../core/logo.js'; +import { padEndV, visualWidth } from '../core/text.js'; +import { renderBitmap, type Bitmap } from '../core/pixel-art.js'; +import { mountainScene, scaleBitmap, shelfScene, skyScene } from '../art/scene.js'; +import { space } from '../core/theme.js'; + +/** What a page draws in the space its content leaves empty. */ +export type AmbientSpec = + | { kind: 'sky' } + | { kind: 'shelf' } + | { kind: 'mountain'; progress: number } + | { kind: 'emblem' } + | { kind: 'icon'; icon: Bitmap }; + +const GAP = 4; +const MIN_WIDTH = 30; +const MIN_ROWS = 7; +const MAX_SCENE_WIDTH = 104; +const SCENE_GAMMA = 1.8; + +function hourOf(now: Date): number { + return now.getHours() + now.getMinutes() / 60; +} + +function picture(spec: AmbientSpec, width: number, rows: number, now: Date): string[] | undefined { + switch (spec.kind) { + case 'sky': + case 'mountain': { + const cols = Math.min(width, MAX_SCENE_WIDTH); + const height = Math.min(rows, Math.round(cols * 0.55)); + if (cols < MIN_WIDTH || height < MIN_ROWS) return undefined; + return renderBitmap( + spec.kind === 'sky' + ? skyScene(cols, height * 2, hourOf(now)) + : mountainScene(cols, height * 2, spec.progress, hourOf(now)), + undefined, + SCENE_GAMMA, + ); + } + case 'shelf': { + const cols = Math.min(width, 64); + const height = Math.min(rows, Math.round(cols * 0.5)); + return cols < MIN_WIDTH || height < MIN_ROWS + ? undefined + : renderBitmap(shelfScene(cols, height * 2), undefined, SCENE_GAMMA); + } + case 'emblem': + return emblemLines(width, rows); + case 'icon': { + const factor = Math.min( + Math.floor(width / (spec.icon.rows[0]?.length ?? 1)), + Math.floor((rows * 2) / spec.icon.rows.length), + 3, + ); + return factor >= 2 ? renderBitmap(scaleBitmap(spec.icon, factor)) : undefined; + } + } +} + +/** Puts a picture to the right of the page, 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 contentWidth = Math.max(0, ...body.map(visualWidth)); + const width = cols - contentWidth - GAP - visualWidth(space.indent); + const art = picture(spec, width, Math.max(0, Math.floor(bodyRows) - 1), now); + if (!art) return [...body]; + + const rows = Math.max(body.length, art.length); + return Array.from({ length: rows }, (_, 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.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.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..2423d5a 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -20,6 +20,7 @@ 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'; @@ -329,6 +330,10 @@ export const homeView = { } }, + ambient(): AmbientSpec { + return { 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..6f78e9b 100644 --- a/src/app/views/schedule-render.test.ts +++ b/src/app/views/schedule-render.test.ts @@ -1183,3 +1183,22 @@ 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 progress = termProgress(window); + expect(progress).toBeGreaterThan(0.2); + expect(progress).toBeLessThan(0.5); + 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..8cd4f10 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -412,6 +412,15 @@ function renderHubBody( return withGrid.grid ? withGrid.lines : layoutHub(state, now, bodyRows, cols, shortcuts).lines; } +export function termProgress(w: AcademicWindow | OnBreak | null | undefined): number | undefined { + if (!w || !('currentWeek' in w) || !w.nextBreakStart) return undefined; + const totalWeeks = Math.max( + 1, + Math.round(isoDayDifference(w.weekOneMonday, w.nextBreakStart) / 7), + ); + return Math.min(1, Math.max(0, w.currentWeek / totalWeeks)); +} + const TERM_PROGRESS_WIDTH = 20; function renderTermProgressBar(w: AcademicWindow, cols: number): string[] | null { diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index a249878..9b941f8 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 progress = termProgress(state.publicWindow); + return progress === undefined ? undefined : { kind: 'mountain', progress }; + }, + isBusy(): boolean { if (state.mode === 'records') return recordsPanel?.isBusy() ?? false; return state.mode === 'loading'; diff --git a/src/app/views/settings.test.ts b/src/app/views/settings.test.ts index 7b01bdc..810d3d8 100644 --- a/src/app/views/settings.test.ts +++ b/src/app/views/settings.test.ts @@ -159,4 +159,12 @@ describe('settingsView', () => { .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..2782ad8 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 { @@ -93,6 +94,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; }, diff --git a/src/art/scene.test.ts b/src/art/scene.test.ts new file mode 100644 index 0000000..df626a6 --- /dev/null +++ b/src/art/scene.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { mountainScene, scaleBitmap, shelfScene, skyScene } from './scene.js'; +import type { Bitmap } from '../core/pixel-art.js'; + +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); + for (const symbol of new Set(bitmap.rows.join('').replace(/ /g, ''))) { + expect(bitmap.palette[symbol]).toBeDefined(); + } +} + +describe('scenes', () => { + it.each([ + [30, 14], + [60, 24], + [104, 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, hour), width, height); + } + }); + + it('changes with the hour and shows more detail when it is bigger', () => { + const noon = colorsOf(skyScene(60, 24, 12)); + const midnight = colorsOf(skyScene(60, 24, 0)); + expect(noon).not.toEqual(midnight); + expect(skyScene(100, 56, 12).rows.join('')).not.toBe(skyScene(100, 56, 13).rows.join('')); + expect(colorsOf(skyScene(100, 56, 12)).size).toBeGreaterThan( + colorsOf(skyScene(30, 14, 12)).size, + ); + }); + + it('puts stars in the night sky and none at noon', () => { + expect(colorsOf(skyScene(60, 24, 0)).has('#f8fafc')).toBe(true); + expect(colorsOf(skyScene(60, 24, 12)).has('#f8fafc')).toBe(false); + }); + + it('moves the flag to the right as progress grows', () => { + const flagColumn = (progress: number) => { + const bitmap = mountainScene(60, 30, progress, 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(60, 30, 1.4, 12), 60, 30); + }); + + it('fills a bookcase deterministically', () => { + expectWellFormed(shelfScene(50, 30), 50, 30); + expect(shelfScene(50, 30)).toEqual(shelfScene(50, 30)); + expect(colorsOf(shelfScene(60, 36)).size).toBeGreaterThan(8); + }); + + 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..1a70061 --- /dev/null +++ b/src/art/scene.ts @@ -0,0 +1,324 @@ +import type { Bitmap } from '../core/pixel-art.js'; + +type Rgb = [number, number, number]; + +const SYMBOLS = Array.from({ length: 94 }, (_, index) => String.fromCharCode(0x21 + index)); + +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)); + } + set(x: number, y: number, color: string | null): void { + const col = Math.round(x); + const row = Math.round(y); + const line = this.cells[row]; + 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); + } + } + disc( + cx: number, + cy: number, + radius: number, + color: (x: number, y: number) => string | null, + ): void { + for (let y = Math.floor(cy - radius); y <= Math.ceil(cy + radius); y++) { + for (let x = Math.floor(cx - radius); x <= Math.ceil(cx + radius); x++) { + if (Math.hypot(x - cx, y - cy) <= radius) { + const value = color(x, y); + if (value !== null) this.set(x, y, value); + } + } + } + } + toBitmap(): Bitmap { + 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) { + symbol = SYMBOLS[symbols.size] ?? SYMBOLS.at(-1) ?? '!'; + symbols.set(color, symbol); + palette[symbol] = color; + } + return symbol; + }) + .join(''), + ); + return { palette, rows }; + } +} + +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 isNight = (hour: number): boolean => hour < 5.5 || hour >= 19.5; + +function drawSky(c: Canvas, horizon: number, hour: number, seed: 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 / Math.max(1, horizon - 1))); + const rand = random(seed); + if (isNight(hour) || hour < 6.5 || hour > 18.5) { + const strength = isNight(hour) ? 1 : 0.4; + const count = Math.round(c.width * horizon * 0.03 * strength); + for (let index = 0; index < count; index++) { + const bright = rand() > 0.7; + c.set(rand() * c.width, rand() * horizon * 0.85, bright ? '#f8fafc' : '#9fb4d8'); + } + } + const clouds = Math.max(2, Math.round(c.width / 14)); + for (let index = 0; index < clouds; index++) { + const y = Math.round(horizon * (0.25 + rand() * 0.55)); + const x = rand() * c.width; + const length = Math.round(c.width * (0.08 + rand() * 0.14)); + const color = mix(mix(top, low, y / horizon), isNight(hour) ? '#23366a' : '#ffffff', 0.35); + c.rect(x, y, Math.min(c.width - 1, x + length), y, color); + c.rect(x + length * 0.2, y - 1, x + length * 0.7, y - 1, color); + } +} + +function drawBody(c: Canvas, horizon: number, hour: number): { x: number; above: boolean } { + const night = isNight(hour) || 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.78; + const radius = Math.max(2, c.height * 0.1); + const glow = night ? '#3b4f8a' : '#f2a65e'; + const core = night ? '#e8eefc' : '#ffd27a'; + c.disc(cx, cy, radius * 1.45, (_x, y) => (y < horizon ? glow : null)); + c.disc(cx, cy, radius, (_x, y) => (y < horizon ? core : null)); + if (night) + c.disc(cx + radius * 0.45, cy - radius * 0.2, radius * 0.8, (_x, y) => + y < horizon ? skyColors(hour).top : null, + ); + return { x: cx, above: cy < horizon }; +} + +function drawSea( + c: Canvas, + horizon: number, + hour: number, + seed: number, + body: { x: number; above: boolean }, +): void { + const { top, horizon: low } = skyColors(hour); + const shallow = mix(low, '#1f2c55', 0.6); + const deep = mix(top, '#050d1c', 0.7); + for (let y = horizon; y < c.height; y++) + c.rect( + 0, + y, + c.width - 1, + y, + mix(shallow, deep, (y - horizon) / Math.max(1, c.height - horizon - 1)), + ); + const rand = random(seed + 3); + const glint = isNight(hour) ? '#c9d6f5' : '#f0b266'; + if (body.above) { + for (let y = horizon + 1; y < c.height - 1; y++) { + if ((y - horizon) % 2 === 0) continue; + const half = Math.max( + 1, + Math.round(c.width * 0.12 * (1 - (y - horizon) / (c.height - horizon + 2))), + ); + c.rect( + body.x - half, + y, + body.x + half, + y, + mix(glint, deep, (y - horizon) / (c.height - horizon)), + ); + } + } + const waves = Math.round(c.width * (c.height - horizon) * 0.05); + for (let index = 0; index < waves; index++) { + const y = horizon + 1 + Math.floor(rand() * (c.height - horizon - 2)); + const x = rand() * c.width; + c.rect(x, y, x + 1 + Math.floor(rand() * 2), y, mix(shallow, '#ffffff', 0.12)); + } +} + +function drawShore(c: Canvas, horizon: number, hour: number): void { + const shade = isNight(hour) ? '#02060f' : '#050d1c'; + const bump = (from: number, to: number, peak: number, mid: number, spread: number) => { + for (let x = Math.round(from * c.width); x < Math.round(to * c.width); x++) { + const h = Math.round( + peak * Math.exp(-Math.pow((x - mid * c.width) / (spread * c.width), 2)) + 1, + ); + c.rect(x, horizon - h + 1, x, horizon, shade); + } + }; + bump(0, 0.36, horizon * 0.34, 0.15, 0.11); + bump(0.66, 1, horizon * 0.24, 0.87, 0.1); + const tx = Math.round(c.width * 0.85); + const top = Math.round(horizon - horizon * 0.34); + c.rect(tx, top, tx + 1, horizon - 3, shade); + c.rect(tx - 1, top + Math.round(horizon * 0.14), tx + 2, top + Math.round(horizon * 0.14), shade); + const lamp = isNight(hour) || hour < 6 || hour > 18 ? '#ffe08a' : '#8a6a3a'; + c.rect(tx, top - 1, tx + 1, top - 1, lamp); + if (c.width >= 40) { + for (let step = 0; step < 3; step++) c.set(tx + 2 + step * 2, top - 1 - step, lamp); + } + c.rect(0, c.height - 2, c.width - 1, c.height - 1, shade); +} + +/** A seascape whose sky, sun or moon, stars, and lamp follow the hour of the day. */ +export function skyScene(width: number, height: number, hour: number): Bitmap { + const c = new Canvas(width, height); + const horizon = Math.round(height * 0.58); + drawSky(c, horizon, hour, 11); + const body = drawBody(c, horizon, hour); + drawSea(c, horizon, hour, 11, body); + drawShore(c, horizon, hour); + return c.toBitmap(); +} + +/** A ridge with a flag that climbs it as `progress` goes from 0 to 1. */ +export function mountainScene( + width: number, + height: number, + progress: number, + hour: number, +): Bitmap { + const c = new Canvas(width, height); + const base = height - 2; + const { top, horizon: low } = skyColors(Math.min(17, Math.max(8, hour))); + for (let y = 0; y < height; y++) c.rect(0, y, width - 1, y, mix(top, low, y / height)); + const rand = random(5); + for (let index = 0; index < Math.max(2, Math.round(width / 12)); index++) { + const y = Math.round(height * (0.15 + rand() * 0.4)); + const x = rand() * width; + c.rect(x, y, x + width * 0.1, y, mix(mix(top, low, y / height), '#ffffff', 0.4)); + } + const ridge = (x: number, scale: number, phase: number) => + base - + height * + scale * + (0.55 + 0.45 * Math.sin((x / width) * Math.PI * 2.2 + phase)) * + Math.sin((x / width) * Math.PI) ** 0.6; + const far = mix(low, '#2b3768', 0.6); + const near = '#0b1630'; + for (let x = 0; x < width; x++) c.rect(x, ridge(x, 0.62, 1.4), x, base + 1, far); + const top2 = (x: number) => ridge(x, 0.5, 0); + for (let x = 0; x < width; x++) c.rect(x, top2(x), x, base + 1, near); + const fx = Math.round(width * (0.12 + 0.76 * Math.min(1, Math.max(0, progress)))); + for (let x = Math.round(width * 0.12); x < fx; x += 2) c.set(x, top2(x) - 1, '#f8d98a'); + const fy = Math.round(top2(fx)); + const pole = Math.max(3, Math.round(height * 0.2)); + c.rect(fx, fy - pole, fx, fy - 1, '#e2e8f0'); + const flag = Math.max(2, Math.round(pole * 0.6)); + for (let row = 0; row < Math.ceil(flag / 2); row++) + c.rect(fx + 1, fy - pole + row, fx + flag - row * 2, fy - pole + row, '#f59e0b'); + c.rect(0, base + 1, width - 1, height - 1, '#050d1c'); + return c.toBitmap(); +} + +/** A bookcase whose books get more numerous and more varied as the space grows. */ +export function shelfScene(width: number, height: number): Bitmap { + const c = new Canvas(width, height); + const wood = '#2a1d14'; + const back = '#140e0a'; + c.rect(0, 0, width - 1, height - 1, back); + const rand = random(23); + const shelfHeight = Math.max(6, Math.round(height / Math.max(2, Math.round(height / 9)))); + const spines = [ + '#0ea5e9', + '#06b6d4', + '#bae6fd', + '#f59e0b', + '#fb7185', + '#a78bfa', + '#4ade80', + '#94a3b8', + ]; + for (let top = 0; top + shelfHeight <= height; top += shelfHeight) { + const floor = top + shelfHeight - 1; + c.rect(0, floor, width - 1, floor, wood); + let x = 1; + while (x < width - 2) { + const gap = rand() > 0.9 ? Math.round(1 + rand() * 3) : 0; + x += gap; + const bookWidth = 2 + Math.floor(rand() * 3); + const bookHeight = Math.round((shelfHeight - 2) * (0.6 + rand() * 0.4)); + if (x + bookWidth >= width - 1) break; + const color = spines[Math.floor(rand() * spines.length)] ?? '#0ea5e9'; + c.rect(x, floor - bookHeight, x + bookWidth - 1, floor - 1, color); + if (bookHeight >= 5) + c.rect( + x, + floor - bookHeight + 2, + x + bookWidth - 1, + floor - bookHeight + 2, + mix(color, '#ffffff', 0.45), + ); + if (bookHeight >= 7 && bookWidth >= 3) + c.rect(x, floor - 3, x + bookWidth - 1, floor - 3, mix(color, '#000000', 0.35)); + x += bookWidth; + } + } + c.rect(0, 0, 0, height - 1, wood); + c.rect(width - 1, 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/core/logo.ts b/src/core/logo.ts index 2a4ece0..604ec35 100644 --- a/src/core/logo.ts +++ b/src/core/logo.ts @@ -125,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.ts b/src/core/pixel-art.ts index e465cd1..53e4544 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -50,10 +50,15 @@ function halfBlockCell(top: string | undefined, bottom: string | undefined): str return chalk.hex(top).bgHex(bottom)('▀'); } -function toneCell(top: string | undefined, bottom: string | undefined, color: boolean): string { +function toneCell( + top: string | undefined, + bottom: string | undefined, + color: boolean, + gamma: number, +): string { const colors = [top, bottom].filter((value): value is string => value !== undefined); if (colors.length === 0) return ' '; - const average = Math.pow(colors.reduce((sum, value) => sum + luminance(value), 0) / 2, 0.7); + const average = Math.pow(colors.reduce((sum, value) => sum + luminance(value), 0) / 2, gamma); const char = TONE_RAMP[Math.min(TONE_RAMP.length - 1, Math.round(average * (TONE_RAMP.length - 1)))] ?? ' '; if (!color || char === ' ') return char; @@ -64,7 +69,12 @@ function toneCell(top: string | undefined, bottom: string | undefined, color: bo } /** Two pixel rows share one terminal row, matching the 1:2 shape of a cell. */ -export function renderBitmap(bitmap: Bitmap, mode: PictureMode = pictureMode()): string[] { +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); const color = getCapabilities().color && chalk.level > 0; return Array.from({ length: rows }, (_, row) => { @@ -72,7 +82,7 @@ export function renderBitmap(bitmap: Bitmap, mode: PictureMode = pictureMode()): for (let x = 0; x < cols; x++) { const top = pixel(bitmap, x, row * 2); const bottom = pixel(bitmap, x, row * 2 + 1); - line += mode === 'color' ? halfBlockCell(top, bottom) : toneCell(top, bottom, color); + line += mode === 'color' ? halfBlockCell(top, bottom) : toneCell(top, bottom, color, gamma); } return mode === 'tone' ? line.replace(/\s+$/, '') : line; }); From 058fb82d758a1afc57118d10fd00a5c91e194484 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 16:42:29 +0800 Subject: [PATCH 03/15] feat(tui): draw scenes in Braille detail and use the space below the content --- src/app/ambient.test.ts | 44 ++- src/app/ambient.ts | 111 ++++--- src/app/views/schedule-render.test.ts | 7 +- src/app/views/schedule-render.ts | 11 +- src/app/views/schedule.ts | 4 +- src/art/scene.test.ts | 66 ++-- src/art/scene.ts | 461 ++++++++++++++++++-------- src/core/pixel-art.ts | 160 ++++++--- 8 files changed, 603 insertions(+), 261 deletions(-) diff --git a/src/app/ambient.test.ts b/src/app/ambient.test.ts index da7406c..916ac8d 100644 --- a/src/app/ambient.test.ts +++ b/src/app/ambient.test.ts @@ -11,24 +11,56 @@ const noon = new Date('2026-10-28T12:00:00'); const specs: [string, AmbientSpec][] = [ ['sky', { kind: 'sky' }], - ['mountain', { kind: 'mountain', progress: 0.4 }], + ['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 beside the page in a big window', (_name, spec) => { + 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(stripAnsi(lines[0] ?? '')).toContain('line 0 of the page content'); - expect(visualWidth(lines[0] ?? '')).toBeGreaterThan(visualWidth(body[0] ?? '')); + expect(lines.map((line) => stripAnsi(line).trimEnd()).join('\n')).toContain( + 'line 5 of the page content', + ); }); - it.each(specs)('leaves the %s out of an ordinary 80 x 24 window', (_name, spec) => { - expect(withAmbient(body, spec, 80, 19, noon)).toEqual(body); + 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', () => { diff --git a/src/app/ambient.ts b/src/app/ambient.ts index 2587eb4..262fbb2 100644 --- a/src/app/ambient.ts +++ b/src/app/ambient.ts @@ -1,6 +1,6 @@ import { emblemLines } from '../core/logo.js'; import { padEndV, visualWidth } from '../core/text.js'; -import { renderBitmap, type Bitmap } from '../core/pixel-art.js'; +import { pictureMode, renderBitmap, type Bitmap } from '../core/pixel-art.js'; import { mountainScene, scaleBitmap, shelfScene, skyScene } from '../art/scene.js'; import { space } from '../core/theme.js'; @@ -8,56 +8,70 @@ import { space } from '../core/theme.js'; export type AmbientSpec = | { kind: 'sky' } | { kind: 'shelf' } - | { kind: 'mountain'; progress: number } + | { kind: 'mountain'; progress: number; weeks: number } | { kind: 'emblem' } | { kind: 'icon'; icon: Bitmap }; const GAP = 4; -const MIN_WIDTH = 30; +const MIN_COLS = 30; const MIN_ROWS = 7; -const MAX_SCENE_WIDTH = 104; +const MIN_PANORAMA_ROWS = 8; +const MAX_COLS = 130; const SCENE_GAMMA = 1.8; +const FINE = { x: 2, y: 4 } as const; -function hourOf(now: Date): number { - return now.getHours() + now.getMinutes() / 60; +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; } -function picture(spec: AmbientSpec, width: number, rows: number, now: Date): string[] | undefined { - switch (spec.kind) { - case 'sky': - case 'mountain': { - const cols = Math.min(width, MAX_SCENE_WIDTH); - const height = Math.min(rows, Math.round(cols * 0.55)); - if (cols < MIN_WIDTH || height < MIN_ROWS) return undefined; - return renderBitmap( - spec.kind === 'sky' - ? skyScene(cols, height * 2, hourOf(now)) - : mountainScene(cols, height * 2, spec.progress, hourOf(now)), - undefined, - SCENE_GAMMA, - ); - } - case 'shelf': { - const cols = Math.min(width, 64); - const height = Math.min(rows, Math.round(cols * 0.5)); - return cols < MIN_WIDTH || height < MIN_ROWS - ? undefined - : renderBitmap(shelfScene(cols, height * 2), undefined, SCENE_GAMMA); - } - case 'emblem': - return emblemLines(width, rows); - case 'icon': { - const factor = Math.min( - Math.floor(width / (spec.icon.rows[0]?.length ?? 1)), - Math.floor((rows * 2) / spec.icon.rows.length), - 3, - ); - return factor >= 2 ? renderBitmap(scaleBitmap(spec.icon, factor)) : undefined; - } - } +/** 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(), + ].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): string[] | undefined { + if (spec.kind === 'emblem') return emblemLines(cols, rows); + 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; } -/** Puts a picture to the right of the page, as large as the leftover space allows. */ +/** + * 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, @@ -66,13 +80,22 @@ export function withAmbient( 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 - visualWidth(space.indent); - const art = picture(spec, width, Math.max(0, Math.floor(bodyRows) - 1), now); + 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); if (!art) return [...body]; - const rows = Math.max(body.length, art.length); - return Array.from({ length: rows }, (_, index) => { + 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/views/schedule-render.test.ts b/src/app/views/schedule-render.test.ts index 6f78e9b..b44936f 100644 --- a/src/app/views/schedule-render.test.ts +++ b/src/app/views/schedule-render.test.ts @@ -1195,9 +1195,10 @@ describe('termProgress', () => { currentWeek: 5, nextBreakStart: '2026-12-21', } as unknown as Parameters[0]; - const progress = termProgress(window); - expect(progress).toBeGreaterThan(0.2); - expect(progress).toBeLessThan(0.5); + 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 8cd4f10..4fd83a6 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -412,13 +412,12 @@ function renderHubBody( return withGrid.grid ? withGrid.lines : layoutHub(state, now, bodyRows, cols, shortcuts).lines; } -export function termProgress(w: AcademicWindow | OnBreak | null | undefined): number | undefined { +export function termProgress( + w: AcademicWindow | OnBreak | null | undefined, +): { progress: number; weeks: number } | undefined { if (!w || !('currentWeek' in w) || !w.nextBreakStart) return undefined; - const totalWeeks = Math.max( - 1, - Math.round(isoDayDifference(w.weekOneMonday, w.nextBreakStart) / 7), - ); - return Math.min(1, Math.max(0, w.currentWeek / totalWeeks)); + 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; diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index 9b941f8..829b52c 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -478,8 +478,8 @@ export const scheduleView = { ambient(): AmbientSpec | undefined { if (state.mode !== 'public' && state.mode !== 'hub') return undefined; - const progress = termProgress(state.publicWindow); - return progress === undefined ? undefined : { kind: 'mountain', progress }; + const term = termProgress(state.publicWindow); + return term === undefined ? undefined : { kind: 'mountain', ...term }; }, isBusy(): boolean { diff --git a/src/art/scene.test.ts b/src/art/scene.test.ts index df626a6..c1075b0 100644 --- a/src/art/scene.test.ts +++ b/src/art/scene.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { mountainScene, scaleBitmap, shelfScene, skyScene } from './scene.js'; -import type { Bitmap } from '../core/pixel-art.js'; +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( @@ -13,6 +16,7 @@ function colorsOf(bitmap: Bitmap): Set { 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(); } @@ -20,33 +24,55 @@ function expectWellFormed(bitmap: Bitmap, width: number, height: number): void { describe('scenes', () => { it.each([ - [30, 14], - [60, 24], - [104, 60], + [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, hour), width, height); + 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', () => { - const noon = colorsOf(skyScene(60, 24, 12)); - const midnight = colorsOf(skyScene(60, 24, 0)); - expect(noon).not.toEqual(midnight); - expect(skyScene(100, 56, 12).rows.join('')).not.toBe(skyScene(100, 56, 13).rows.join('')); - expect(colorsOf(skyScene(100, 56, 12)).size).toBeGreaterThan( - colorsOf(skyScene(30, 14, 12)).size, + 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', () => { - expect(colorsOf(skyScene(60, 24, 0)).has('#f8fafc')).toBe(true); - expect(colorsOf(skyScene(60, 24, 12)).has('#f8fafc')).toBe(false); + 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(60, 30, progress, 12); + const bitmap = mountainScene(120, 60, progress, 17, at(12)); const symbol = Object.entries(bitmap.palette).find(([, color]) => color === '#f59e0b')?.[0] ?? ''; return Math.min( @@ -55,13 +81,13 @@ describe('scenes', () => { }; expect(flagColumn(0.1)).toBeLessThan(flagColumn(0.5)); expect(flagColumn(0.5)).toBeLessThan(flagColumn(0.9)); - expectWellFormed(mountainScene(60, 30, 1.4, 12), 60, 30); + expectWellFormed(mountainScene(120, 60, 1.4, 17, at(12)), 120, 60); }); - it('fills a bookcase deterministically', () => { - expectWellFormed(shelfScene(50, 30), 50, 30); - expect(shelfScene(50, 30)).toEqual(shelfScene(50, 30)); - expect(colorsOf(shelfScene(60, 36)).size).toBeGreaterThan(8); + 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', () => { diff --git a/src/art/scene.ts b/src/art/scene.ts index 1a70061..be514f7 100644 --- a/src/art/scene.ts +++ b/src/art/scene.ts @@ -2,7 +2,8 @@ import type { Bitmap } from '../core/pixel-art.js'; type Rgb = [number, number, number]; -const SYMBOLS = Array.from({ length: 94 }, (_, index) => String.fromCharCode(0x21 + index)); +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; @@ -29,10 +30,12 @@ class Canvas { ) { 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 row = Math.round(y); - const line = this.cells[row]; + 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 { @@ -40,21 +43,30 @@ class Canvas { for (let x = Math.round(x0); x <= Math.round(x1); x++) this.set(x, y, color); } } - disc( + /** 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, - radius: number, - color: (x: number, y: number) => string | null, + rx: number, + ry: number, + paint: (x: number, y: number, edge: number) => string | null, ): void { - for (let y = Math.floor(cy - radius); y <= Math.ceil(cy + radius); y++) { - for (let x = Math.floor(cx - radius); x <= Math.ceil(cx + radius); x++) { - if (Math.hypot(x - cx, y - cy) <= radius) { - const value = color(x, y); - if (value !== null) this.set(x, y, value); + 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); + } + } toBitmap(): Bitmap { const palette: Record = {}; const symbols = new Map(); @@ -64,7 +76,9 @@ class Canvas { if (color === null) return ' '; let symbol = symbols.get(color); if (symbol === undefined) { - symbol = SYMBOLS[symbols.size] ?? SYMBOLS.at(-1) ?? '!'; + const index = symbols.size; + symbol = + ASCII_SYMBOLS[index] ?? String.fromCharCode(0x100 + index - ASCII_SYMBOLS.length); symbols.set(color, symbol); palette[symbol] = color; } @@ -72,7 +86,7 @@ class Canvas { }) .join(''), ); - return { palette, rows }; + return { palette, rows, density: FINE }; } } @@ -104,46 +118,87 @@ function skyColors(hour: number): { top: string; horizon: string } { return { top: mix(prev[1], next[1], amount), horizon: mix(prev[2], next[2], amount) }; } -const isNight = (hour: number): boolean => hour < 5.5 || hour >= 19.5; +const isDark = (hour: number): boolean => hour < 6 || hour >= 18.5; -function drawSky(c: Canvas, horizon: number, hour: number, seed: number): void { +/** 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 / Math.max(1, horizon - 1))); - const rand = random(seed); - if (isNight(hour) || hour < 6.5 || hour > 18.5) { - const strength = isNight(hour) ? 1 : 0.4; - const count = Math.round(c.width * horizon * 0.03 * strength); + 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 bright = rand() > 0.7; - c.set(rand() * c.width, rand() * horizon * 0.85, bright ? '#f8fafc' : '#9fb4d8'); + 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 clouds = Math.max(2, Math.round(c.width / 14)); - for (let index = 0; index < clouds; index++) { - const y = Math.round(horizon * (0.25 + rand() * 0.55)); - const x = rand() * c.width; - const length = Math.round(c.width * (0.08 + rand() * 0.14)); - const color = mix(mix(top, low, y / horizon), isNight(hour) ? '#23366a' : '#ffffff', 0.35); - c.rect(x, y, Math.min(c.width - 1, x + length), y, color); - c.rect(x + length * 0.2, y - 1, x + length * 0.7, y - 1, color); + 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): { x: number; above: boolean } { - const night = isNight(hour) || hour < 6 || hour >= 18; +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.78; - const radius = Math.max(2, c.height * 0.1); - const glow = night ? '#3b4f8a' : '#f2a65e'; - const core = night ? '#e8eefc' : '#ffd27a'; - c.disc(cx, cy, radius * 1.45, (_x, y) => (y < horizon ? glow : null)); - c.disc(cx, cy, radius, (_x, y) => (y < horizon ? core : null)); - if (night) - c.disc(cx + radius * 0.45, cy - radius * 0.2, radius * 0.8, (_x, y) => - y < horizon ? skyColors(hour).top : null, + 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 }; } @@ -151,129 +206,214 @@ function drawSea( c: Canvas, horizon: number, hour: number, - seed: number, + rand: () => number, body: { x: number; above: boolean }, ): void { const { top, horizon: low } = skyColors(hour); - const shallow = mix(low, '#1f2c55', 0.6); - const deep = mix(top, '#050d1c', 0.7); + 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) / Math.max(1, c.height - horizon - 1)), - ); - const rand = random(seed + 3); - const glint = isNight(hour) ? '#c9d6f5' : '#f0b266'; + 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 - 1; y++) { - if ((y - horizon) % 2 === 0) continue; - const half = Math.max( - 1, - Math.round(c.width * 0.12 * (1 - (y - horizon) / (c.height - horizon + 2))), - ); + 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 - half, + body.x + wobble - half, y, - body.x + half, + body.x + wobble + half, y, - mix(glint, deep, (y - horizon) / (c.height - horizon)), + mix(glint, deep, progress * 0.8), ); } } - const waves = Math.round(c.width * (c.height - horizon) * 0.05); + const waves = Math.round(c.width * depth * 0.012); for (let index = 0; index < waves; index++) { - const y = horizon + 1 + Math.floor(rand() * (c.height - horizon - 2)); + const y = horizon + 1 + Math.floor(rand() * (depth - 3)); const x = rand() * c.width; - c.rect(x, y, x + 1 + Math.floor(rand() * 2), y, mix(shallow, '#ffffff', 0.12)); + 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): void { - const shade = isNight(hour) ? '#02060f' : '#050d1c'; - const bump = (from: number, to: number, peak: number, mid: number, spread: number) => { - for (let x = Math.round(from * c.width); x < Math.round(to * c.width); x++) { +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(-Math.pow((x - mid * c.width) / (spread * c.width), 2)) + 1, + peak * Math.exp(-(((px - mid * c.width) / (spread * c.width)) ** 2)) + 1, ); - c.rect(x, horizon - h + 1, x, horizon, shade); + c.rect(px, horizon - h + 1, px, horizon + 1, color); } }; - bump(0, 0.36, horizon * 0.34, 0.15, 0.11); - bump(0.66, 1, horizon * 0.24, 0.87, 0.1); - const tx = Math.round(c.width * 0.85); - const top = Math.round(horizon - horizon * 0.34); - c.rect(tx, top, tx + 1, horizon - 3, shade); - c.rect(tx - 1, top + Math.round(horizon * 0.14), tx + 2, top + Math.round(horizon * 0.14), shade); - const lamp = isNight(hour) || hour < 6 || hour > 18 ? '#ffe08a' : '#8a6a3a'; - c.rect(tx, top - 1, tx + 1, top - 1, lamp); - if (c.width >= 40) { - for (let step = 0; step < 3; step++) c.set(tx + 2 + step * 2, top - 1 - step, lamp); + 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))); + } + } } - c.rect(0, c.height - 2, c.width - 1, c.height - 1, shade); + 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, stars, and lamp follow the hour of the day. */ -export function skyScene(width: number, height: number, hour: number): Bitmap { +/** 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.58); - drawSky(c, horizon, hour, 11); - const body = drawBody(c, horizon, hour); - drawSea(c, horizon, hour, 11, body); - drawShore(c, horizon, hour); + 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(); } -/** A ridge with a flag that climbs it as `progress` goes from 0 to 1. */ +/** 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, - hour: 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 base = height - 2; - const { top, horizon: low } = skyColors(Math.min(17, Math.max(8, hour))); - for (let y = 0; y < height; y++) c.rect(0, y, width - 1, y, mix(top, low, y / 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 / 12)); index++) { - const y = Math.round(height * (0.15 + rand() * 0.4)); - const x = rand() * width; - c.rect(x, y, x + width * 0.1, y, mix(mix(top, low, y / height), '#ffffff', 0.4)); + 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'); } - const ridge = (x: number, scale: number, phase: number) => - base - - height * - scale * - (0.55 + 0.45 * Math.sin((x / width) * Math.PI * 2.2 + phase)) * - Math.sin((x / width) * Math.PI) ** 0.6; - const far = mix(low, '#2b3768', 0.6); - const near = '#0b1630'; - for (let x = 0; x < width; x++) c.rect(x, ridge(x, 0.62, 1.4), x, base + 1, far); - const top2 = (x: number) => ridge(x, 0.5, 0); - for (let x = 0; x < width; x++) c.rect(x, top2(x), x, base + 1, near); - const fx = Math.round(width * (0.12 + 0.76 * Math.min(1, Math.max(0, progress)))); - for (let x = Math.round(width * 0.12); x < fx; x += 2) c.set(x, top2(x) - 1, '#f8d98a'); - const fy = Math.round(top2(fx)); - const pole = Math.max(3, Math.round(height * 0.2)); + 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 flag = Math.max(2, Math.round(pole * 0.6)); - for (let row = 0; row < Math.ceil(flag / 2); row++) - c.rect(fx + 1, fy - pole + row, fx + flag - row * 2, fy - pole + row, '#f59e0b'); + 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 whose books get more numerous and more varied as the space grows. */ +/** 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 = '#2a1d14'; - const back = '#140e0a'; - c.rect(0, 0, width - 1, height - 1, back); + 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(6, Math.round(height / Math.max(2, Math.round(height / 9)))); + const shelfHeight = Math.max(14, Math.round(height / Math.max(2, Math.round(height / 22)))); const spines = [ '#0ea5e9', '#06b6d4', @@ -283,34 +423,71 @@ export function shelfScene(width: number, height: number): Bitmap { '#a78bfa', '#4ade80', '#94a3b8', + '#e11d48', + '#2dd4bf', ]; - for (let top = 0; top + shelfHeight <= height; top += shelfHeight) { - const floor = top + shelfHeight - 1; - c.rect(0, floor, width - 1, floor, wood); - let x = 1; - while (x < width - 2) { - const gap = rand() > 0.9 ? Math.round(1 + rand() * 3) : 0; - x += gap; - const bookWidth = 2 + Math.floor(rand() * 3); - const bookHeight = Math.round((shelfHeight - 2) * (0.6 + rand() * 0.4)); - if (x + bookWidth >= width - 1) break; + 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'; - c.rect(x, floor - bookHeight, x + bookWidth - 1, floor - 1, color); - if (bookHeight >= 5) + const lean = rand() > 0.96; + for (let row = 0; row < bookHeight; row++) { + const shift = lean ? Math.round((row / bookHeight) * 2) : 0; c.rect( - x, - floor - bookHeight + 2, - x + bookWidth - 1, - floor - bookHeight + 2, - mix(color, '#ffffff', 0.45), + x + shift, + floor - 1 - row, + x + shift + bookWidth - 1, + floor - 1 - row, + mix(color, '#000000', 0.12 * (x % 3)), ); - if (bookHeight >= 7 && bookWidth >= 3) - c.rect(x, floor - 3, x + bookWidth - 1, floor - 3, mix(color, '#000000', 0.35)); + } + 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'); + } } - c.rect(0, 0, 0, height - 1, wood); - c.rect(width - 1, 0, width - 1, height - 1, wood); + 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(); } diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts index 53e4544..3cbafe6 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -6,17 +6,37 @@ 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, the shape of a half block. */ + density?: { x: number; y: number }; } export type PictureMode = 'color' | 'tone'; // Light-on-dark density order, measured from JetBrains Mono ink coverage. const TONE_RAMP = ' .:-=+*#%@'; +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)[][]; export function bitmapSize(bitmap: Bitmap): { cols: number; rows: number } { + const { x, y } = bitmap.density ?? HALF_BLOCK_DENSITY; return { - cols: Math.max(0, ...bitmap.rows.map((row) => row.length)), - rows: Math.ceil(bitmap.rows.length / 2), + cols: Math.ceil(Math.max(0, ...bitmap.rows.map((row) => row.length)) / x), + rows: Math.ceil(bitmap.rows.length / y), }; } @@ -24,51 +44,114 @@ export function pictureMode(): PictureMode { return getCapabilities().color && chalk.level > 0 && useUnicodeIcons() ? 'color' : 'tone'; } -function rgb(hex: string): [number, number, number] { - return [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)) as [ - number, - number, - number, - ]; +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(hex: string): number { - const [r, g, b] = rgb(hex); - return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +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 pixel(bitmap: Bitmap, x: number, y: number): string | undefined { +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]; - return symbol === undefined || symbol === ' ' ? undefined : bitmap.palette[symbol]; + 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(top: string | undefined, bottom: string | undefined): string { +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 (top === bottom && top !== undefined) return chalk.hex(top)('█'); - if (bottom === undefined) return chalk.hex(top ?? '#000000')('▀'); - if (top === undefined) return chalk.hex(bottom)('▄'); - return chalk.hex(top).bgHex(bottom)('▀'); -} - -function toneCell( - top: string | undefined, - bottom: string | undefined, - color: boolean, - gamma: number, -): string { - const colors = [top, bottom].filter((value): value is string => value !== undefined); - if (colors.length === 0) return ' '; - const average = Math.pow(colors.reduce((sum, value) => sum + luminance(value), 0) / 2, gamma); + 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))('▀'); +} + +/** Splits the eight dots of a cell into two colours: the lit dots and the background behind them. */ +function brailleCell(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 ' '; + + let dark = present.reduce((best, pixel) => (luminance(pixel) < luminance(best) ? pixel : best)); + let light = present.reduce((best, pixel) => (luminance(pixel) > luminance(best) ? pixel : best)); + let lit = grid.map(() => false); + for (let round = 0; round < 3; round++) { + lit = grid.map( + (pixel) => pixel !== undefined && distance(pixel, light) < distance(pixel, dark), + ); + const lights = solid(grid.filter((_, index) => lit[index] === true)); + const darks = solid(grid.filter((_, index) => lit[index] === false)); + if (lights.length > 0) light = average(lights); + if (darks.length > 0) dark = average(darks); + } + 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]); + const hasGap = grid.some((pixel) => pixel === undefined); + 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); +} + +function toneCell(pixels: Pixels, color: boolean, gamma: number): string { + const all = pixels.flat(); + const present = solid(all); + if (present.length === 0) return ' '; + const level = Math.pow( + present.reduce((sum, pixel) => sum + luminance(pixel), 0) / all.length, + gamma, + ); const char = - TONE_RAMP[Math.min(TONE_RAMP.length - 1, Math.round(average * (TONE_RAMP.length - 1)))] ?? ' '; + TONE_RAMP[Math.min(TONE_RAMP.length - 1, Math.round(level * (TONE_RAMP.length - 1)))] ?? ' '; if (!color || char === ' ') return char; - const brightest = colors.reduce((best, value) => - luminance(value) > luminance(best) ? value : best, + const brightest = present.reduce((best, pixel) => + luminance(pixel) > luminance(best) ? pixel : best, ); - return chalk.hex(brightest)(char); + return chalk.hex(hex(brightest))(char); } -/** Two pixel rows share one terminal row, matching the 1:2 shape of a cell. */ +/** + * Draws a bitmap with one terminal cell per block of pixels: Braille dots for fine 2 × 4 bitmaps, + * half blocks for chunky 1 × 2 ones, or ASCII density when colour or Unicode is unavailable. + */ export function renderBitmap( bitmap: Bitmap, mode: PictureMode = pictureMode(), @@ -77,12 +160,13 @@ export function renderBitmap( ): string[] { const { cols, rows } = bitmapSize(bitmap); const color = getCapabilities().color && chalk.level > 0; + const fine = (bitmap.density?.y ?? 2) >= BRAILLE_ROWS; return Array.from({ length: rows }, (_, row) => { let line = ''; - for (let x = 0; x < cols; x++) { - const top = pixel(bitmap, x, row * 2); - const bottom = pixel(bitmap, x, row * 2 + 1); - line += mode === 'color' ? halfBlockCell(top, bottom) : toneCell(top, bottom, color, gamma); + for (let col = 0; col < cols; col++) { + const pixels = cellPixels(bitmap, col, row); + if (mode === 'tone') line += toneCell(pixels, color, gamma); + else line += fine ? brailleCell(pixels) : halfBlockCell(pixels); } return mode === 'tone' ? line.replace(/\s+$/, '') : line; }); From d8f3188bbcc34d93395cbb3271ee9653269e7ab6 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 16:50:26 +0800 Subject: [PATCH 04/15] feat(tui): smooth flat areas, edges, and slopes in fine pictures --- src/art/scene.ts | 29 ++++++++++++++ src/core/pixel-art.ts | 90 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/art/scene.ts b/src/art/scene.ts index be514f7..81a2b90 100644 --- a/src/art/scene.ts +++ b/src/art/scene.ts @@ -67,7 +67,36 @@ class Canvas { 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) => diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts index 3cbafe6..a631ef6 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -100,8 +100,48 @@ function halfBlockCell(pixels: Pixels): string { return chalk.hex(hex(top)).bgHex(hex(bottom))('▀'); } -/** Splits the eight dots of a cell into two colours: the lit dots and the background behind them. */ -function brailleCell(pixels: Pixels): string { +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))]; @@ -109,23 +149,43 @@ function brailleCell(pixels: Pixels): string { }); 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)))('█'); + } - let dark = present.reduce((best, pixel) => (luminance(pixel) < luminance(best) ? pixel : best)); - let light = present.reduce((best, pixel) => (luminance(pixel) > luminance(best) ? pixel : best)); - let lit = grid.map(() => false); - for (let round = 0; round < 3; round++) { - lit = grid.map( - (pixel) => pixel !== undefined && distance(pixel, light) < distance(pixel, dark), - ); - const lights = solid(grid.filter((_, index) => lit[index] === true)); - const darks = solid(grid.filter((_, index) => lit[index] === false)); - if (lights.length > 0) light = average(lights); - if (darks.length > 0) dark = average(darks); + 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]); - const hasGap = grid.some((pixel) => pixel === undefined); 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); @@ -166,7 +226,7 @@ export function renderBitmap( for (let col = 0; col < cols; col++) { const pixels = cellPixels(bitmap, col, row); if (mode === 'tone') line += toneCell(pixels, color, gamma); - else line += fine ? brailleCell(pixels) : halfBlockCell(pixels); + else line += fine ? fineCell(pixels) : halfBlockCell(pixels); } return mode === 'tone' ? line.replace(/\s+$/, '') : line; }); From 4c09454f595a1be0dc25da68e49982b224bef400 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 17:07:20 +0800 Subject: [PATCH 05/15] feat: draw pictures as ASCII art with glyph matching --- scripts/generate-glyphs.mjs | 55 ++++++ src/art/glyphs.ts | 101 ++++++++++ src/art/scene.ts | 2 +- src/core/pixel-art.test.ts | 10 +- src/core/pixel-art.ts | 368 ++++++++++++++++++++---------------- 5 files changed, 363 insertions(+), 173 deletions(-) create mode 100644 scripts/generate-glyphs.mjs create mode 100644 src/art/glyphs.ts 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/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/scene.ts b/src/art/scene.ts index 81a2b90..90c5914 100644 --- a/src/art/scene.ts +++ b/src/art/scene.ts @@ -115,7 +115,7 @@ class Canvas { }) .join(''), ); - return { palette, rows, density: FINE }; + return { palette, rows, density: FINE, smooth: true }; } } diff --git a/src/core/pixel-art.test.ts b/src/core/pixel-art.test.ts index cb18579..f52666f 100644 --- a/src/core/pixel-art.test.ts +++ b/src/core/pixel-art.test.ts @@ -15,7 +15,7 @@ describe('renderBitmap', () => { chalk.level = level; }); - it('packs two pixel rows into one terminal row', () => { + it('maps two pixel rows to one terminal row', () => { expect(bitmapSize(sample)).toEqual({ cols: 4, rows: 2 }); expect(renderBitmap(sample, 'tone')).toHaveLength(2); }); @@ -27,12 +27,12 @@ describe('renderBitmap', () => { expect(lines[0]?.[2]).toBe(' '); }); - it('uses half blocks with foreground and background colours in colour mode', () => { + it('draws colour mode as ASCII characters on tinted backgrounds', () => { chalk.level = 3; const [first] = renderBitmap(sample, 'color'); - expect(first).toContain('\x1b[38;2;255;255;255m'); - expect(first).toContain('\x1b[48;2;0;0;0m'); - expect(stripAnsi(first ?? '')).toContain('▀'); + 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('emits no escape sequences when colour is off', () => { diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts index a631ef6..90674fd 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -1,216 +1,252 @@ import chalk from 'chalk'; +import { GLYPHS, GLYPH_COLS, GLYPH_ROWS } from '../art/glyphs.js'; import { getCapabilities } from './capabilities.js'; -import { useUnicodeIcons } from './icons.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, the shape of a half block. */ + /** 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; } export type PictureMode = 'color' | 'tone'; -// Light-on-dark density order, measured from JetBrains Mono ink coverage. -const TONE_RAMP = ' .:-=+*#%@'; -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], -]; +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; +// How strongly the character density follows the brightness of a smooth area. +const TONE_WEIGHT = 0.02; +// Smallest difference between a cell's text and background colours, so the character stays visible. +const MIN_CONTRAST = 30; -type Rgb = [number, number, number]; -type Pixels = (Rgb | undefined)[][]; +interface Glyph { + char: string; + cover: Float32Array; + ink: number; +} + +// Punctuation and shapes only: letters and digits read as noise inside a picture. +const ART_CHARS = ' .,\'`:;-_~=+*^"|/\\()[]{}<>!?#%&@$oxOX'; + +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)); export function bitmapSize(bitmap: Bitmap): { cols: number; rows: number } { - const { x, y } = bitmap.density ?? HALF_BLOCK_DENSITY; + 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), }; } +/** Colour when the terminal shows it; otherwise the same picture in plain characters. */ export function pictureMode(): PictureMode { - return getCapabilities().color && chalk.level > 0 && useUnicodeIcons() ? 'color' : 'tone'; + return getCapabilities().color && chalk.level > 0 ? 'color' : 'tone'; } -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('')}`; -} +type Rgb = [number, number, number]; -function luminance(color: Rgb): number { - return (0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]) / 255; +interface Cell { + r: Float32Array; + g: Float32Array; + b: Float32Array; + filled: number; } -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; -} +const parsed = new Map(); -function solid(pixels: readonly (Rgb | undefined)[]): Rgb[] { - return pixels.filter((pixel): pixel is Rgb => pixel !== undefined); +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 pixelAt(bitmap: Bitmap, x: number, y: number): Rgb | undefined { +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 : 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)), - ); + return color === undefined ? undefined : rgbOf(color); } -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))('▀'); +/** 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]; } -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); +/** 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 { light, dark, lit }; + 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))); + /** - * 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. + * Picks the character, text colour, and background colour that best reproduce the cell: edges pick characters + * that follow them, smooth areas pick a density that follows their brightness, and the colours carry the hue. */ -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)))('█'); +function colorCell(cell: Cell): string { + const { r, g, b } = cell; + let sr = 0; + let sg = 0; + let sb = 0; + let squares = 0; + for (let index = 0; index < SAMPLES; index++) { + const pr = r[index] ?? 0; + const pg = g[index] ?? 0; + const pb = b[index] ?? 0; + sr += pr; + sg += pg; + sb += pb; + squares += pr * pr + pg * pg + pb * pb; } + const meanSquare = (sr * sr + sg * sg + sb * sb) / SAMPLES; + const target = 0.015 + 0.2 * luma(sr / SAMPLES, sg / SAMPLES, sb / SAMPLES); - 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); + let best: Glyph | undefined; + let bestCost = Number.POSITIVE_INFINITY; + let bestInk: Rgb = [0, 0, 0]; + for (const glyph of GLYPH_TABLE) { + const { cover, ink } = glyph; + let dr = 0; + let dg = 0; + let db = 0; + for (let index = 0; index < SAMPLES; index++) { + const weight = cover[index] ?? 0; + dr += weight * (r[index] ?? 0); + dg += weight * (g[index] ?? 0); + db += weight * (b[index] ?? 0); + } + const rest = SAMPLES - ink; + let cost = squares - meanSquare; + if (ink > 1e-6 && rest > 1e-6) { + cost -= + (dr * dr + dg * dg + db * db) / ink + + ((sr - dr) ** 2 + (sg - dg) ** 2 + (sb - db) ** 2) / rest - + meanSquare; + } + cost += TONE_WEIGHT * SAMPLES * 3 * (255 * (ink / SAMPLES - target)) ** 2; + if (cost < bestCost) { + bestCost = cost; + best = glyph; + bestInk = [dr, dg, db]; } } + if (!best) return ' '; - 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); -} - -function toneCell(pixels: Pixels, color: boolean, gamma: number): string { - const all = pixels.flat(); - const present = solid(all); - if (present.length === 0) return ' '; - const level = Math.pow( - present.reduce((sum, pixel) => sum + luminance(pixel), 0) / all.length, - gamma, + const coverage = best.ink / SAMPLES; + const rest = SAMPLES - best.ink; + const sums: Rgb = [sr, sg, sb]; + const text = bestInk.map((value, channel) => + best.ink > 1e-6 ? value / best.ink : (sums[channel] ?? 0) / SAMPLES, ); - const char = - TONE_RAMP[Math.min(TONE_RAMP.length - 1, Math.round(level * (TONE_RAMP.length - 1)))] ?? ' '; - if (!color || char === ' ') return char; - const brightest = present.reduce((best, pixel) => - luminance(pixel) > luminance(best) ? pixel : best, + const back = sums.map((value, channel) => + rest > 1e-6 ? (value - (bestInk[channel] ?? 0)) / rest : value / SAMPLES, ); - return chalk.hex(hex(brightest))(char); + const gap = + luma(text[0] ?? 0, text[1] ?? 0, text[2] ?? 0) - luma(back[0] ?? 0, back[1] ?? 0, back[2] ?? 0); + const lift = Math.max(0, MIN_CONTRAST - Math.abs(gap) * 255) * (gap >= 0 ? 1 : -1); + const fg = text.map((value) => clamp(value + lift * (1 - coverage))); + const bg = back.map((value) => clamp(value - lift * coverage)); + const style = chalk + .rgb(fg[0] ?? 0, fg[1] ?? 0, fg[2] ?? 0) + .bgRgb(bg[0] ?? 0, bg[1] ?? 0, bg[2] ?? 0); + return style(best.char); +} + +/** Plain characters only: the glyph whose ink best matches the brightness of each part of the cell. */ +function toneCell(cell: Cell, gamma: number): string { + const bright = new Float32Array(SAMPLES); + let total = 0; + for (let index = 0; index < SAMPLES; index++) { + const value = 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.02) return ' '; + let best = ' '; + let bestCost = Number.POSITIVE_INFINITY; + for (const glyph of GLYPH_TABLE) { + let cost = 0; + for (let index = 0; index < SAMPLES; index++) + cost += ((bright[index] ?? 0) - (glyph.cover[index] ?? 0)) ** 2; + if (cost < bestCost) { + bestCost = cost; + best = glyph.char; + } + } + return best; } /** - * Draws a bitmap with one terminal cell per block of pixels: Braille dots for fine 2 × 4 bitmaps, - * half blocks for chunky 1 × 2 ones, or ASCII density when colour or Unicode is unavailable. + * 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. */ export function renderBitmap( bitmap: Bitmap, @@ -219,14 +255,12 @@ export function renderBitmap( gamma = 0.7, ): string[] { const { cols, rows } = bitmapSize(bitmap); - const color = getCapabilities().color && chalk.level > 0; - 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); - if (mode === 'tone') line += toneCell(pixels, color, gamma); - else line += fine ? fineCell(pixels) : halfBlockCell(pixels); + const cell = sampleCell(bitmap, col, row); + if (cell.filled === 0) line += ' '; + else line += mode === 'color' ? colorCell(cell) : toneCell(cell, gamma); } return mode === 'tone' ? line.replace(/\s+$/, '') : line; }); From ff1eabbad6d8bedec115ef116f3fdb6179b70e3a Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 17:14:18 +0800 Subject: [PATCH 06/15] fix: draw ASCII pictures as colored characters on the terminal background --- src/app/ambient.ts | 2 +- src/core/pixel-art.ts | 133 +++++++++++++++--------------------------- 2 files changed, 49 insertions(+), 86 deletions(-) diff --git a/src/app/ambient.ts b/src/app/ambient.ts index 262fbb2..43f9fe2 100644 --- a/src/app/ambient.ts +++ b/src/app/ambient.ts @@ -17,7 +17,7 @@ const MIN_COLS = 30; const MIN_ROWS = 7; const MIN_PANORAMA_ROWS = 8; const MAX_COLS = 130; -const SCENE_GAMMA = 1.8; +const SCENE_GAMMA = 0.8; const FINE = { x: 2, y: 4 } as const; const cache = new Map(); diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts index 90674fd..7408e35 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -18,10 +18,12 @@ 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; -// How strongly the character density follows the brightness of a smooth area. -const TONE_WEIGHT = 0.02; -// Smallest difference between a cell's text and background colours, so the character stays visible. -const MIN_CONTRAST = 30; +// 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; @@ -30,7 +32,7 @@ interface Glyph { } // Punctuation and shapes only: letters and digits read as noise inside a picture. -const ART_CHARS = ' .,\'`:;-_~=+*^"|/\\()[]{}<>!?#%&@$oxOX'; +const ART_CHARS = ' .,:;-_~=+*^"|/\\()[]{}<>?#%&@$'; const GLYPH_TABLE: readonly Glyph[] = GLYPHS.map((hex, index) => { const cover = Float32Array.from( @@ -147,101 +149,62 @@ 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))); -/** - * Picks the character, text colour, and background colour that best reproduce the cell: edges pick characters - * that follow them, smooth areas pick a density that follows their brightness, and the colours carry the hue. - */ -function colorCell(cell: Cell): string { - const { r, g, b } = cell; - let sr = 0; - let sg = 0; - let sb = 0; - let squares = 0; +/** The glyph whose ink best matches the brightness of each part of the cell. */ +function pickGlyph(cell: Cell, gamma: number): Glyph | undefined { + const bright = new Float32Array(SAMPLES); + let total = 0; for (let index = 0; index < SAMPLES; index++) { - const pr = r[index] ?? 0; - const pg = g[index] ?? 0; - const pb = b[index] ?? 0; - sr += pr; - sg += pg; - sb += pb; - squares += pr * pr + pg * pg + pb * pb; + 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; } - const meanSquare = (sr * sr + sg * sg + sb * sb) / SAMPLES; - const target = 0.015 + 0.2 * luma(sr / SAMPLES, sg / SAMPLES, sb / SAMPLES); - + if (total / SAMPLES < 0.004) return undefined; + const mean = total / SAMPLES; let best: Glyph | undefined; let bestCost = Number.POSITIVE_INFINITY; - let bestInk: Rgb = [0, 0, 0]; for (const glyph of GLYPH_TABLE) { - const { cover, ink } = glyph; - let dr = 0; - let dg = 0; - let db = 0; - for (let index = 0; index < SAMPLES; index++) { - const weight = cover[index] ?? 0; - dr += weight * (r[index] ?? 0); - dg += weight * (g[index] ?? 0); - db += weight * (b[index] ?? 0); - } - const rest = SAMPLES - ink; - let cost = squares - meanSquare; - if (ink > 1e-6 && rest > 1e-6) { - cost -= - (dr * dr + dg * dg + db * db) / ink + - ((sr - dr) ** 2 + (sg - dg) ** 2 + (sb - db) ** 2) / rest - - meanSquare; - } - cost += TONE_WEIGHT * SAMPLES * 3 * (255 * (ink / SAMPLES - target)) ** 2; + const glyphMean = glyph.ink / SAMPLES; + let cost = TONE_WEIGHT * SAMPLES * (mean - glyphMean) ** 2; + for (let index = 0; index < SAMPLES; index++) + cost += ((bright[index] ?? 0) - mean - ((glyph.cover[index] ?? 0) - glyphMean)) ** 2; if (cost < bestCost) { bestCost = cost; best = glyph; - bestInk = [dr, dg, db]; } } - if (!best) return ' '; - - const coverage = best.ink / SAMPLES; - const rest = SAMPLES - best.ink; - const sums: Rgb = [sr, sg, sb]; - const text = bestInk.map((value, channel) => - best.ink > 1e-6 ? value / best.ink : (sums[channel] ?? 0) / SAMPLES, - ); - const back = sums.map((value, channel) => - rest > 1e-6 ? (value - (bestInk[channel] ?? 0)) / rest : value / SAMPLES, - ); - const gap = - luma(text[0] ?? 0, text[1] ?? 0, text[2] ?? 0) - luma(back[0] ?? 0, back[1] ?? 0, back[2] ?? 0); - const lift = Math.max(0, MIN_CONTRAST - Math.abs(gap) * 255) * (gap >= 0 ? 1 : -1); - const fg = text.map((value) => clamp(value + lift * (1 - coverage))); - const bg = back.map((value) => clamp(value - lift * coverage)); - const style = chalk - .rgb(fg[0] ?? 0, fg[1] ?? 0, fg[2] ?? 0) - .bgRgb(bg[0] ?? 0, bg[1] ?? 0, bg[2] ?? 0); - return style(best.char); + return best; } -/** Plain characters only: the glyph whose ink best matches the brightness of each part of the cell. */ +/** Plain characters only. */ function toneCell(cell: Cell, gamma: number): string { - const bright = new Float32Array(SAMPLES); - let total = 0; + return pickGlyph(cell, gamma)?.char ?? ' '; +} + +/** + * 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): string { + const glyph = pickGlyph(cell, gamma); + if (!glyph || glyph.char === ' ') return ' '; + const mean: Rgb = [0, 0, 0]; + const ink: Rgb = [0, 0, 0]; for (let index = 0; index < SAMPLES; index++) { - const value = 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.02) return ' '; - let best = ' '; - let bestCost = Number.POSITIVE_INFINITY; - for (const glyph of GLYPH_TABLE) { - let cost = 0; - for (let index = 0; index < SAMPLES; index++) - cost += ((bright[index] ?? 0) - (glyph.cover[index] ?? 0)) ** 2; - if (cost < bestCost) { - bestCost = cost; - best = glyph.char; + 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; } } - return best; + 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 bg = mean.map((value) => clamp(value * TINT)); + return chalk.rgb(fg[0] ?? 0, fg[1] ?? 0, fg[2] ?? 0).bgRgb(bg[0] ?? 0, bg[1] ?? 0, bg[2] ?? 0)( + glyph.char, + ); } /** @@ -260,7 +223,7 @@ export function renderBitmap( for (let col = 0; col < cols; col++) { const cell = sampleCell(bitmap, col, row); if (cell.filled === 0) line += ' '; - else line += mode === 'color' ? colorCell(cell) : toneCell(cell, gamma); + else line += mode === 'color' ? colorCell(cell, gamma) : toneCell(cell, gamma); } return mode === 'tone' ? line.replace(/\s+$/, '') : line; }); From 759cde0b9713d23765c3356bccc68d6763ca5f8d Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 17:23:25 +0800 Subject: [PATCH 07/15] feat: tier pictures by terminal capability with an opt-in fine mode --- src/app/ambient.ts | 2 + src/app/views/settings-render.ts | 3 +- src/app/views/settings.test.ts | 17 ++- src/app/views/settings.ts | 34 +++++- src/config/preferences.ts | 13 ++- src/core/pixel-art-fine.ts | 180 +++++++++++++++++++++++++++++++ src/core/pixel-art.test.ts | 14 +++ src/core/pixel-art.ts | 19 ++-- src/i18n/index.ts | 4 + src/i18n/locales/en.json | 6 +- src/i18n/locales/zh.json | 4 + 11 files changed, 283 insertions(+), 13 deletions(-) create mode 100644 src/core/pixel-art-fine.ts diff --git a/src/app/ambient.ts b/src/app/ambient.ts index 43f9fe2..8d040eb 100644 --- a/src/app/ambient.ts +++ b/src/app/ambient.ts @@ -1,5 +1,6 @@ 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 { space } from '../core/theme.js'; @@ -44,6 +45,7 @@ function scene(spec: AmbientSpec, cols: number, rows: number, now: Date): string 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 }; diff --git a/src/app/views/settings-render.ts b/src/app/views/settings-render.ts index ee0c8cd..88b1f13 100644 --- a/src/app/views/settings-render.ts +++ b/src/app/views/settings-render.ts @@ -5,7 +5,7 @@ import { renderBitmap } from '../../core/pixel-art.js'; import { scene } from '../../art/bitmaps.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; -export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'reset' | 'about'; +export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'picture' | 'reset' | 'about'; export interface SettingsViewState { mode: SettingsMode; @@ -43,6 +43,7 @@ export function renderSettings( case 'language': case 'icon': case 'color': + case 'picture': case 'reset': return state.subField?.render(bodyRows, cols) ?? []; case 'about': { diff --git a/src/app/views/settings.test.ts b/src/app/views/settings.test.ts index 810d3d8..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'); @@ -140,6 +140,19 @@ describe('settingsView', () => { 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'); diff --git a/src/app/views/settings.ts b/src/app/views/settings.ts index 2782ad8..6e6936e 100644 --- a/src/app/views/settings.ts +++ b/src/app/views/settings.ts @@ -7,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'; @@ -30,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, @@ -38,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), @@ -61,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 }, ]; @@ -178,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', @@ -250,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/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/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 index f52666f..13fc619 100644 --- a/src/core/pixel-art.test.ts +++ b/src/core/pixel-art.test.ts @@ -35,6 +35,20 @@ describe('renderBitmap', () => { 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('emits no escape sequences when colour is off', () => { chalk.level = 0; for (const mode of ['tone', 'color'] as const) { diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts index 7408e35..b8eacf3 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -1,6 +1,8 @@ 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. */ @@ -12,7 +14,8 @@ export interface Bitmap { smooth?: boolean; } -export type PictureMode = 'color' | 'tone'; +/** 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; @@ -54,9 +57,12 @@ export function bitmapSize(bitmap: Bitmap): { cols: number; rows: number } { }; } -/** Colour when the terminal shows it; otherwise the same picture in plain characters. */ +/** The finest picture the terminal can show and the user allows, falling back to plain ASCII. */ export function pictureMode(): PictureMode { - return getCapabilities().color && chalk.level > 0 ? 'color' : 'tone'; + 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]; @@ -201,10 +207,10 @@ function colorCell(cell: Cell, gamma: number): string { 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(glyph.char); const bg = mean.map((value) => clamp(value * TINT)); - return chalk.rgb(fg[0] ?? 0, fg[1] ?? 0, fg[2] ?? 0).bgRgb(bg[0] ?? 0, bg[1] ?? 0, bg[2] ?? 0)( - glyph.char, - ); + return text.bgRgb(bg[0] ?? 0, bg[1] ?? 0, bg[2] ?? 0)(glyph.char); } /** @@ -218,6 +224,7 @@ export function renderBitmap( gamma = 0.7, ): string[] { const { cols, rows } = bitmapSize(bitmap); + if (mode === 'fine') return renderFine(bitmap, cols, rows); return Array.from({ length: rows }, (_, row) => { let line = ''; for (let col = 0; col < cols; col++) { 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": "主题设置已重置", From 66b7745b7ea6df0df90742f01bb877b39c050ed4 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 17:28:55 +0800 Subject: [PATCH 08/15] feat: add shaded planets beside pages and drop the static About scene --- scripts/generate-art.mjs | 69 -------------------- src/app/ambient.ts | 12 +++- src/app/views/settings-render.ts | 7 +- src/art/bitmaps.ts | 45 ------------- src/art/planet.test.ts | 52 +++++++++++++++ src/art/planet.ts | 106 +++++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 119 deletions(-) create mode 100644 src/art/planet.test.ts create mode 100644 src/art/planet.ts diff --git a/scripts/generate-art.mjs b/scripts/generate-art.mjs index bcee529..374f5b4 100644 --- a/scripts/generate-art.mjs +++ b/scripts/generate-art.mjs @@ -139,73 +139,6 @@ class Canvas { } } -function seeded(seed) { - let state = seed; - return () => (state = (state * 16807) % 2147483647) / 2147483647; -} - -function scene(width, height) { - const c = new Canvas(width, height); - const k = width / 60; - const horizon = Math.round(height * 0.55); - const sky = ['#081a33', '#0b2a52', '#124689', '#1d5fa8', '#5b4b9a', '#a0508c', '#e0705f']; - const bandRows = Math.max(1, Math.round(horizon / sky.length)); - for (let y = 0; y < horizon; y++) - c.rect(0, y, width - 1, y, sky[Math.min(sky.length - 1, Math.floor(y / bandRows))]); - const random = seeded(7); - for (let i = 0; i < Math.round(16 * k); i++) - c.set(Math.floor(random() * width), Math.floor(random() * horizon * 0.4), '#f8fafc'); - const cx = width / 2; - c.circle(cx, horizon, 9 * k, (x, y) => (y < horizon ? '#e8885a' : null)); - c.circle(cx, horizon, 6.5 * k, (x, y) => (y < horizon ? '#ffd27a' : null)); - const cloud = [ - [6, 0.38, 18, '#7c6bc4'], - [38, 0.3, 16, '#7c6bc4'], - [12, 0.62, 22, '#d98aa8'], - [34, 0.7, 20, '#d98aa8'], - ]; - cloud.forEach(([x, row, w, color]) => - c.rect( - Math.round(x * k), - Math.round(horizon * row), - Math.round((x + w) * k) - 1, - Math.round(horizon * row), - color, - ), - ); - const sea = ['#3a3f7a', '#2b3768', '#1f2c55', '#16224a', '#0f1a3a']; - const seaBand = Math.max(1, Math.round((height - horizon) / sea.length)); - for (let y = horizon; y < height; y++) - c.rect(0, y, width - 1, y, sea[Math.min(sea.length - 1, Math.floor((y - horizon) / seaBand))]); - for (let y = horizon + 1; y < height - 2; y++) { - const half = Math.max(1, Math.round((10 - (y - horizon) * 0.8) * k)); - if ((y - horizon) % 2 === 1) - c.rect( - Math.round(cx - half), - y, - Math.round(cx + half), - y, - y < horizon + 5 ? '#f0b266' : '#b9803f', - ); - } - const hill = '#050d1c'; - const bump = (x0, x1, peak, mid, spread) => { - for (let x = Math.round(x0 * k); x < Math.round(x1 * k); x++) { - const h = Math.round(peak * Math.exp(-Math.pow((x - mid * k) / (spread * k), 2)) + 1); - c.rect(x, horizon - h + 1, x, horizon, hill); - } - }; - bump(0, 22, Math.round(horizon * 0.3), 9, 7); - bump(40, 60, Math.round(horizon * 0.22), 52, 6); - const tx = Math.round(51 * k); - c.rect(tx, horizon - 9, tx + 1, horizon - 5, hill); - c.rect(tx - 1, horizon - 5, tx + 2, horizon - 5, hill); - c.set(tx, horizon - 10, '#ffe08a'); - c.set(tx + 1, horizon - 10, '#ffe08a'); - c.rect(0, height - 2, width - 1, height - 1, hill); - return c.toBitmap(); -} - const ICON = { navy: '#102a52', blue: '#0ea5e9', @@ -334,8 +267,6 @@ const bitmaps = [ "import type { Bitmap } from '../core/pixel-art.js';", '', '// Generated by scripts/generate-art.mjs; edit the script, not this file.', - '', - emitBitmap('scene', scene(44, 18)), ]; const iconSet = icons(); for (const [name, bitmap] of Object.entries(iconSet)) diff --git a/src/app/ambient.ts b/src/app/ambient.ts index 8d040eb..fc739cb 100644 --- a/src/app/ambient.ts +++ b/src/app/ambient.ts @@ -3,6 +3,7 @@ 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 { gasGiantLines, moonLines, planetSize } from '../art/planet.js'; import { space } from '../core/theme.js'; /** What a page draws in the space its content leaves empty. */ @@ -11,6 +12,7 @@ export type AmbientSpec = | { kind: 'shelf' } | { kind: 'mountain'; progress: number; weeks: number } | { kind: 'emblem' } + | { kind: 'planet'; style: 'moon' | 'gas' } | { kind: 'icon'; icon: Bitmap }; const GAP = 4; @@ -59,8 +61,14 @@ function scene(spec: AmbientSpec, cols: number, rows: number, now: Date): string }); } -function compact(spec: AmbientSpec, cols: number, rows: number): string[] | undefined { +function compact(spec: AmbientSpec, cols: number, rows: number, now: Date): string[] | undefined { if (spec.kind === 'emblem') return emblemLines(cols, rows); + if (spec.kind === 'sky') + 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)), @@ -94,7 +102,7 @@ export function withAmbient( 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); + 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) => { diff --git a/src/app/views/settings-render.ts b/src/app/views/settings-render.ts index 88b1f13..015fa81 100644 --- a/src/app/views/settings-render.ts +++ b/src/app/views/settings-render.ts @@ -1,10 +1,11 @@ import { space, type } from '../../core/theme.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; import { pictureBeside } from '../../core/components/picture.js'; -import { renderBitmap } from '../../core/pixel-art.js'; -import { scene } from '../../art/bitmaps.js'; +import { gasGiantLines } from '../../art/planet.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; +const ABOUT_PLANET_COLS = 28; + export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'picture' | 'reset' | 'about'; export interface SettingsViewState { @@ -54,7 +55,7 @@ export function renderSettings( ), '', ]; - const picture = renderBitmap(scene); + 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; diff --git a/src/art/bitmaps.ts b/src/art/bitmaps.ts index b0e4821..a6a13f3 100644 --- a/src/art/bitmaps.ts +++ b/src/art/bitmaps.ts @@ -1,51 +1,6 @@ import type { Bitmap } from '../core/pixel-art.js'; // Generated by scripts/generate-art.mjs; edit the script, not this file. - -export const scene: Bitmap = { - palette: { - '0': '#081a33', - '1': '#f8fafc', - '2': '#ffe08a', - '3': '#0b2a52', - '4': '#050d1c', - '5': '#124689', - '6': '#1d5fa8', - '7': '#7c6bc4', - '8': '#5b4b9a', - '9': '#e8885a', - a: '#a0508c', - b: '#e0705f', - c: '#d98aa8', - d: '#ffd27a', - e: '#3a3f7a', - f: '#f0b266', - g: '#2b3768', - h: '#1f2c55', - i: '#b9803f', - }, - rows: [ - '00000000000010000000000000000010000102200000', - '33333333333333333333333333333313333334433333', - '55555155555555551555555555555515115554455555', - '16666666666666166666666666667777777774471666', - '88887777777777777788999998888888888884488888', - 'aaaaaaaaaaaaaaaaaa999999999aaaaaaaaa4444aaaa', - 'bbbbbbbbbcccccccccccccccc999bbbbbbbbbbbbbbbb', - 'bbbbb4444bbbbbbbb99ddddddcccccccccccccccbbbb', - 'bbb44444444bbbbb99ddddddddd99bbbbbbb44444bbb', - '44444444444444bb99ddddddddd99bbbb44444444444', - '4444444444444444eeeeeeeeeeeee444444444444444', - 'eeeeeeeeeeeeeeefffffffffffffffeeeeeeeeeeeeee', - 'gggggggggggggggggggggggggggggggggggggggggggg', - 'ggggggggggggggggfffffffffffffggggggggggggggg', - 'hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh', - 'hhhhhhhhhhhhhhhhhhiiiiiiiiihhhhhhhhhhhhhhhhh', - '44444444444444444444444444444444444444444444', - '44444444444444444444444444444444444444444444', - ], -}; - export const eventIconDefault: Bitmap = { palette: { '0': '#94a3b8', '1': '#0ea5e9', '2': '#f1f5f9', '3': '#bae6fd', '4': '#f59e0b' }, rows: [ diff --git a/src/art/planet.test.ts b/src/art/planet.test.ts new file mode 100644 index 0000000..5d2f5d8 --- /dev/null +++ b/src/art/planet.test.ts @@ -0,0 +1,52 @@ +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('declines when there is no room', () => { + expect(gasGiantLines(8)).toBeUndefined(); + expect(moonLines(8, 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..a822401 --- /dev/null +++ b/src/art/planet.ts @@ -0,0 +1,106 @@ +import chalk from 'chalk'; +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 = 12; +const MAX_COLS = 44; + +interface Sphere { + light: Vec; + /** Brightness multiplier at a point on the surface, in -1..1 sphere coordinates. */ + surface: (x: number, y: number) => number; + /** Colour of fully lit surface at that point. */ + color: (x: number, y: number) => Vec; +} + +const normalize = (v: Vec): Vec => { + const length = Math.hypot(...v); + return [v[0] / length, v[1] / length, v[2] / length]; +}; + +function drawSphere(cols: number, sphere: Sphere, mode: PictureMode): string[] { + 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 x = ((col + 0.5) / cols) * 2 - 1; + const y = ((row + 0.5) / rows) * 2 - 1; + const radius = x * x + y * y; + if (radius > 1) { + line += ' '; + continue; + } + const z = Math.sqrt(1 - radius); + const lit = Math.max(0, x * sphere.light[0] + y * sphere.light[1] + z * sphere.light[2]); + const level = Math.min(1, lit * sphere.surface(x, y) * (0.55 + 0.45 * z)); + if (level < 0.02) { + line += colour ? chalk.rgb(48, 54, 70)('.') : ' '; + continue; + } + const char = RAMP[Math.min(RAMP.length - 1, Math.floor(level * RAMP.length))] ?? ' '; + if (!colour) { + line += char; + continue; + } + const [r, g, b] = sphere.color(x, y); + const gain = 0.62 + 0.38 * level; + line += chalk.rgb(Math.round(r * gain), Math.round(g * gain), Math.round(b * gain))(char); + } + return line.replace(/\s+$/, ''); + }); +} + +const MARIA: readonly (readonly [number, number, number])[] = [ + [-0.3, -0.25, 0.28], + [0.25, -0.1, 0.22], + [-0.05, 0.4, 0.2], + [0.45, 0.35, 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()): string[] | undefined { + if (cols < MIN_COLS) return undefined; + const angle = 2 * Math.PI * moonPhase(now); + const light = normalize([Math.sin(angle), -0.12, -Math.cos(angle)]); + return drawSphere( + Math.min(cols, MAX_COLS), + { + light, + surface: (x, y) => { + const dark = MARIA.reduce( + (sum, [mx, my, size]) => + sum + Math.max(0, 1 - Math.hypot(x - mx, (y - my) * 1.1) / size) * 0.3, + 0, + ); + return 1 - Math.min(0.35, dark); + }, + color: () => [236, 238, 246], + }, + mode, + ); +} + +/** A banded gas giant lit from the upper left. */ +export function gasGiantLines(cols: number, mode = pictureMode()): string[] | undefined { + if (cols < MIN_COLS) return undefined; + return drawSphere( + Math.min(cols, MAX_COLS), + { + light: normalize([-0.6, -0.45, 0.66]), + surface: (x, y) => 0.88 + 0.12 * Math.sin(y * 11 + Math.sin(x * 3 + y * 2) * 0.9), + color: (_, y) => { + const band = 0.5 + 0.5 * Math.sin(y * 7 + 1); + return [236 - 40 * band, 176 - 30 * band, 120 + 40 * band]; + }, + }, + mode, + ); +} + +export const planetSize = (cols: number, rows: number): number => + Math.min(MAX_COLS, cols, rows * 2); From fcb7a88d4741a8d367981cdd90a75b3311eff507 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 17:44:23 +0800 Subject: [PATCH 09/15] feat: add moon and magnifier pictures and smooth half-block planets for the fine tier --- scripts/generate-art.mjs | 24 ------- src/art/bitmaps.ts | 36 ---------- src/art/motifs.test.ts | 32 +++++++++ src/art/motifs.ts | 105 +++++++++++++++++++++++++++++ src/art/planet.test.ts | 9 +++ src/art/planet.ts | 96 +++++++++++++++++++++----- src/core/components/empty-state.ts | 7 +- 7 files changed, 228 insertions(+), 81 deletions(-) create mode 100644 src/art/motifs.test.ts create mode 100644 src/art/motifs.ts diff --git a/scripts/generate-art.mjs b/scripts/generate-art.mjs index 374f5b4..5f59614 100644 --- a/scripts/generate-art.mjs +++ b/scripts/generate-art.mjs @@ -233,27 +233,6 @@ function icons() { }; } -function emptyArt() { - const calendar = new Canvas(14, 12); - calendarPage(calendar, 1, 0, 12, 11, null); - calendar.rect(5, 6, 8, 7, '#e2e8f0'); - const search = new Canvas(14, 12); - search.circle( - 5.5, - 5, - 4.6, - (x, y, d) => (d < 3.2 ? (x + y < 9 ? '#e0f2fe' : '#7dd3fc') : ICON.cyan), - 0, - ); - search.circle(5.5, 5, 4.6, ICON.cyan, 3.2); - search.line(9, 8, 12, 11, ICON.amber); - search.line(10, 8, 13, 11, ICON.amber); - search.set(3, 3, ICON.white); - search.set(4, 3, ICON.white); - search.set(3, 4, ICON.white); - return { calendar: calendar.toBitmap(), search: search.toBitmap() }; -} - function emitBitmap(name, bitmap) { const palette = JSON.stringify(bitmap.palette) .replace(/"/g, "'") @@ -271,9 +250,6 @@ const bitmaps = [ const iconSet = icons(); for (const [name, bitmap] of Object.entries(iconSet)) bitmaps.push(emitBitmap(`eventIcon${name[0].toUpperCase()}${name.slice(1)}`, bitmap)); -const empty = emptyArt(); -for (const [name, bitmap] of Object.entries(empty)) - bitmaps.push(emitBitmap(`empty${name[0].toUpperCase()}${name.slice(1)}`, bitmap)); writeFileSync('src/art/bitmaps.ts', bitmaps.join('\n')); const large = 'src/logo/ca-dotmatrix-large.txt'; diff --git a/src/art/bitmaps.ts b/src/art/bitmaps.ts index a6a13f3..0dfd893 100644 --- a/src/art/bitmaps.ts +++ b/src/art/bitmaps.ts @@ -80,39 +80,3 @@ export const eventIconConference: Bitmap = { ' 000000 ', ], }; - -export const emptyCalendar: Bitmap = { - palette: { '0': '#94a3b8', '1': '#0ea5e9', '2': '#f1f5f9', '3': '#bae6fd', '4': '#e2e8f0' }, - rows: [ - ' 0 0 ', - ' 111111111111 ', - ' 111111111111 ', - ' 222222222222 ', - ' 223232323232 ', - ' 222222222222 ', - ' 223244443232 ', - ' 222244442222 ', - ' 223232323232 ', - ' 222222222222 ', - ' 223232323232 ', - ' 222222222222 ', - ], -}; - -export const emptySearch: Bitmap = { - palette: { '0': '#06b6d4', '1': '#e0f2fe', '2': '#f1f5f9', '3': '#7dd3fc', '4': '#f59e0b' }, - rows: [ - ' ', - ' 0000 ', - ' 001100 ', - ' 02213300 ', - ' 02133330 ', - ' 0013333300 ', - ' 03333330 ', - ' 00333300 ', - ' 00330044 ', - ' 0000 44 ', - ' 44 ', - ' 44', - ], -}; diff --git a/src/art/motifs.test.ts b/src/art/motifs.test.ts new file mode 100644 index 0000000..2b71145 --- /dev/null +++ b/src/art/motifs.test.ts @@ -0,0 +1,32 @@ +import chalk from 'chalk'; +import { describe, expect, it } from 'vitest'; +import { magnifier, moonSky } 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]*$/); + }); +}); diff --git a/src/art/motifs.ts b/src/art/motifs.ts new file mode 100644 index 0000000..10d1be2 --- /dev/null +++ b/src/art/motifs.ts @@ -0,0 +1,105 @@ +import chalk from 'chalk'; +import { moonLines, tint } from './planet.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+$/, ''); + }); +} diff --git a/src/art/planet.test.ts b/src/art/planet.test.ts index 5d2f5d8..a5f558a 100644 --- a/src/art/planet.test.ts +++ b/src/art/planet.test.ts @@ -44,6 +44,15 @@ describe('planets', () => { 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('declines when there is no room', () => { expect(gasGiantLines(8)).toBeUndefined(); expect(moonLines(8, fullMoon)).toBeUndefined(); diff --git a/src/art/planet.ts b/src/art/planet.ts index a822401..4d05681 100644 --- a/src/art/planet.ts +++ b/src/art/planet.ts @@ -17,39 +17,101 @@ interface Sphere { color: (x: number, y: number) => Vec; } +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 lit = Math.max(0, x * sphere.light[0] + y * sphere.light[1] + z * sphere.light[2]); + return { + level: Math.min(1, lit * sphere.surface(x, y) * (0.55 + 0.45 * z)), + color: sphere.color(x, y), + }; +} + +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 x = ((col + 0.5) / cols) * 2 - 1; - const y = ((row + 0.5) / rows) * 2 - 1; - const radius = x * x + y * y; - if (radius > 1) { + const shade = shadeAt(sphere, ((col + 0.5) / cols) * 2 - 1, ((row + 0.5) / rows) * 2 - 1); + if (!shade) { line += ' '; continue; } - const z = Math.sqrt(1 - radius); - const lit = Math.max(0, x * sphere.light[0] + y * sphere.light[1] + z * sphere.light[2]); - const level = Math.min(1, lit * sphere.surface(x, y) * (0.55 + 0.45 * z)); - if (level < 0.02) { - line += colour ? chalk.rgb(48, 54, 70)('.') : ' '; - continue; - } - const char = RAMP[Math.min(RAMP.length - 1, Math.floor(level * RAMP.length))] ?? ' '; - if (!colour) { - line += char; + if (shade.level < 0.02) { + line += colour ? chalk.rgb(...NIGHT)('.') : ' '; continue; } - const [r, g, b] = sphere.color(x, y); - const gain = 0.62 + 0.38 * level; - line += chalk.rgb(Math.round(r * gain), Math.round(g * gain), Math.round(b * gain))(char); + 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+$/, ''); }); diff --git a/src/core/components/empty-state.ts b/src/core/components/empty-state.ts index 6eb87f9..e2067cb 100644 --- a/src/core/components/empty-state.ts +++ b/src/core/components/empty-state.ts @@ -1,14 +1,13 @@ import { c, space, type } from '../theme.js'; import { padEndV, visualWidth, wrapAnsiWithIndent } from '../text.js'; -import { emptyCalendar, emptySearch } from '../../art/bitmaps.js'; -import { renderBitmap } from '../pixel-art.js'; +import { magnifier, moonSky } from '../../art/motifs.js'; const ART = { calendar: [' .-.-. ', ' | | ', " '---' "], search: [' .--. ', '( ) ', " '--'\\_"], } as const; -const PICTURE = { calendar: emptyCalendar, search: emptySearch } as const; +const PICTURE = { calendar: moonSky, search: magnifier } as const; export function emptyStateLines( kind: keyof typeof ART, @@ -17,7 +16,7 @@ export function emptyStateLines( rows = Number.POSITIVE_INFINITY, ): string[] { const text = wrapAnsiWithIndent(type.hint(label), cols, space.indent); - const picture = renderBitmap(PICTURE[kind]); + const picture = PICTURE[kind](); const lines = cols >= 44 && rows >= picture.length ? beside(picture, label, cols, false) : undefined; if (lines) return lines; From 2db3374ebb8c0dea63744d0ad4ff1e4318454ebc Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 18:11:33 +0800 Subject: [PATCH 10/15] feat: add loading orbit and dropped-signal pictures to everyday states --- src/app/views/docs-render.ts | 28 ++++----- src/app/views/events-render.ts | 19 +++--- src/app/views/home.ts | 5 +- src/app/views/schedule-render.ts | 5 +- src/art/motifs.ts | 78 ++++++++++++++++++++++++- src/art/planet.test.ts | 4 +- src/art/planet.ts | 2 +- src/core/components/empty-state.test.ts | 22 ++++++- src/core/components/empty-state.ts | 35 ++++++++++- 9 files changed, 166 insertions(+), 32 deletions(-) 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/events-render.ts b/src/app/views/events-render.ts index 9bac065..93e51f2 100644 --- a/src/app/views/events-render.ts +++ b/src/app/views/events-render.ts @@ -15,8 +15,11 @@ import { wrapAnsiWithIndent } from '../../core/text.js'; import { pictureBeside } from '../../core/components/picture.js'; import { renderBitmap } from '../../core/pixel-art.js'; import { eventIcon } from '../../features/event-art.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 EventsMode = 'loading' | 'hub' | 'heatmap' | 'list' | 'detail' | 'search' | 'error'; @@ -163,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': @@ -236,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/home.ts b/src/app/views/home.ts index 2423d5a..d5982dd 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -25,8 +25,7 @@ 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; @@ -238,7 +237,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 { diff --git a/src/app/views/schedule-render.ts b/src/app/views/schedule-render.ts index 4fd83a6..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 = @@ -464,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') { @@ -520,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/art/motifs.ts b/src/art/motifs.ts index 10d1be2..c878301 100644 --- a/src/art/motifs.ts +++ b/src/art/motifs.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import { moonLines, tint } from './planet.js'; +import { gasGiantLines, moonLines, tint } from './planet.js'; import { pictureMode, type PictureMode } from '../core/pixel-art.js'; type Vec = readonly [number, number, number]; @@ -103,3 +103,79 @@ export function magnifier(mode: PictureMode = pictureMode()): string[] { 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 }; +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 planet = gasGiantLines(8, mode === 'fine' ? 'color' : mode) ?? []; + 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+$/, ''), + ); +} diff --git a/src/art/planet.test.ts b/src/art/planet.test.ts index a5f558a..1243a42 100644 --- a/src/art/planet.test.ts +++ b/src/art/planet.test.ts @@ -54,8 +54,8 @@ describe('planets', () => { }); it('declines when there is no room', () => { - expect(gasGiantLines(8)).toBeUndefined(); - expect(moonLines(8, fullMoon)).toBeUndefined(); + 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 index 4d05681..600aab4 100644 --- a/src/art/planet.ts +++ b/src/art/planet.ts @@ -6,7 +6,7 @@ type Vec = readonly [number, number, number]; // Light to dark ink, as in classic tone-shaded ASCII spheres. const RAMP = '.-:=+*xo#%&@'; -const MIN_COLS = 12; +const MIN_COLS = 8; const MAX_COLS = 44; interface Sphere { diff --git a/src/core/components/empty-state.test.ts b/src/core/components/empty-state.test.ts index 270a283..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', () => { @@ -44,4 +44,24 @@ describe('emptyStateLines', () => { 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 e2067cb..7296697 100644 --- a/src/core/components/empty-state.ts +++ b/src/core/components/empty-state.ts @@ -1,6 +1,8 @@ import { c, space, type } from '../theme.js'; import { padEndV, visualWidth, wrapAnsiWithIndent } from '../text.js'; -import { magnifier, moonSky } from '../../art/motifs.js'; +import { magnifier, moonSky, orbit, signalLost } from '../../art/motifs.js'; +import { pictureBeside } from './picture.js'; +import { loadingLines } from './spinner.js'; const ART = { calendar: [' .-.-. ', ' | | ', " '---' "], @@ -42,3 +44,34 @@ function beside(art: string[], label: string, cols: number, subtle: boolean): st (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; +} From 5ab5dd90c7f3a1468e71f98d08a0a8b758fcc8c4 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 18:25:15 +0800 Subject: [PATCH 11/15] feat: diffuse brightness error between cells when drawing ASCII pictures --- src/core/pixel-art.test.ts | 11 ++++++ src/core/pixel-art.ts | 77 ++++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/core/pixel-art.test.ts b/src/core/pixel-art.test.ts index 13fc619..7517958 100644 --- a/src/core/pixel-art.test.ts +++ b/src/core/pixel-art.test.ts @@ -49,6 +49,17 @@ describe('renderBitmap', () => { 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) { diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts index b8eacf3..19962fd 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -155,8 +155,17 @@ 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))); -/** The glyph whose ink best matches the brightness of each part of the cell. */ -function pickGlyph(cell: Cell, gamma: number): Glyph | undefined { +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++) { @@ -166,34 +175,42 @@ function pickGlyph(cell: Cell, gamma: number): Glyph | undefined { total += value; } if (total / SAMPLES < 0.004) return undefined; - const mean = total / SAMPLES; + const mean = Math.max(0, total / SAMPLES + carry); + const shift = mean - total / SAMPLES; let best: Glyph | undefined; let bestCost = Number.POSITIVE_INFINITY; for (const glyph of GLYPH_TABLE) { const glyphMean = glyph.ink / SAMPLES; let cost = TONE_WEIGHT * SAMPLES * (mean - glyphMean) ** 2; for (let index = 0; index < SAMPLES; index++) - cost += ((bright[index] ?? 0) - mean - ((glyph.cover[index] ?? 0) - glyphMean)) ** 2; + cost += ((bright[index] ?? 0) + shift - mean - ((glyph.cover[index] ?? 0) - glyphMean)) ** 2; if (cost < bestCost) { bestCost = cost; best = glyph; } } - return best; + return best && { glyph: best, error: mean - best.ink / SAMPLES }; +} + +interface Drawn { + text: string; + error: number; } /** Plain characters only. */ -function toneCell(cell: Cell, gamma: number): string { - return pickGlyph(cell, gamma)?.char ?? ' '; +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): string { - const glyph = pickGlyph(cell, gamma); - if (!glyph || glyph.char === ' ') return ' '; +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++) { @@ -208,14 +225,27 @@ function colorCell(cell: Cell, gamma: number): string { 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(glyph.char); + if (chalk.level < 3) return { text: text(glyph.char), error }; const bg = mean.map((value) => clamp(value * TINT)); - return text.bgRgb(bg[0] ?? 0, bg[1] ?? 0, bg[2] ?? 0)(glyph.char); + 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, @@ -225,13 +255,28 @@ export function renderBitmap( ): 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) => { - let line = ''; - for (let col = 0; col < cols; col++) { + 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) line += ' '; - else line += mode === 'color' ? colorCell(cell, gamma) : toneCell(cell, gamma); + 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; }); } From 5a482d8a51b41e964a20a51adb35436d922793c8 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 18:28:43 +0800 Subject: [PATCH 12/15] feat: bump-map the planets with fractal and Worley noise and let them turn --- src/art/motifs.ts | 12 +++- src/art/noise.test.ts | 33 ++++++++++ src/art/noise.ts | 53 ++++++++++++++++ src/art/planet.test.ts | 16 +++++ src/art/planet.ts | 135 +++++++++++++++++++++++++++++++---------- 5 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 src/art/noise.test.ts create mode 100644 src/art/noise.ts diff --git a/src/art/motifs.ts b/src/art/motifs.ts index c878301..a52c17e 100644 --- a/src/art/motifs.ts +++ b/src/art/motifs.ts @@ -105,7 +105,14 @@ export function magnifier(mode: PictureMode = pictureMode()): string[] { } 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 }; +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]; @@ -126,7 +133,8 @@ export function orbit(at: number, mode: PictureMode = pictureMode()): string[] { const trail = plain(mode) ? '.' : chalk.rgb(...ORBIT_TRAIL)('.'); for (let step = 0; step < 24; step++) dot((step / 24) * 2 * Math.PI, trail); - const planet = gasGiantLines(8, mode === 'fine' ? 'color' : mode) ?? []; + 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); 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 index 1243a42..a86d332 100644 --- a/src/art/planet.test.ts +++ b/src/art/planet.test.ts @@ -53,6 +53,22 @@ describe('planets', () => { 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(); diff --git a/src/art/planet.ts b/src/art/planet.ts index 600aab4..ff2b23a 100644 --- a/src/art/planet.ts +++ b/src/art/planet.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { fbm3, worley3 } from './noise.js'; import { moonPhase } from './scene.js'; import { pictureMode, type PictureMode } from '../core/pixel-art.js'; @@ -9,12 +10,19 @@ 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; - /** Brightness multiplier at a point on the surface, in -1..1 sphere coordinates. */ - surface: (x: number, y: number) => number; - /** Colour of fully lit surface at that point. */ - color: (x: number, y: number) => Vec; + /** The surface at a point of the unit sphere. */ + sample: (point: Vec) => Surface; } interface Shade { @@ -31,11 +39,10 @@ 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 lit = Math.max(0, x * sphere.light[0] + y * sphere.light[1] + z * sphere.light[2]); - return { - level: Math.min(1, lit * sphere.surface(x, y) * (0.55 + 0.45 * z)), - color: sphere.color(x, y), - }; + 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]; @@ -117,47 +124,111 @@ function drawFineSphere(cols: number, sphere: Sphere): string[] { }); } -const MARIA: readonly (readonly [number, number, number])[] = [ - [-0.3, -0.25, 0.28], - [0.25, -0.1, 0.22], - [-0.05, 0.4, 0.2], - [0.45, 0.35, 0.12], -]; +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()): string[] | undefined { +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); - const light = normalize([Math.sin(angle), -0.12, -Math.cos(angle)]); return drawSphere( Math.min(cols, MAX_COLS), { - light, - surface: (x, y) => { - const dark = MARIA.reduce( - (sum, [mx, my, size]) => - sum + Math.max(0, 1 - Math.hypot(x - mx, (y - my) * 1.1) / size) * 0.3, - 0, - ); - return 1 - Math.min(0.35, dark); + 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)), + }; }, - color: () => [236, 238, 246], }, mode, ); } -/** A banded gas giant lit from the upper left. */ -export function gasGiantLines(cols: number, mode = pictureMode()): string[] | undefined { +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]), - surface: (x, y) => 0.88 + 0.12 * Math.sin(y * 11 + Math.sin(x * 3 + y * 2) * 0.9), - color: (_, y) => { - const band = 0.5 + 0.5 * Math.sin(y * 7 + 1); - return [236 - 40 * band, 176 - 30 * band, 120 + 40 * band]; + 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, From a6ac13456e3d382534811bfb307fb17f0083671d Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 18:54:27 +0800 Subject: [PATCH 13/15] fix: keep flat areas of pictures to density-ramp glyphs so dithering stays calm --- src/core/pixel-art.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/core/pixel-art.ts b/src/core/pixel-art.ts index 19962fd..f16ed83 100644 --- a/src/core/pixel-art.ts +++ b/src/core/pixel-art.ts @@ -37,6 +37,11 @@ interface Glyph { // 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 }, @@ -49,6 +54,8 @@ const GLYPH_TABLE: readonly Glyph[] = GLYPHS.map((hex, index) => { }; }).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 { @@ -177,9 +184,13 @@ function pickGlyph(cell: Cell, gamma: number, carry: number): Pick | undefined { 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 GLYPH_TABLE) { + 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++) From 396dc4d65ff90b5baf1cb8b1077424aa4fcc6399 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 18:57:31 +0800 Subject: [PATCH 14/15] test: isolate the config directory from the developer's real settings --- vitest.config.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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-')), + }, + }, }); From f5c6edf01104d659273353193b00644b018d5b99 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 28 Oct 2026 19:03:40 +0800 Subject: [PATCH 15/15] feat: show a cup of tea beside Home on days without classes --- src/app/ambient.test.ts | 10 ++++++++++ src/app/ambient.ts | 9 +++++++-- src/app/views/home.ts | 5 ++++- src/art/motifs.test.ts | 13 +++++++++++- src/art/motifs.ts | 37 +++++++++++++++++++++++++++++++++++ src/features/schedule-view.ts | 10 ++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/app/ambient.test.ts b/src/app/ambient.test.ts index 916ac8d..fad4d89 100644 --- a/src/app/ambient.test.ts +++ b/src/app/ambient.test.ts @@ -78,4 +78,14 @@ describe('withAmbient', () => { 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 index fc739cb..b271157 100644 --- a/src/app/ambient.ts +++ b/src/app/ambient.ts @@ -3,12 +3,13 @@ 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' } + | { kind: 'sky'; rest?: boolean } | { kind: 'shelf' } | { kind: 'mountain'; progress: number; weeks: number } | { kind: 'emblem' } @@ -20,6 +21,8 @@ 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; @@ -63,8 +66,10 @@ function scene(spec: AmbientSpec, cols: number, rows: number, now: Date): string 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.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); diff --git a/src/app/views/home.ts b/src/app/views/home.ts index d5982dd..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, @@ -330,7 +331,9 @@ export const homeView = { }, ambient(): AmbientSpec { - return { kind: 'sky' }; + return data.schedule && isRestDay(data.schedule) + ? { kind: 'sky', rest: true } + : { kind: 'sky' }; }, isBusy(): boolean { diff --git a/src/art/motifs.test.ts b/src/art/motifs.test.ts index 2b71145..a381457 100644 --- a/src/art/motifs.test.ts +++ b/src/art/motifs.test.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { describe, expect, it } from 'vitest'; -import { magnifier, moonSky } from './motifs.js'; +import { magnifier, moonSky, orbit, signalLost, teaCup } from './motifs.js'; import { stripAnsi, visualWidth } from '../core/text.js'; describe('motifs', () => { @@ -29,4 +29,15 @@ describe('motifs', () => { 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 index a52c17e..85e7394 100644 --- a/src/art/motifs.ts +++ b/src/art/motifs.ts @@ -1,5 +1,6 @@ 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]; @@ -187,3 +188,39 @@ export function signalLost(mode: PictureMode = pictureMode()): string[] { .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/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[];