From 4208fae83e3c54088fd577939d49df4fa23fa86d Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 24 Oct 2026 17:47:34 +0800 Subject: [PATCH 1/4] fix(deps): patch Vite and Vitest security vulnerabilities --- package-lock.json | 98 +++++++++++++++++++++++------------------------ package.json | 4 +- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6e2ac0..ca47946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,8 @@ "tsx": "^4.23.5", "typescript": "^5.9.3", "typescript-eslint": "^8.67.0", - "vite": "^6.4.3", - "vitest": "^4.1.11" + "vite": "^6.4.4", + "vitest": "^4.1.12" }, "engines": { "node": ">=20.12.0" @@ -1459,16 +1459,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1477,9 +1477,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -1490,13 +1490,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -1504,14 +1504,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1520,9 +1520,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -1530,13 +1530,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2926,9 +2926,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3485,19 +3485,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3525,12 +3525,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3575,13 +3575,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, diff --git a/package.json b/package.json index b860b3e..a81b05f 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "tsx": "^4.23.5", "typescript": "^5.9.3", "typescript-eslint": "^8.67.0", - "vite": "^6.4.3", - "vitest": "^4.1.11" + "vite": "^6.4.4", + "vitest": "^4.1.12" }, "engines": { "node": ">=20.12.0" From aabf5e39e65c9c1242ce6af5c5f58d9850ccec07 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 24 Oct 2026 17:47:34 +0800 Subject: [PATCH 2/4] feat(tui): add responsive ASCII art and explicit calendar states --- src/app/views/docs-render.test.ts | 16 +++++ src/app/views/docs-render.ts | 31 +++++---- src/app/views/events-render.test.ts | 17 +++++ src/app/views/events-render.ts | 24 ++++++- src/app/views/home.test.ts | 42 +++++++++---- src/app/views/home.ts | 19 +++--- src/core/components/empty-state.test.ts | 33 ++++++++++ src/core/components/empty-state.ts | 34 ++++++++++ src/core/logo.test.ts | 84 ++++++++++++++++++------- src/core/logo.ts | 84 +++++++++++++------------ src/i18n/index.ts | 2 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/zh.json | 6 +- src/logo/ascii-logo.txt | 15 +---- 14 files changed, 299 insertions(+), 114 deletions(-) create mode 100644 src/core/components/empty-state.test.ts create mode 100644 src/core/components/empty-state.ts diff --git a/src/app/views/docs-render.test.ts b/src/app/views/docs-render.test.ts index b89c7f5..e306794 100644 --- a/src/app/views/docs-render.test.ts +++ b/src/app/views/docs-render.test.ts @@ -119,6 +119,22 @@ describe('renderDocs', () => { expect(out).toContain('X Doc'); }); + it.each([5, 12])('keeps empty-search actions visible in a %i-row body', (bodyRows) => { + const searchResultsField = new ListField({ + title: 'Results', + options: [{ value: 'search', label: 'Search again' }], + }); + const lines = renderDocs( + { mode: 'searchResults', searchResultsField, searchResultsEmpty: true }, + 80, + bodyRows, + ); + const text = lines.map(stripAnsi).join('\n'); + expect(lines.length).toBeLessThanOrEqual(bodyRows); + expect(text).toContain('No documents match your search'); + expect(text).toContain('Search again'); + }); + it.each([ ['en', 'Community governance and operations', 'Current', 'No documents match your search'], ['zh', '社区治理与组织协作运行机制', '当前', '未找到匹配的文档'], diff --git a/src/app/views/docs-render.ts b/src/app/views/docs-render.ts index 7230a9a..d46c13b 100644 --- a/src/app/views/docs-render.ts +++ b/src/app/views/docs-render.ts @@ -6,6 +6,7 @@ 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'; export type DocsMode = | 'loading' @@ -78,6 +79,22 @@ export function renderDocs( bodyRows = Number.POSITIVE_INFINITY, ): string[] { const trans = t(); + const errorContext = state.errorMessage ? [...hintLines(state.errorMessage, cols), ''] : []; + const emptyContext = + state.mode === 'searchResults' && state.searchResultsEmpty + ? [ + ...emptyStateLines( + 'search', + trans.docs.searchNoResults, + cols, + bodyRows - + errorContext.length - + (state.searchResultsField?.render(Number.POSITIVE_INFINITY, cols).length ?? 0) - + 1, + ), + '', + ] + : []; let lines: string[]; switch (state.mode) { case 'loading': @@ -117,16 +134,7 @@ export function renderDocs( break; case 'searchResults': lines = state.searchResultsField - ? renderListFieldWithContext( - [ - ...(state.searchResultsEmpty - ? [...hintLines(trans.docs.searchNoResults, cols), ''] - : []), - ], - state.searchResultsField, - bodyRows, - cols, - ) + ? renderListFieldWithContext(emptyContext, state.searchResultsField, bodyRows, cols) : []; break; case 'readerLoading': @@ -153,12 +161,11 @@ export function renderDocs( } } if (!state.errorMessage) return lines; - const errorContext = [...hintLines(state.errorMessage, cols), '']; const listField = listFieldForState(state); if (!listField) return [...errorContext, ...lines]; const context = state.mode === 'searchResults' && state.searchResultsEmpty - ? [...errorContext, ...hintLines(trans.docs.searchNoResults, cols), ''] + ? [...errorContext, ...emptyContext] : errorContext; return renderListFieldWithContext(context, listField, bodyRows, cols); } diff --git a/src/app/views/events-render.test.ts b/src/app/views/events-render.test.ts index 721d9e1..9d274bd 100644 --- a/src/app/views/events-render.test.ts +++ b/src/app/views/events-render.test.ts @@ -290,6 +290,23 @@ describe('renderEvents', () => { expect(notice).toBeLessThan(lines.findIndex((line) => line.includes('Browse'))); }); + it.each([5, 12])('keeps the empty-hub status and browse action in a %i-row body', (bodyRows) => { + const hubField = new ListField({ + title: 'Browse', + options: [{ value: 'upcoming', label: 'Next 30 Days' }], + }); + const lines = renderEvents( + { mode: 'hub', hubField, recentEvents: [] }, + new Date('2026-10-24'), + bodyRows, + 80, + ); + const text = lines.map(stripAnsi).join('\n'); + expect(lines.length).toBeLessThanOrEqual(bodyRows); + expect(text).toContain('No upcoming events'); + expect(text).toContain('Next 30 Days'); + }); + it('hub mode windows the menu against actual content height instead of overflowing', () => { const manyOptions = Array.from({ length: 8 }, (_, i) => ({ value: String(i), diff --git a/src/app/views/events-render.ts b/src/app/views/events-render.ts index 1ddb7df..08966eb 100644 --- a/src/app/views/events-render.ts +++ b/src/app/views/events-render.ts @@ -12,6 +12,7 @@ import { import { renderHeatmap } from '../../features/calendar-heatmap.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; import { loadingLines } from '../../core/components/spinner.js'; +import { emptyStateLines } from '../../core/components/empty-state.js'; export type EventsMode = 'loading' | 'hub' | 'heatmap' | 'list' | 'detail' | 'search' | 'error'; @@ -98,8 +99,16 @@ function renderHubBody( } if (eventLines.length > 0) lines.push(...activityHeading, ...eventLines, ''); } else if (!state.nextEvent) { - const notice = [...wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint), '']; const fieldRows = state.hubField?.render(Number.POSITIVE_INFINITY, cols).length ?? 0; + const notice = [ + ...emptyStateLines( + 'calendar', + trans.calendar.noEvents, + cols, + rows - lines.length - fieldRows - 1, + ), + '', + ]; if (lines.length + notice.length + fieldRows <= rows) lines.push(...notice); } if (state.hubField) { @@ -126,7 +135,7 @@ export function renderEvents( ...notice, ...(state.heatmapBuckets && state.heatmapBuckets.length > 0 ? heatmapLines(state.heatmapBuckets, now, cols) - : wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint)), + : emptyStateLines('calendar', trans.calendar.noEvents, cols, bodyRows - notice.length)), ]; case 'list': { if (!state.listField) return []; @@ -137,7 +146,16 @@ export function renderEvents( ...notice, ...wrappedIndentedLines(state.listTitle ?? '', cols, type.heading), '', - ...wrappedIndentedLines(state.listNotice, cols, type.hint), + ...emptyStateLines( + 'calendar', + state.listNotice, + cols, + bodyRows - + notice.length - + wrappedIndentedLines(state.listTitle ?? '', cols, type.heading).length - + state.listField.render(Number.POSITIVE_INFINITY, cols).length - + 2, + ), '', ]; return renderListFieldWithContext(context, state.listField, bodyRows, cols); diff --git a/src/app/views/home.test.ts b/src/app/views/home.test.ts index 98521b1..c7dcfbc 100644 --- a/src/app/views/home.test.ts +++ b/src/app/views/home.test.ts @@ -165,6 +165,12 @@ describe('renderHome (schedule-first dashboard)', () => { expect(out).toContain('No upcoming events'); }); + it.each([2, 4])('keeps the empty status inside a %i-row home body', (bodyRows) => { + const lines = renderHome({ eventLines: [], loading: false }, noon, bodyRows, 80); + expect(lines.length).toBeLessThanOrEqual(bodyRows); + expect(lines.map(stripAnsi).join('\n')).toContain('No upcoming events'); + }); + it('shows an error state when the events fetch failed, distinct from "no events"', () => { const out = stripAnsi(renderHome({ eventsLoadFailed: true, loading: false }, noon).join('\n')); expect(out).toContain('Failed to load event calendar'); @@ -388,12 +394,12 @@ describe('renderHome — week overview panel', () => { expect(titleIdx).toBeGreaterThanOrEqual(0); expect(lines[titleIdx + 2]).toContain('Classes'); expect(lines[titleIdx + 3]).toContain('Events'); - expect(lines[titleIdx + 4]).toContain('Busy'); - expect(lines[titleIdx + 4]).toContain('Light'); - expect(lines[titleIdx + 4]).toContain('None'); + expect(lines[titleIdx + 4]).toContain('Scheduled'); + expect(lines[titleIdx + 4]).toContain('Free'); + expect(lines[titleIdx + 4]).not.toContain('Not loaded'); }); - it('shows weekend classes as busy and an empty weekend as none', () => { + it('shows weekend classes as scheduled and an empty weekend as free', () => { process.env['NBTCA_ICON_MODE'] = 'unicode'; resetIconCache(); try { @@ -439,14 +445,14 @@ describe('renderHome — week overview panel', () => { const saturday = eventCells[5]; const sunday = eventCells[6]; expect(saturday).toBe('▓▓'); - expect(sunday).toBe('░░'); + expect(sunday).toBe('··'); } finally { process.env['NBTCA_ICON_MODE'] = 'unicode'; resetIconCache(); } }); - it('renders the event row with no glyphs at all when eventDays is not yet known', () => { + it('distinguishes unknown event days from a known empty week', () => { const lines = renderHome( { loading: false, @@ -456,7 +462,21 @@ describe('renderHome — week overview panel', () => { ).map((l) => stripAnsi(l)); const titleIdx = lines.findIndex((l) => l.includes('Week overview')); const eventLine = defined(lines[titleIdx + 3]); - expect(eventLine).not.toMatch(/[▓░]/); + expect(eventLine.match(/\?\?/g)).toHaveLength(7); + expect(lines[titleIdx + 4]).toContain('Not loaded'); + + const loaded = renderHome( + { + loading: false, + weekAhead: { + classDays: [true, false, false, false, false, false, false], + eventDays: [false, false, false, false, false, false, false], + }, + }, + noon, + ).map(stripAnsi); + expect(loaded[titleIdx + 3]).not.toContain('??'); + expect(loaded[titleIdx + 4]).not.toContain('Not loaded'); }); it('never collapses the grid into one array entry', () => { @@ -500,7 +520,6 @@ describe('renderHome — week overview panel', () => { trans.menu.events, trans.timetable.weekAheadBusy, trans.timetable.weekAheadFree, - trans.timetable.weekAheadNone, ]) { expect(text).toContain(label.replace(/\s/g, '')); } @@ -686,8 +705,8 @@ describe('homeView.load()', () => { await homeView.load(ctx); const lines = stripAnsi(homeView.render(ctx).join('\n')).split('\n'); const titleIdx = lines.findIndex((l) => l.includes('Week overview')); - const cells = defined(lines[titleIdx + 3]).match(/▓▓|░░/g); - expect(cells).toEqual(['▓▓', '░░', '░░', '░░', '░░', '░░', '▓▓']); + const cells = defined(lines[titleIdx + 3]).match(/▓▓|··/g); + expect(cells).toEqual(['▓▓', '··', '··', '··', '··', '··', '▓▓']); } finally { vi.useRealTimers(); if (previousTimeZone === undefined) delete process.env.TZ; @@ -707,6 +726,7 @@ describe('homeView.load()', () => { const titleIdx = lines.findIndex((l) => l.includes('Week overview')); expect(titleIdx).toBeGreaterThanOrEqual(0); const eventLine = defined(lines[titleIdx + 3]); - expect(eventLine).toMatch(/[▓░]/); + expect(eventLine.match(/▓▓|··/g)).toHaveLength(7); + expect(eventLine).not.toContain('??'); }); }); diff --git a/src/app/views/home.ts b/src/app/views/home.ts index e7c9365..6042016 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -23,6 +23,7 @@ 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 type { Calendar } from '@nbtca/nbtcal'; const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7] as const; @@ -89,19 +90,19 @@ function renderWeekAheadGrid( ): string { const trans = t(); const hasClassChar = pickIcon('▓▓', '##'); - const freeChar = pickIcon('░░', '..'); - const weekendChar = pickIcon('··', '..'); - const blankCell = ' '; + const freeChar = pickIcon('··', '..'); + const unknownChar = '??'; const rowLabelW = Math.max(visualWidth(trans.timetable.weekAheadClasses), visualWidth(trans.menu.events)) + 1; const days = [1, 2, 3, 4, 5, 6, 7]; - const classCell = (wd: number): string => - classDays[wd - 1] ? hasClassChar : wd >= 6 ? weekendChar : freeChar; + const classCell = (wd: number): string => (classDays[wd - 1] ? hasClassChar : freeChar); const eventCell = (wd: number): string => - eventDays ? (eventDays[wd - 1] ? hasClassChar : freeChar) : blankCell; - const legendText = `${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree} ${weekendChar} ${trans.timetable.weekAheadNone}`; + eventDays ? (eventDays[wd - 1] ? hasClassChar : freeChar) : unknownChar; + const legendText = + `${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree}` + + (eventDays ? '' : ` ${unknownChar} ${trans.timetable.weekAheadUnknown}`); const dayLabels = days.map((wd) => type.hint(weekdayShortLabel(wd))).join(' '); const headerLine = `${space.indent}${padEndV('', rowLabelW)}${dayLabels}`; @@ -202,7 +203,9 @@ export function renderHome(data: HomeData, now: Date, bodyRows = 100, cols = 80) } else if (data.eventsLoadFailed) { lines.push(...wrappedIndentedLines(trans.calendar.error, cols, type.hint)); } else { - lines.push(...wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint)); + lines.push( + ...emptyStateLines('calendar', trans.calendar.noEvents, cols, bodyRows - lines.length), + ); } return lines; diff --git a/src/core/components/empty-state.test.ts b/src/core/components/empty-state.test.ts new file mode 100644 index 0000000..c860228 --- /dev/null +++ b/src/core/components/empty-state.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { emptyStateLines } from './empty-state.js'; +import { stripAnsi, visualWidth } from '../text.js'; + +describe('emptyStateLines', () => { + it.each(['calendar', 'search'] as const)( + 'keeps the %s illustration and status within a roomy body', + (kind) => { + const lines = emptyStateLines(kind, 'No upcoming events', 80, 3); + expect(lines).toHaveLength(3); + expect(lines.map(stripAnsi).join('\n')).toContain('No upcoming events'); + expect(lines.every((line) => visualWidth(line) <= 80)).toBe(true); + expect(lines.every((line) => /^[\x20-\x7e]*$/.test(stripAnsi(line)))).toBe(true); + }, + ); + + it.each([ + [20, 10, '未找到匹配的文档'], + [80, 1, 'No upcoming events'], + [ + 40, + 3, + 'This unusually long status must remain completely readable without an illustration taking room from the message', + ], + ])( + 'preserves the complete status within %i columns and %i rows of art budget', + (cols, rows, label) => { + const lines = emptyStateLines('search', label, cols, rows); + expect(lines.map(stripAnsi).join('').replace(/\s/g, '')).toBe(label.replace(/\s/g, '')); + expect(lines.every((line) => visualWidth(line) <= cols)).toBe(true); + }, + ); +}); diff --git a/src/core/components/empty-state.ts b/src/core/components/empty-state.ts new file mode 100644 index 0000000..543cd10 --- /dev/null +++ b/src/core/components/empty-state.ts @@ -0,0 +1,34 @@ +import { c, space, type } from '../theme.js'; +import { padEndV, visualWidth, wrapAnsiWithIndent } from '../text.js'; + +const ART = { + calendar: [' .-.-. ', ' | | ', " '---' "], + search: [' .--. ', '( ) ', " '--'\\_"], +} as const; + +export function emptyStateLines( + kind: keyof typeof ART, + label: string, + cols = Number.POSITIVE_INFINITY, + rows = Number.POSITIVE_INFINITY, +): string[] { + const text = wrapAnsiWithIndent(type.hint(label), cols, space.indent); + const art = ART[kind]; + if (cols < 40 || rows < art.length) return text; + + 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; + + const offset = Math.floor((art.length - message.length) / 2); + return art.map( + (line, index) => + space.indent + + c.subtle(padEndV(line, artWidth)) + + (message[index - offset] ? ` ${message[index - offset]}` : ''), + ); +} diff --git a/src/core/logo.test.ts b/src/core/logo.test.ts index b8212d2..3f253ef 100644 --- a/src/core/logo.test.ts +++ b/src/core/logo.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildLogoLines, startupFitsTerminal, startupOffset } from './logo.js'; -import { stripAnsi } from './text.js'; +import { buildLogoLines, startupOffset } from './logo.js'; +import { stripAnsi, visualWidth } from './text.js'; import { resetIconCache } from './icons.js'; describe('buildLogoLines', () => { @@ -17,15 +17,6 @@ describe('buildLogoLines', () => { }); }); -describe('startupFitsTerminal', () => { - it('skips startup art that would overflow either terminal dimension', () => { - expect(startupFitsTerminal(5, 20, 'one\ntwo\nthree')).toBe(false); - expect(startupFitsTerminal(8, 2, 'one\ntwo\nthree')).toBe(false); - expect(startupFitsTerminal(8, 5, 'one\ntwo\nthree')).toBe(true); - expect(startupFitsTerminal(undefined, undefined, 'one\ntwo\nthree')).toBe(true); - }); -}); - describe('startupOffset', () => { it('starts the logo where the app frame starts its content', () => { expect(startupOffset(80, 52)).toBe(3); @@ -43,8 +34,6 @@ describe('buildLogoLines dot-matrix tier selection', () => { const originalRows = process.stdout.rows; beforeEach(() => { - // Tier selection only applies to the braille art path; force it on so - // these tests aren't at the mercy of the test runner's own TTY/locale. process.env['NBTCA_ICON_MODE'] = 'unicode'; resetIconCache(); }); @@ -56,16 +45,9 @@ describe('buildLogoLines dot-matrix tier selection', () => { resetIconCache(); }); - // Each tier's art block is a fixed, known line count (12/16/26 -- see - // src/logo/ca-dotmatrix-*.txt), so counting the art lines between the - // leading blank and the trailing blank+tagline+version block is enough to - // tell which tier rendered, without hardcoding file paths in the test. function artLineCount(): number { const lines = buildLogoLines().map(stripAnsi); - // lines = ['', ...art, '', tagline, version, ''] -- art sits between - // index 1 and the blank line preceding the tagline. - const taglineIdx = lines.findIndex((l) => l.includes('intersection of technology')); - return taglineIdx - 2; // minus the leading blank and the blank before tagline + return lines.indexOf('', 1) - 1; } it('picks the small tier on a narrow/short terminal', () => { @@ -92,3 +74,63 @@ describe('buildLogoLines dot-matrix tier selection', () => { expect(artLineCount()).toBe(12); }); }); + +describe('buildLogoLines display capabilities', () => { + const originalCols = process.stdout.columns; + const originalRows = process.stdout.rows; + const originalIconMode = process.env['NBTCA_ICON_MODE']; + + afterEach(() => { + process.stdout.columns = originalCols; + process.stdout.rows = originalRows; + if (originalIconMode === undefined) delete process.env['NBTCA_ICON_MODE']; + else process.env['NBTCA_ICON_MODE'] = originalIconMode; + resetIconCache(); + }); + + it.each([ + [80, 24], + [40, 12], + [20, 12], + ])('fits the complete ASCII startup in %i columns and %i rows', (cols, rows) => { + process.env['NBTCA_ICON_MODE'] = 'ascii'; + process.stdout.columns = cols; + process.stdout.rows = rows; + resetIconCache(); + + const lines = buildLogoLines().map(stripAnsi); + expect(lines.length).toBeGreaterThan(0); + expect(lines.length).toBeLessThanOrEqual(rows); + expect(lines.every((line) => /^[\x20-\x7e]*$/.test(line))).toBe(true); + expect( + lines.every((line) => visualWidth(line) + startupOffset(cols, visualWidth(line)) <= cols), + ).toBe(true); + expect(lines.join('').replace(/\s/g, '')).toContain('intersectionoftechnologyandliberalarts'); + expect(lines.join('')).toContain('@nbtca/prompt'); + }); + + it('uses a readable wordmark when the ASCII artwork is too wide', () => { + process.env['NBTCA_ICON_MODE'] = 'ascii'; + process.stdout.columns = 20; + process.stdout.rows = 12; + resetIconCache(); + expect(stripAnsi(buildLogoLines()[1] ?? '')).toBe('NBTCA'); + }); + + it('uses compact artwork when a Unicode terminal is too short for the badge', () => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + process.stdout.columns = 80; + process.stdout.rows = 12; + resetIconCache(); + const lines = buildLogoLines().map(stripAnsi); + expect(lines.length).toBeGreaterThan(0); + expect(lines.length).toBeLessThanOrEqual(12); + expect(lines.join('')).not.toMatch(/[\u2800-\u28ff]/u); + }); + + it('skips startup when even the wordmark and metadata cannot fit', () => { + process.stdout.columns = 8; + process.stdout.rows = 3; + expect(buildLogoLines()).toEqual([]); + }); +}); diff --git a/src/core/logo.ts b/src/core/logo.ts index d6546a1..1561654 100644 --- a/src/core/logo.ts +++ b/src/core/logo.ts @@ -6,7 +6,7 @@ import { useUnicodeIcons } from './icons.js'; import { APP_INFO } from '../config/data.js'; import { typeReveal, materializeBraille, sleep } from './motion.js'; import { brandGradient as brand, c, frameLeft, space } from './theme.js'; -import { visualWidth } from './text.js'; +import { visualWidth, wrapAnsiWithIndent } from './text.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -27,43 +27,53 @@ const LOGO_TIERS = [ { file: 'ca-dotmatrix-small.txt', minCols: 0, minRows: 0 }, ] as const; -function dotmatrixFile(): string { - const cols = process.stdout.columns; - const rows = process.stdout.rows; - const tier = LOGO_TIERS.find((t) => cols >= t.minCols && rows >= t.minRows); - return tier?.file ?? 'ca-dotmatrix-small.txt'; -} - function paint(text: string, color: boolean): string { return color ? brand(text) : text; } -function loadArt(): string { - const art = useUnicodeIcons() ? readArt(dotmatrixFile()) : readArt('ascii-logo.txt'); - return art ?? 'NBTCA'; -} - -export function startupFitsTerminal( - rows: number | undefined, - cols: number | undefined, - art: string, -): boolean { - const lines = art.split('\n'); - const fitsRows = rows === undefined || rows >= lines.length + 5; - const fitsCols = cols === undefined || lines.every((line) => visualWidth(line) <= cols); - return fitsRows && fitsCols; +function logoContent(): { art: string; metadata: string[] } | undefined { + const cols = Number.isFinite(process.stdout.columns) ? process.stdout.columns : undefined; + const rows = Number.isFinite(process.stdout.rows) ? process.stdout.rows : undefined; + const width = + cols === undefined ? Number.POSITIVE_INFINITY : Math.max(1, cols - startupOffset(cols, 0)); + const color = !process.env['NO_COLOR']; + const metadata = [ + ...wrapAnsiWithIndent(TAGLINE, width, '').map((line) => paint(line, color)), + ...wrapAnsiWithIndent(`@nbtca/prompt v${APP_INFO.version}`, width, '').map((line) => + chalk.dim(line), + ), + ]; + const files = [ + ...(useUnicodeIcons() + ? LOGO_TIERS.filter((tier) => (cols ?? 0) >= tier.minCols && (rows ?? 0) >= tier.minRows).map( + (tier) => tier.file, + ) + : []), + 'ascii-logo.txt', + undefined, + ]; + for (const file of files) { + const art = file ? readArt(file) : 'NBTCA'; + if (!art) continue; + const lines = art.split('\n'); + if ( + lines.every((line) => visualWidth(line) <= width) && + (rows === undefined || lines.length + metadata.length + 3 <= rows) + ) { + return { art, metadata }; + } + } + return undefined; } export function buildLogoLines(): string[] { - const color = !process.env['NO_COLOR']; - const paintedArt = paint(loadArt(), color).split('\n'); - + const content = logoContent(); + if (!content) return []; return [ '', - ...paintedArt, + ...paint(content.art, !process.env['NO_COLOR']).split('\n'), '', - color ? brand(TAGLINE) : TAGLINE, - chalk.dim(`@nbtca/prompt v${APP_INFO.version}`), + ...content.metadata, '', ]; } @@ -106,26 +116,20 @@ function skipOnKeypress(): { signal: AbortSignal; release(): void } { export async function runStartup(): Promise { if (!process.stdout.isTTY) return; - const art = loadArt(); - if (!startupFitsTerminal(process.stdout.rows, process.stdout.columns, art)) return; + const content = logoContent(); + if (!content) return; const cols = process.stdout.columns; const color = !process.env['NO_COLOR']; const skip = skipOnKeypress(); process.stdout.write('\n'); try { - await materializeBraille(art, (s) => indentBlock(paint(s, color), cols), { + await materializeBraille(content.art, (s) => indentBlock(paint(s, color), cols), { paintProgress: (s) => indentBlock(color ? c.brand(s) : s, cols), signal: skip.signal, }); - await typeReveal( - [ - '', - indentBlock(color ? brand(TAGLINE) : TAGLINE, cols), - indentBlock(chalk.dim(`@nbtca/prompt v${APP_INFO.version}`), cols), - '', - ], - { signal: skip.signal }, - ); + await typeReveal(['', ...content.metadata.map((line) => indentBlock(line, cols)), ''], { + signal: skip.signal, + }); await sleep(FINAL_FRAME_MS, skip.signal); } finally { skip.release(); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 490d86f..8ca8f06 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -333,7 +333,7 @@ export interface Translations { weekAheadClasses: string; weekAheadBusy: string; weekAheadFree: string; - weekAheadNone: string; + weekAheadUnknown: string; termPreviewWeek: string; hubFullGrid: string; detailTime: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e91c70c..cab5f29 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -379,9 +379,9 @@ "exportSaved": "Saved: {path}", "weekOverviewTitle": "Week overview", "weekAheadClasses": "Classes", - "weekAheadBusy": "Busy", - "weekAheadFree": "Light", - "weekAheadNone": "None", + "weekAheadBusy": "Scheduled", + "weekAheadFree": "Free", + "weekAheadUnknown": "Not loaded", "termPreviewWeek": "Week 1 preview", "hubFullGrid": "Full grid", "detailTime": "Time", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3799e98..89d980f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -379,9 +379,9 @@ "exportSaved": "已保存:{path}", "weekOverviewTitle": "本周概览", "weekAheadClasses": "课程", - "weekAheadBusy": "较满", - "weekAheadFree": "较空", - "weekAheadNone": "无安排", + "weekAheadBusy": "有安排", + "weekAheadFree": "无安排", + "weekAheadUnknown": "尚未加载", "termPreviewWeek": "第一周预览", "hubFullGrid": "完整课表", "detailTime": "时间", diff --git a/src/logo/ascii-logo.txt b/src/logo/ascii-logo.txt index da3a417..8d2b5c6 100644 --- a/src/logo/ascii-logo.txt +++ b/src/logo/ascii-logo.txt @@ -1,12 +1,3 @@ -⠀⠀⠀⠀⠀⠀⣀⠤⡐⣒⣈⡉⡉⠁⠒⠂⠤⣀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⢀⠔⣋⠄⣀⠤⠜⠒⠒⠒⠒⠣⠤⣁⢤⢙⠢⡀⠀⠀⠀ -⠀⠀⡴⣡⠈⡥⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⢬⡠⡈⢦⠀⠀ -⠀⡜⡀⢪⠮⠐⠒⠂⠠⢀⠀⠀⠀⡀⠄⠐⠒⠀⠄⡑⡔⢀⢣⠀ -⠸⠀⢳⣷⣆⠠⠀⠀⠠⡀⠱⠀⣾⣶⣀⠤⠀⠠⢄⠈⠞⡈⡀⠇ -⡇⠄⠸⠉⠁⠀⠀⠀⠀⠈⠈⠀⠉⠉⠀⠀⠀⠀⠀⠁⠀⠇⣠⢸ -⡇⠍⢠⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡄⢋⢸ -⢱⠀⠘⡄⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠇⢠⠃⠀⡎ -⠀⢆⢺⠐⡌⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⢠⢢⡁⡰⠀ -⠀⠈⠣⡂⢪⠢⡁⠢⢀⡀⠀⠀⠀⠀⣀⠠⠒⢁⠔⠅⢈⠜⠁⠀ -⠀⠀⠀⠙⠢⡀⠴⠑⢂⠤⠉⢉⡉⠁⠤⠐⠊⠤⢅⠔⠋⠀⠀⠀ -⠀⠀⠀⠀⠀⠈⠑⠢⠤⣐⣘⣒⣁⣒⣐⠤⠔⠊⠁⠀⠀⠀⠀⠀ +|\ | __ _____ __ /\ +| \ | |__) | / /__\ +| \| |__) | \__ / \ From beb695ba3f3e3060b6043d60cced65b679118b18 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 25 Oct 2026 12:26:11 +0800 Subject: [PATCH 3/4] fix(tui): preserve readable states across terminal sizes --- src/app/app.test.ts | 31 ++++++++++++++- src/app/app.ts | 2 +- src/app/chrome.ts | 14 ++++++- src/app/view.ts | 2 +- src/app/views/events-render.test.ts | 51 ++++++++++++++++++------ src/app/views/events-render.ts | 2 +- src/app/views/home.test.ts | 62 +++++++++++++++++++++++++++++ src/app/views/home.ts | 37 ++++++++++++----- src/core/components/menu.ts | 4 +- src/core/theme.ts | 2 +- src/features/calendar-heatmap.ts | 2 +- src/features/calendar.ts | 4 +- src/i18n/index.ts | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/zh.json | 1 + 15 files changed, 182 insertions(+), 34 deletions(-) diff --git a/src/app/app.test.ts b/src/app/app.test.ts index d03f42a..ebe950e 100644 --- a/src/app/app.test.ts +++ b/src/app/app.test.ts @@ -3,6 +3,8 @@ import { ansi } from '../core/canvas.js'; const homeLoad = vi.hoisted(() => vi.fn()); const dispose = vi.hoisted(() => vi.fn()); +const homeRender = vi.hoisted(() => vi.fn(() => ['Home'])); +const homeFooter = vi.hoisted(() => vi.fn(() => 'Footer')); vi.mock('./tabs.js', () => ({ getAppTabs: () => [{ id: 'home', title: 'Home' }], @@ -13,7 +15,9 @@ vi.mock('./views/home.js', () => ({ id: 'home', title: 'Home', load: homeLoad, - render: () => ['Home'], + render: homeRender, + footerHint: homeFooter, + scrollsBody: () => true, dispose, }, })); @@ -49,6 +53,7 @@ describe('runApp terminal lifecycle', () => { beforeEach(() => { vi.clearAllMocks(); + homeRender.mockReturnValue(['Home']); signal = undefined; Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); @@ -169,4 +174,28 @@ describe('runApp terminal lifecycle', () => { else Reflect.deleteProperty(process.stdout, 'columns'); } }); + + it('shows overflow and scrolls the home body with arrow keys', async () => { + const rows = Object.getOwnPropertyDescriptor(process.stdout, 'rows'); + const columns = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); + Object.defineProperty(process.stdout, 'rows', { value: 12, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { value: 40, configurable: true }); + homeRender.mockReturnValue(Array.from({ length: 10 }, (_, index) => `Line ${index + 1}`)); + try { + const running = runApp(); + expect(homeFooter).toHaveBeenLastCalledWith(1, 40, true); + expect(writtenOutput()).toContain('0%'); + const start = writtenOutput().length; + process.stdin.emit('data', Buffer.from('\x1b[B')); + expect(writtenOutput().slice(start)).toContain('Line 8'); + expect(writtenOutput().slice(start)).toContain('33%'); + process.stdin.emit('data', Buffer.from('q')); + await running; + } finally { + if (rows) Object.defineProperty(process.stdout, 'rows', rows); + else Reflect.deleteProperty(process.stdout, 'rows'); + if (columns) Object.defineProperty(process.stdout, 'columns', columns); + else Reflect.deleteProperty(process.stdout, 'columns'); + } + }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 9fe9d27..c61312f 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -110,7 +110,7 @@ export async function runApp(): Promise { view, cols, tabs.length, - helpOpen ? t().help.close : active?.footerHint?.(tabs.length, cols), + helpOpen ? t().help.close : active?.footerHint?.(tabs.length, cols, body.length > height), chrome.footerLines, helpOpen || active?.scrollsBody?.() === true ? scrollPercent() : undefined, ); diff --git a/src/app/chrome.ts b/src/app/chrome.ts index af1c105..2d7fe95 100644 --- a/src/app/chrome.ts +++ b/src/app/chrome.ts @@ -26,7 +26,7 @@ function renderRule(cols: number): string { const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : 80; const indent = visualWidth(space.indent) < width ? space.indent : ''; const ruleWidth = Math.max(1, bodyEdge(width) - visualWidth(indent)); - return indent + type.hint(glyph.rule().repeat(ruleWidth)); + return indent + c.muted(glyph.rule().repeat(ruleWidth)); } function renderTabs(views: { id: ViewId; title: string }[], active: ViewId, cols: number): string { @@ -144,12 +144,24 @@ export function passiveFooterHint( tabCount: number, cols = Number.POSITIVE_INFINITY, canGoBack = true, + canScroll = false, ): string { const trans = t(); const dot = glyph.sep(); const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : ''; const back = canGoBack ? `Esc ${trans.menu.hintBack} ${dot} ` : ''; const backKey = canGoBack ? `Esc ${dot} ` : ''; + if (canScroll) { + const move = glyph.updown(); + return fitFooterHint( + cols, + `${digitTabHint(tabCount)}${move} ${trans.help.scroll} ${dot} ${back}q ${trans.menu.hintQuit} ${dot} ${trans.help.hint}`, + `${compactTabs}${move} ${dot} ${backKey}q ${dot} ?`, + `${move} ${dot} ${backKey}q`, + `${move} q`, + 'q', + ); + } return fitFooterHint( cols, `${digitTabHint(tabCount)}${back}q ${trans.menu.hintQuit} ${dot} ${trans.help.hint}`, diff --git a/src/app/view.ts b/src/app/view.ts index 4bb075f..372e2d3 100644 --- a/src/app/view.ts +++ b/src/app/view.ts @@ -32,5 +32,5 @@ export interface View { capturesInput?(): boolean; capturesPageKeys?(): boolean; handleBack?(ctx: AppContext): boolean; - footerHint?(tabCount: number, cols: number): string | undefined; + footerHint?(tabCount: number, cols: number, canScroll: boolean): string | undefined; } diff --git a/src/app/views/events-render.test.ts b/src/app/views/events-render.test.ts index 9d274bd..f36c9d2 100644 --- a/src/app/views/events-render.test.ts +++ b/src/app/views/events-render.test.ts @@ -68,19 +68,22 @@ describe('renderEvents', () => { '活动', ['活动列表', '本周活动', '本月活动', '搜索活动', '往期活动', '过去十二个月活动热力图'], ], - ])('fits every hub action within nine rows and twenty columns', (title, labels) => { - const hubField = new ListField({ - title, - options: labels.map((label, index) => ({ value: String(index), label })), - initialIndex: labels.length - 1, - }); - const lines = renderEvents({ mode: 'hub', hubField, heatmapBuckets: [] }, new Date(), 9, 20); - const text = lines.map(stripAnsi).join('').replace(/\s/g, ''); + ])('keeps every hub action reachable within nine rows and twenty columns', (title, labels) => { + for (const [initialIndex, label] of labels.entries()) { + const hubField = new ListField({ + title, + options: labels.map((label, index) => ({ value: String(index), label })), + initialIndex, + }); + const lines = renderEvents({ mode: 'hub', hubField, heatmapBuckets: [] }, new Date(), 9, 20); + const text = lines.map(stripAnsi).join('').replace(/\s/g, ''); - expect(lines.length).toBeLessThanOrEqual(9); - expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); - for (const label of labels) expect(text).toContain(label.replace(/\s/g, '')); - expect(lines.filter((line) => /[→>]/u.test(stripAnsi(line)))).toHaveLength(1); + expect(lines.length).toBeLessThanOrEqual(9); + expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); + expect(text).toContain(label.replace(/\s/g, '')); + expect(text).toContain('Noupcomingevents'); + expect(lines.filter((line) => /[→>]/u.test(stripAnsi(line)))).toHaveLength(1); + } }); it.each([ @@ -307,6 +310,30 @@ describe('renderEvents', () => { expect(text).toContain('Next 30 Days'); }); + it.each([5, 7])( + 'keeps the empty status with the full browse menu in a %i-row body', + (bodyRows) => { + const hubField = new ListField({ + title: 'Browse', + options: Array.from({ length: 6 }, (_, index) => ({ + value: String(index), + label: `Browse ${index + 1}`, + })), + initialIndex: 5, + }); + const lines = renderEvents( + { mode: 'hub', hubField, recentEvents: [] }, + new Date('2026-10-24'), + bodyRows, + 40, + ).map(stripAnsi); + expect(lines.length).toBeLessThanOrEqual(bodyRows); + expect(lines.join('\n')).toContain('No upcoming events'); + expect(lines.join('\n')).toContain('Browse 6'); + expect(lines.every((line) => visualWidth(line) <= 40)).toBe(true); + }, + ); + it('hub mode windows the menu against actual content height instead of overflowing', () => { const manyOptions = Array.from({ length: 8 }, (_, i) => ({ value: String(i), diff --git a/src/app/views/events-render.ts b/src/app/views/events-render.ts index 08966eb..1ec32e1 100644 --- a/src/app/views/events-render.ts +++ b/src/app/views/events-render.ts @@ -109,7 +109,7 @@ function renderHubBody( ), '', ]; - if (lines.length + notice.length + fieldRows <= rows) lines.push(...notice); + lines.push(...notice); } if (state.hubField) { return renderListFieldWithContext(lines, state.hubField, bodyRows, cols); diff --git a/src/app/views/home.test.ts b/src/app/views/home.test.ts index c7dcfbc..4a79dde 100644 --- a/src/app/views/home.test.ts +++ b/src/app/views/home.test.ts @@ -87,6 +87,16 @@ describe('homeView footer', () => { it('does not offer Esc to go back from the root tab', () => { expect(stripAnsi(homeView.footerHint(5, 80))).not.toContain('Esc'); }); + + it('offers line scrolling when the home body overflows', () => { + expect(homeView.scrollsBody()).toBe(true); + for (const cols of [20, 40, 80]) { + const hint = homeView.footerHint(5, cols, true); + expect(hint).toContain('↑↓'); + expect(hint).toContain('q'); + expect(visualWidth(hint) + 3).toBeLessThanOrEqual(cols); + } + }); }); describe('renderHome (schedule-first dashboard)', () => { @@ -132,6 +142,23 @@ describe('renderHome (schedule-first dashboard)', () => { expect(out).toContain('Hackathon'); }); + it.each(['en', 'zh'] as const)( + 'keeps the login prompt and event status visible at 20×12 in %s', + (language) => { + setLanguage(language); + try { + const lines = renderHome({ schedule: null, eventsLoadFailed: true }, noon, 7, 20).map( + stripAnsi, + ); + expect(lines.length).toBeLessThanOrEqual(7); + expect(lines.join('').replace(/\s/g, '')).toContain(t().calendar.error.replace(/\s/g, '')); + expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); + } finally { + setLanguage('en'); + } + }, + ); + it('keeps the next-class countdown on one line at forty columns', () => { const lines = renderHome( { @@ -374,6 +401,41 @@ describe('renderHome day-progress bar', () => { }); describe('renderHome — week overview panel', () => { + it.each([ + ['en', 'unicode', 80], + ['en', 'unicode', 40], + ['en', 'ascii', 80], + ['zh', 'unicode', 80], + ['zh', 'ascii', 80], + ] as const)('aligns all seven weekday columns in %s/%s at %i columns', (language, mode, cols) => { + setLanguage(language); + process.env['NBTCA_ICON_MODE'] = mode; + resetIconCache(); + try { + const lines = renderHome( + { weekAhead: { classDays: [true, false, true, false, false, false, false] } }, + noon, + 100, + cols, + ).map(stripAnsi); + const start = lines.findIndex((line) => line.includes(t().timetable.weekOverviewTitle)); + const header = defined(lines[start + 1]); + const positions = (line: string, pattern: RegExp) => + Array.from(line.matchAll(pattern), (match) => visualWidth(line.slice(0, match.index))); + const weekdays = positions( + header, + language === 'en' ? /Mon|Tue|Wed|Thu|Fri|Sat|Sun/g : /[一二三四五六日]/g, + ); + expect(weekdays).toHaveLength(7); + expect(positions(defined(lines[start + 2]), /▓▓|··|##|\.\./g)).toEqual(weekdays); + expect(positions(defined(lines[start + 3]), /\?\?/g)).toEqual(weekdays); + } finally { + setLanguage('en'); + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); + } + }); + it('does not show the week overview panel at all when weekAhead is absent', () => { const out = stripAnsi(renderHome({ loading: false }, noon).join('\n')); expect(out).not.toContain('Week overview'); diff --git a/src/app/views/home.ts b/src/app/views/home.ts index 6042016..32b8c8f 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -96,26 +96,32 @@ function renderWeekAheadGrid( const rowLabelW = Math.max(visualWidth(trans.timetable.weekAheadClasses), visualWidth(trans.menu.events)) + 1; - const days = [1, 2, 3, 4, 5, 6, 7]; + const days = WEEKDAYS; + const labels = days.map(weekdayShortLabel); + const cellWidth = Math.max(2, ...labels.map(visualWidth)); + const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; + const gap = + visualWidth(space.indent) + rowLabelW + days.length * cellWidth + (days.length - 1) * 2 <= width + ? ' ' + : ' '; const classCell = (wd: number): string => (classDays[wd - 1] ? hasClassChar : freeChar); const eventCell = (wd: number): string => eventDays ? (eventDays[wd - 1] ? hasClassChar : freeChar) : unknownChar; const legendText = `${hasClassChar} ${trans.timetable.weekAheadBusy} ${freeChar} ${trans.timetable.weekAheadFree}` + (eventDays ? '' : ` ${unknownChar} ${trans.timetable.weekAheadUnknown}`); - const dayLabels = days.map((wd) => type.hint(weekdayShortLabel(wd))).join(' '); + const dayLabels = labels.map((label) => type.hint(padEndV(label, cellWidth))).join(gap); const headerLine = `${space.indent}${padEndV('', rowLabelW)}${dayLabels}`; - const classCells = days.map((wd) => type.body(classCell(wd))).join(' '); + const classCells = days.map((wd) => type.body(padEndV(classCell(wd), cellWidth))).join(gap); const classLine = `${space.indent}${type.hint(padEndV(trans.timetable.weekAheadClasses, rowLabelW))}${classCells}`; - const eventCells = days.map((wd) => type.body(eventCell(wd))).join(' '); + const eventCells = days.map((wd) => type.body(padEndV(eventCell(wd), cellWidth))).join(gap); const eventLine = `${space.indent}${type.hint(padEndV(trans.menu.events, rowLabelW))}${eventCells}`; const legend = `${space.indent}${type.hint(legendText)}`; const wideLines = [headerLine, classLine, eventLine, legend]; - const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; if (wideLines.every((line) => visualWidth(line) <= width)) return wideLines.join('\n'); const compactHeading = wrappedIndentedLines( @@ -140,10 +146,15 @@ export function renderHome(data: HomeData, now: Date, bodyRows = 100, cols = 80) lines.push(...panelHeading(trans.timetable.menuTitle, cols)); lines.push( ...wrappedIndentedLines( - fmt(trans.timetable.homeLoginCta, { - tab: trans.timetable.menuEntry, - action: trans.timetable.publicLoginAction, - }), + fmt( + cols < 40 || bodyRows < 10 + ? trans.timetable.homeLoginCtaCompact + : trans.timetable.homeLoginCta, + { + tab: trans.timetable.menuEntry, + action: trans.timetable.publicLoginAction, + }, + ), cols, type.hint, ), @@ -250,8 +261,12 @@ export const homeView = { id: 'home', title: 'Home', - footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string { - return passiveFooterHint(tabCount, cols, false); + footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY, canScroll = false): string { + return passiveFooterHint(tabCount, cols, false, canScroll); + }, + + scrollsBody(): boolean { + return true; }, async load(ctx: AppContext): Promise { diff --git a/src/core/components/menu.ts b/src/core/components/menu.ts index 400c0f3..c2e5b20 100644 --- a/src/core/components/menu.ts +++ b/src/core/components/menu.ts @@ -1,4 +1,4 @@ -import { glyph, type, space, bodyEdge } from '../theme.js'; +import { c, glyph, type, space, bodyEdge } from '../theme.js'; import { visualWidth, padEndV, @@ -179,7 +179,7 @@ export function renderMenuOption( const continuation = ' '.repeat(visualWidth(prefix)); const contentWidth = Math.max(1, bodyEdge(width) - visualWidth(prefix)); const cells = optionCells(option, columns, contentWidth); - const style = selected ? type.active : option.dim ? type.hint : type.body; + const style = selected ? type.active : option.dim ? c.muted : type.body; const hint = cells.hint ? ` ${type.hint(cells.hint)}` : ''; const label = style(cells.label); return wrapAnsiToVisualWidth(`${label}${hint}`, contentWidth).map( diff --git a/src/core/theme.ts b/src/core/theme.ts index 6093b02..3df60cd 100644 --- a/src/core/theme.ts +++ b/src/core/theme.ts @@ -102,7 +102,7 @@ export const type = { heading: (s: string) => chalk.bold(s), label: (s: string) => s, body: (s: string) => s, - hint: (s: string) => chalk.dim(s), + hint: (s: string) => s, active: (s: string) => chalk.bold(c.brand(s)), cursor: (s: string) => chalk.bgHex('#0ea5e9').black(s), }; diff --git a/src/features/calendar-heatmap.ts b/src/features/calendar-heatmap.ts index cf55e1b..d53ff06 100644 --- a/src/features/calendar-heatmap.ts +++ b/src/features/calendar-heatmap.ts @@ -31,7 +31,7 @@ function levelCell(count: number, useColor: boolean): string { const [unicode, ascii] = LEVEL_GLYPHS[level] ?? LEVEL_GLYPHS[0]; const cell = pickIcon(unicode, ascii); if (!useColor) return cell; - return level === 0 ? type.hint(cell) : c.success(cell); + return level === 0 ? c.muted(cell) : c.success(cell); } interface MonthAnchor { diff --git a/src/features/calendar.ts b/src/features/calendar.ts index 62006e0..9fd43e1 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -277,7 +277,7 @@ const SOURCE_TAG = /^(\[[^\]]{1,16}\])(\s+)(?=\S)/; function styleTitle(title: string, style: (value: string) => string): string { const match = SOURCE_TAG.exec(title); if (!match?.[1] || !match[2]) return style(title); - return `${type.hint(match[1])}${match[2]}${style(title.slice(match[0].length))}`; + return `${c.muted(match[1])}${match[2]}${style(title.slice(match[0].length))}`; } const TIME_COLUMN = ' 00:00'.length; @@ -315,7 +315,7 @@ export function renderEventBriefLines( ? c.brand : proximity === 'week' ? type.body - : type.hint; + : c.muted; const marker = proximity === 'today' ? type.active(pickIcon('●', '*')) : dateStyle(glyph.sep()); const titleStyled = styleTitle(e.title, proximity === 'today' ? type.active : type.body); const content = `${dateStyle(eventWhen(e))} ${type.hint(dot)} ${titleStyled}${type.hint(recurringMark(e))}`; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 8ca8f06..39290db 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -349,6 +349,7 @@ export interface Translations { termPickerNeedsLogin: string; termPickerLoginAction: string; homeLoginCta: string; + homeLoginCtaCompact: string; helpSwitchDay: string; helpPickClass: string; helpOpenClass: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cab5f29..825f5c8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -397,6 +397,7 @@ "termPickerNeedsLogin": "Log in to the school system to switch terms.", "termPickerLoginAction": "Log in", "homeLoginCta": "Open {tab} and choose “{action}” to see your classes here.", + "homeLoginCtaCompact": "Log in from {tab}.", "helpSwitchDay": "Switch day", "helpPickClass": "Pick a class", "helpOpenClass": "Class details", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 89d980f..a9ed1a5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -397,6 +397,7 @@ "termPickerNeedsLogin": "切换学期需要先登录教务系统。", "termPickerLoginAction": "登录教务系统", "homeLoginCta": "到{tab}页选择“{action}”,这里就会显示你的课程。", + "homeLoginCtaCompact": "到{tab}页登录查看课程。", "helpSwitchDay": "切换星期", "helpPickClass": "选择课程", "helpOpenClass": "查看课程详情", From 2a75b1aaa01dd1292f4338df90eae96b3bc38157 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 25 Oct 2026 12:40:19 +0800 Subject: [PATCH 4/4] fix(logo): restore the original left-aligned startup --- src/core/logo.test.ts | 18 ++---------------- src/core/logo.ts | 23 +++++------------------ 2 files changed, 7 insertions(+), 34 deletions(-) diff --git a/src/core/logo.test.ts b/src/core/logo.test.ts index 3f253ef..493a185 100644 --- a/src/core/logo.test.ts +++ b/src/core/logo.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildLogoLines, startupOffset } from './logo.js'; +import { buildLogoLines } from './logo.js'; import { stripAnsi, visualWidth } from './text.js'; import { resetIconCache } from './icons.js'; @@ -17,18 +17,6 @@ describe('buildLogoLines', () => { }); }); -describe('startupOffset', () => { - it('starts the logo where the app frame starts its content', () => { - expect(startupOffset(80, 52)).toBe(3); - expect(startupOffset(160, 52)).toBe(23); - }); - - it('gives up the offset before letting the art overflow', () => { - expect(startupOffset(54, 52)).toBe(2); - expect(startupOffset(undefined, 52)).toBe(0); - }); -}); - describe('buildLogoLines dot-matrix tier selection', () => { const originalCols = process.stdout.columns; const originalRows = process.stdout.rows; @@ -102,9 +90,7 @@ describe('buildLogoLines display capabilities', () => { expect(lines.length).toBeGreaterThan(0); expect(lines.length).toBeLessThanOrEqual(rows); expect(lines.every((line) => /^[\x20-\x7e]*$/.test(line))).toBe(true); - expect( - lines.every((line) => visualWidth(line) + startupOffset(cols, visualWidth(line)) <= cols), - ).toBe(true); + expect(lines.every((line) => visualWidth(line) <= cols)).toBe(true); expect(lines.join('').replace(/\s/g, '')).toContain('intersectionoftechnologyandliberalarts'); expect(lines.join('')).toContain('@nbtca/prompt'); }); diff --git a/src/core/logo.ts b/src/core/logo.ts index 1561654..3dcd139 100644 --- a/src/core/logo.ts +++ b/src/core/logo.ts @@ -5,7 +5,7 @@ import chalk from 'chalk'; import { useUnicodeIcons } from './icons.js'; import { APP_INFO } from '../config/data.js'; import { typeReveal, materializeBraille, sleep } from './motion.js'; -import { brandGradient as brand, c, frameLeft, space } from './theme.js'; +import { brandGradient as brand, c } from './theme.js'; import { visualWidth, wrapAnsiWithIndent } from './text.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -34,8 +34,7 @@ function paint(text: string, color: boolean): string { function logoContent(): { art: string; metadata: string[] } | undefined { const cols = Number.isFinite(process.stdout.columns) ? process.stdout.columns : undefined; const rows = Number.isFinite(process.stdout.rows) ? process.stdout.rows : undefined; - const width = - cols === undefined ? Number.POSITIVE_INFINITY : Math.max(1, cols - startupOffset(cols, 0)); + const width = cols === undefined ? Number.POSITIVE_INFINITY : Math.max(1, cols); const color = !process.env['NO_COLOR']; const metadata = [ ...wrapAnsiWithIndent(TAGLINE, width, '').map((line) => paint(line, color)), @@ -78,17 +77,6 @@ export function buildLogoLines(): string[] { ]; } -export function startupOffset(cols: number | undefined, width: number): number { - if (cols === undefined) return 0; - return Math.max(0, Math.min(frameLeft(cols) + space.indent.length, cols - width)); -} - -function indentBlock(text: string, cols: number | undefined): string { - const lines = text.split('\n'); - const pad = ' '.repeat(startupOffset(cols, Math.max(...lines.map(visualWidth)))); - return lines.map((line) => pad + line).join('\n'); -} - function skipOnKeypress(): { signal: AbortSignal; release(): void } { const controller = new AbortController(); const stdin = process.stdin; @@ -118,16 +106,15 @@ export async function runStartup(): Promise { if (!process.stdout.isTTY) return; const content = logoContent(); if (!content) return; - const cols = process.stdout.columns; const color = !process.env['NO_COLOR']; const skip = skipOnKeypress(); process.stdout.write('\n'); try { - await materializeBraille(content.art, (s) => indentBlock(paint(s, color), cols), { - paintProgress: (s) => indentBlock(color ? c.brand(s) : s, cols), + await materializeBraille(content.art, (s) => paint(s, color), { + paintProgress: (s) => (color ? c.brand(s) : s), signal: skip.signal, }); - await typeReveal(['', ...content.metadata.map((line) => indentBlock(line, cols)), ''], { + await typeReveal(['', ...content.metadata, ''], { signal: skip.signal, }); await sleep(FINAL_FRAME_MS, skip.signal);