diff --git a/src/app/app.test.ts b/src/app/app.test.ts index a558d2a..d03f42a 100644 --- a/src/app/app.test.ts +++ b/src/app/app.test.ts @@ -140,4 +140,33 @@ describe('runApp terminal lifecycle', () => { expect(setRawMode.mock.calls).toEqual([[true], [false]]); expect(writtenOutput().split(ansi.leaveAlt)).toHaveLength(2); }); + + it('scrolls the help overlay and quits from it', 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 }); + const { t } = await import('../i18n/index.js'); + try { + const running = runApp(); + const start = writtenOutput().length; + process.stdin.emit('data', Buffer.from('?')); + const beforeScroll = writtenOutput().slice(start); + process.stdin.emit('data', Buffer.from('\x1b[F')); + const afterScroll = writtenOutput().slice(start + beforeScroll.length); + process.stdin.emit('data', Buffer.from('q')); + await running; + + expect(beforeScroll).toContain(t().help.title); + expect(beforeScroll).not.toContain(t().help.quit); + expect(afterScroll).toContain(t().help.quit); + expect(afterScroll).toContain('100%'); + expect(signal?.aborted).toBe(true); + } 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 3edc4a7..9fe9d27 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,6 +1,13 @@ import { ansi, ensureCursorRestored } from '../core/canvas.js'; -import { composeFrameLines, computeBodyRows, diffFrame, frameWidth } from './frame.js'; -import { isPrintableKey, KeyStreamDecoder, routeGlobalKey, type ViewId } from './keys.js'; +import { composeFrameLines, computeBodyRows, diffFrame } from './frame.js'; +import { frameWidth } from '../core/theme.js'; +import { + isPrintableKey, + KeyStreamDecoder, + routeGlobalKey, + type GlobalKeyResult, + type ViewId, +} from './keys.js'; import { renderHeader, renderFooter, resolveChromeLayout } from './chrome.js'; import type { AppContext, AppSize, View } from './view.js'; import { homeView } from './views/home.js'; @@ -105,7 +112,7 @@ export async function runApp(): Promise { tabs.length, helpOpen ? t().help.close : active?.footerHint?.(tabs.length, cols), chrome.footerLines, - !helpOpen && active?.scrollsBody?.() === true ? scrollPercent() : undefined, + helpOpen || active?.scrollsBody?.() === true ? scrollPercent() : undefined, ); const lines = composeFrameLines(header, body, footer, rows, terminalCols, bodyScroll); const patch = diffFrame(painted?.cols === terminalCols ? painted.lines : undefined, lines); @@ -154,15 +161,8 @@ export async function runApp(): Promise { render(); return; } - if (helpOpen) { - if (key === '?' || key === '\x1b') { - helpOpen = false; - render(); - } - return; - } - if (key === '?') { - helpOpen = true; + if (key === '?' || (helpOpen && key === '\x1b')) { + helpOpen = !helpOpen; scroll = 0; render(); return; @@ -172,6 +172,10 @@ export async function runApp(): Promise { quit(); return; } + if (helpOpen) { + scrollBody(g); + return; + } if (g.back) { if (active?.handleBack?.(ctx)) { scroll = 0; // the new sub-view's content height has nothing to do with the old one's @@ -187,27 +191,26 @@ export async function runApp(): Promise { switchTo(g.switchTo); return; } - if (g.scrollBy) { - if (active?.capturesPageKeys?.()) { - active.handleKey?.(key, ctx); - scroll = 0; - render(); - return; - } - const page = Math.max(1, ctx.bodyRows - 2); - scrollTo(scroll + g.scrollBy * page); - return; - } - if ((g.scrollLines !== undefined || g.scrollTo !== undefined) && active?.scrollsBody?.()) { - if (g.scrollTo === 'top') scrollTo(0); - else if (g.scrollTo === 'end') scrollTo(maxScroll()); - else scrollTo(scroll + (g.scrollLines ?? 0)); + if (g.scrollBy && active?.capturesPageKeys?.()) { + active.handleKey?.(key, ctx); + scroll = 0; + render(); return; } + if ((g.scrollBy || active?.scrollsBody?.()) && scrollBody(g)) return; active?.handleKey?.(key, ctx); render(); } + function scrollBody(g: GlobalKeyResult): boolean { + if (g.scrollBy) scrollTo(scroll + g.scrollBy * Math.max(1, ctx.bodyRows - 2)); + else if (g.scrollTo === 'top') scrollTo(0); + else if (g.scrollTo === 'end') scrollTo(maxScroll()); + else if (g.scrollLines !== undefined) scrollTo(scroll + g.scrollLines); + else return false; + return true; + } + function dispatchKeys(keys: readonly string[]): void { for (let index = 0; index < keys.length && running && !suspended; index += 1) { let key = keys[index]; diff --git a/src/app/chrome.test.ts b/src/app/chrome.test.ts index 50c193a..5d61766 100644 --- a/src/app/chrome.test.ts +++ b/src/app/chrome.test.ts @@ -8,7 +8,7 @@ import { passiveFooterHint, resolveChromeLayout, } from './chrome.js'; -import { setLanguage } from '../i18n/index.js'; +import { setLanguage, t } from '../i18n/index.js'; import { resetIconCache } from '../core/icons.js'; import { stripAnsi, visualWidth } from '../core/text.js'; @@ -81,7 +81,7 @@ describe('renderHeader', () => { describe('offlineNotice', () => { it('leads with a warning icon and leaves a blank line before the content', () => { const lines = offlineNotice(80); - expect(lines.map(stripAnsi)).toEqual([' ! Offline, showing last fetched data', '']); + expect(lines.map(stripAnsi)).toEqual([' ! Offline, showing cached data', '']); }); it('keeps its indent on one line at forty columns in both languages', () => { @@ -123,6 +123,12 @@ describe('renderContextPath', () => { expect(visualWidth(line)).toBeLessThanOrEqual(60); }); + it('ends where the rule ends', () => { + const line = renderContextPath(['Docs', 'About NBTCA', 'Infrastructure'], 40); + expect(visualWidth(line)).toBeLessThanOrEqual(37); + expect(stripAnsi(line)).toContain('Infrastructure'); + }); + it('drops leading segments before truncating the tail', () => { const line = stripAnsi(renderContextPath(['Docs', 'Guides', 'Second classroom'], 30)); expect(line).toContain('Second classroom'); @@ -156,12 +162,12 @@ describe('renderFooter', () => { }); it('drops the position rather than crowding the hint', () => { - const narrow = renderFooter('docs', 18, 5, 'PgUp/PgDn', 1, '40%')[0] ?? ''; + const narrow = renderFooter('docs', 16, 5, 'PgUp/PgDn', 1, '40%')[0] ?? ''; expect(stripAnsi(narrow)).not.toContain('40%'); }); it('renders a keyhint line', () => { - const f = renderFooter('home', 40, 5).map(stripAnsi).join(' '); + const f = renderFooter('home', 80, 5).map(stripAnsi).join(' '); expect(f).toMatch(/q/); expect(f).toMatch(/quit|Quit|退出/i); done(); @@ -181,33 +187,42 @@ describe('renderFooter', () => { expect(f).not.toMatch(/\d-\d/); }); - it('keeps the complete interactive hint within 40 columns', () => { - const hint = stripAnsi(renderFooter('settings', 40, 5)[1] ?? ''); - expect(visualWidth(hint)).toBeLessThanOrEqual(40); - expect(hint).toContain('1-5/Tab'); - expect(hint).toContain('move'); - expect(hint).toContain('open'); - expect(hint).toContain('quit'); + it('names the key for every action it offers', () => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); + expect(stripAnsi(renderFooter('settings', 120, 5)[1] ?? '').trim()).toBe( + '1-5 / Tab switch · ↑↓ move · ⏎ open · Esc back · q quit · ? keys', + ); + }); + + it('keeps the keys and their labels within 40 columns in both languages', () => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); + for (const language of ['en', 'zh'] as const) { + setLanguage(language); + const hint = stripAnsi(renderFooter('settings', 40, 5)[1] ?? ''); + expect(visualWidth(hint)).toBeLessThanOrEqual(37); + expect(hint).toContain(`↑↓ ${t().menu.hintMove}`); + expect(hint).toContain(`⏎ ${t().menu.hintOpen}`); + expect(hint).toContain(`q ${t().menu.hintQuit}`); + } + setLanguage('en'); }); - it('prioritizes view-local controls over tab switching at 20 columns', () => { + it('prioritizes view-local keys over tab switching at 20 columns', () => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); const hint = stripAnsi(renderFooter('settings', 20, 5)[1] ?? ''); expect(visualWidth(hint)).toBeLessThanOrEqual(20); - expect(hint).toContain('move'); - expect(hint).toContain('open'); - expect(hint).toContain('Esc'); - expect(hint).toContain('q'); - expect(hint).not.toContain('Tab'); + expect(hint.trim()).toBe('↑↓ ⏎ Esc q'); }); - it('keeps tab switching only when every local control also fits', () => { + it('keeps tab switching only when every local key also fits', () => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); const hint = stripAnsi(renderFooter('settings', 28, 5)[1] ?? ''); expect(visualWidth(hint)).toBeLessThanOrEqual(28); - expect(hint).toContain('1-5/Tab'); - expect(hint).toContain('move'); - expect(hint).toContain('open'); - expect(hint).toContain('Esc'); - expect(hint).toContain('q'); + expect(hint.trim()).toBe('1-5/Tab · ↑↓ ⏎ Esc q'); }); }); @@ -223,6 +238,12 @@ describe('passiveFooterHint', () => { expect(hint).not.toMatch(/\d-\d/); }); + it('leaves out Esc where there is nothing to go back to', () => { + const hint = stripAnsi(passiveFooterHint(5, 120, false)); + expect(hint).not.toContain('Esc'); + expect(hint).toContain('q quit'); + }); + it('fits navigation and exit keys within 20 columns', () => { const hint = stripAnsi(renderFooter('home', 20, 5, passiveFooterHint(5, 20))[1] ?? ''); expect(visualWidth(hint)).toBeLessThanOrEqual(20); diff --git a/src/app/chrome.ts b/src/app/chrome.ts index 843762a..0a4939e 100644 --- a/src/app/chrome.ts +++ b/src/app/chrome.ts @@ -1,4 +1,4 @@ -import { c, type, space, glyph, brandMark } from '../core/theme.js'; +import { c, type, space, glyph, brandMark, bodyEdge } from '../core/theme.js'; import { pickIcon } from '../core/icons.js'; import { t } from '../i18n/index.js'; import type { ViewId } from './keys.js'; @@ -25,7 +25,7 @@ export function resolveChromeLayout(rows: number): ChromeLayout { 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, width - visualWidth(indent) * 2); + const ruleWidth = Math.max(1, bodyEdge(width) - visualWidth(indent)); return indent + type.hint(glyph.rule().repeat(ruleWidth)); } @@ -73,7 +73,9 @@ function renderTabs(views: { id: ViewId; title: string }[], active: ViewId, cols export function renderContextPath(segments: readonly string[], cols: number): string { const chevron = pickIcon('›', '>'); const ellipsis = pickIcon('…', '...'); - const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; + const width = Number.isFinite(cols) + ? bodyEdge(Math.max(1, Math.floor(cols))) + : Number.POSITIVE_INFINITY; for (let start = 0; start < segments.length; start += 1) { const shown = segments.slice(start); const last = shown.length - 1; @@ -117,7 +119,7 @@ export function offlineNotice(cols: number): string[] { export function fitFooterHint(cols: number, ...candidates: string[]): string { return ( - candidates.find((candidate) => visualWidth(space.indent + candidate) <= cols) ?? + candidates.find((candidate) => visualWidth(space.indent + candidate) <= bodyEdge(cols)) ?? candidates[candidates.length - 1] ?? '' ); @@ -140,17 +142,23 @@ export function digitTabHint(tabCount: number): string { return tabCount > 1 ? `1-${tabCount} / Tab ${t().menu.hintTabs} ${dot} ` : ''; } -export function passiveFooterHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string { +export function passiveFooterHint( + tabCount: number, + cols = Number.POSITIVE_INFINITY, + canGoBack = true, +): string { const trans = t(); const dot = pickIcon('·', '-'); const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : ''; + const back = canGoBack ? `Esc ${trans.menu.hintBack} ${dot} ` : ''; + const backKey = canGoBack ? `Esc ${dot} ` : ''; return fitFooterHint( cols, - `${digitTabHint(tabCount)}Esc ${trans.menu.hintBack} ${dot} q ${trans.menu.hintQuit} ${dot} ${trans.help.hint}`, - `${digitTabHint(tabCount)}Esc ${trans.menu.hintBack} ${dot} q ${trans.menu.hintQuit}`, - `${compactTabs}Esc ${dot} q ${dot} ?`, - `Esc ${dot} q ${dot} ?`, - `Esc ${dot} q`, + `${digitTabHint(tabCount)}${back}q ${trans.menu.hintQuit} ${dot} ${trans.help.hint}`, + `${digitTabHint(tabCount)}${back}q ${trans.menu.hintQuit}`, + `${compactTabs}${backKey}q ${dot} ?`, + `${backKey}q ${dot} ?`, + `${backKey}q`, 'q', ); } @@ -158,23 +166,23 @@ export function passiveFooterHint(tabCount: number, cols = Number.POSITIVE_INFIN function interactiveFooterHint(tabCount: number, cols: number): string { const trans = t(); const dot = pickIcon('·', '-'); - const fullTabs = digitTabHint(tabCount); const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : ''; - const localLabelled = `${trans.menu.hintMove} ${dot} ${trans.menu.hintOpen} ${dot} Esc ${trans.menu.hintBack} ${dot} q ${trans.menu.hintQuit}`; - const localFull = `${trans.menu.hintMove} ${dot} ${trans.menu.hintOpen} ${dot} Esc ${dot} q ${trans.menu.hintQuit}`; - const localCompact = `${trans.menu.hintMove} ${trans.menu.hintOpen} Esc q`; - const candidates = [ - `${fullTabs}${localLabelled} ${dot} ${trans.help.hint}`, - `${fullTabs}${localLabelled}`, - `${compactTabs}${localFull}`, - localFull, - `${compactTabs}${localCompact}`, - localCompact, - `${trans.menu.hintOpen} Esc q`, + const move = `${glyph.updown()} ${trans.menu.hintMove} ${dot} ${glyph.enter()} ${trans.menu.hintOpen}`; + const quit = `q ${trans.menu.hintQuit}`; + const local = `${move} ${dot} Esc ${trans.menu.hintBack} ${dot} ${quit}`; + const keys = `${glyph.updown()} ${glyph.enter()} Esc q`; + return fitFooterHint( + cols, + `${digitTabHint(tabCount)}${local} ${dot} ${trans.help.hint}`, + `${digitTabHint(tabCount)}${local}`, + `${compactTabs}${local}`, + local, + `${move} ${dot} Esc ${dot} ${quit}`, + `${compactTabs}${keys}`, + keys, `Esc ${dot} q`, 'q', - ]; - return fitFooterHint(cols, ...candidates); + ); } export function renderFooter( @@ -203,8 +211,8 @@ function withPosition( cols: number, ): string { if (position === undefined) return hint; - const margin = visualWidth(space.indent) < cols ? space.indent : ''; - const gap = cols - visualWidth(indent + hintText) - visualWidth(position) - visualWidth(margin); + const edge = bodyEdge(cols); + const gap = edge - visualWidth(indent + hintText) - visualWidth(position); if (gap < 2) return hint; - return hint + ' '.repeat(gap) + type.hint(position) + margin; + return hint + ' '.repeat(gap) + type.hint(position) + ' '.repeat(cols - edge); } diff --git a/src/app/fields/list-field.ts b/src/app/fields/list-field.ts index f361003..f121da6 100644 --- a/src/app/fields/list-field.ts +++ b/src/app/fields/list-field.ts @@ -6,7 +6,7 @@ import { parseKey, type MenuOption, } from '../../core/components/menu.js'; -import { glyph, space, type } from '../../core/theme.js'; +import { bodyEdge, glyph, space, type } from '../../core/theme.js'; import { pickIcon } from '../../core/icons.js'; import { t, fmt } from '../../i18n/index.js'; import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js'; @@ -35,7 +35,7 @@ function renderIndentedOutput( ): string[] { const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; const indent = [preferredIndent, space.indent].find((i) => visualWidth(i) < width) ?? ''; - const contentWidth = Math.max(1, width - visualWidth(indent)); + const contentWidth = Math.max(1, bodyEdge(width) - visualWidth(indent)); return wrapAnsiToVisualWidth(value, contentWidth).map((line) => `${indent}${line}`); } diff --git a/src/app/frame.test.ts b/src/app/frame.test.ts index 3fc2ad9..fec0851 100644 --- a/src/app/frame.test.ts +++ b/src/app/frame.test.ts @@ -1,14 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { - fitLine, - fitBody, - composeFrameLines, - computeBodyRows, - diffFrame, - frameWidth, -} from './frame.js'; +import { fitLine, fitBody, composeFrameLines, computeBodyRows, diffFrame } from './frame.js'; import { visualWidth } from '../core/text.js'; -import { MAX_FRAME_COLS } from '../core/theme.js'; +import { frameWidth, MAX_FRAME_COLS } from '../core/theme.js'; describe('fitLine', () => { it('pads a short line to exactly cols', () => { diff --git a/src/app/frame.ts b/src/app/frame.ts index 8c39126..3bc692f 100644 --- a/src/app/frame.ts +++ b/src/app/frame.ts @@ -1,5 +1,5 @@ import { ansi } from '../core/canvas.js'; -import { MAX_FRAME_COLS } from '../core/theme.js'; +import { frameLeft, frameWidth } from '../core/theme.js'; import { clipAnsiToVisualWidth, visualWidth } from '../core/text.js'; export function clipToWidth(line: string, cols: number): string { @@ -21,10 +21,6 @@ export function fitBody(lines: string[], height: number, scroll: number, cols: n return out; } -export function frameWidth(cols: number): number { - return Math.min(cols, MAX_FRAME_COLS); -} - export function composeFrameLines( header: string[], body: string[], @@ -34,7 +30,7 @@ export function composeFrameLines( scroll: number, ): string[] { const width = frameWidth(cols); - const left = ' '.repeat(Math.floor((cols - width) / 2)); + const left = ' '.repeat(frameLeft(cols)); const right = ' '.repeat(cols - width - left.length); const h = header.map((l) => fitLine(l, width)); const f = footer.map((l) => fitLine(l, width)); diff --git a/src/app/help.ts b/src/app/help.ts index 4d316fc..d2a4655 100644 --- a/src/app/help.ts +++ b/src/app/help.ts @@ -1,4 +1,4 @@ -import { type, space, glyph } from '../core/theme.js'; +import { type, space, glyph, bodyEdge } from '../core/theme.js'; import { pickIcon } from '../core/icons.js'; import { t } from '../i18n/index.js'; import { padEndV, visualWidth, wrapAnsiWithIndent } from '../core/text.js'; @@ -11,7 +11,7 @@ export interface Shortcut { function row(shortcut: Shortcut, keyWidth: number, cols: number): string[] { const key = padEndV(type.label(shortcut.key), keyWidth); const line = `${space.indent}${space.indent}${key} ${type.hint(shortcut.label)}`; - if (visualWidth(line) <= cols) return [line]; + if (visualWidth(line) <= bodyEdge(cols)) return [line]; return wrapAnsiWithIndent( `${type.label(shortcut.key)} ${type.hint(shortcut.label)}`, cols, diff --git a/src/app/views/docs-render.ts b/src/app/views/docs-render.ts index d8c49e0..1a0afa5 100644 --- a/src/app/views/docs-render.ts +++ b/src/app/views/docs-render.ts @@ -1,4 +1,4 @@ -import { type, space } from '../../core/theme.js'; +import { type, space, bodyEdge } from '../../core/theme.js'; import { t } from '../../i18n/index.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; import { offlineNotice } from '../chrome.js'; @@ -42,7 +42,7 @@ function hintLines(label: string, cols: number): string[] { } function renderReader(render: (width: number) => string[], cols: number): string[] { - const contentWidth = Math.max(1, Math.min(80, cols - visualWidth(space.indent))); + const contentWidth = Math.max(1, Math.min(80, bodyEdge(cols) - visualWidth(space.indent))); return render(contentWidth).map((line) => (line ? `${space.indent}${line}` : '')); } diff --git a/src/app/views/docs.test.ts b/src/app/views/docs.test.ts index b90d074..75d6909 100644 --- a/src/app/views/docs.test.ts +++ b/src/app/views/docs.test.ts @@ -383,7 +383,7 @@ describe('docsView native reader (no shell-out to less/glow)', () => { await loading; const out = stripAnsi(freshDocsView.render(ctx).join('\n')); expect(out).toContain('Guide'); - expect(out).toContain('Offline, showing last fetched data'); + expect(out).toContain(t().common.offline); }); it('keeps the reader open when the background refresh lands', async () => { diff --git a/src/app/views/events-render.test.ts b/src/app/views/events-render.test.ts index 9b15685..ca2f82a 100644 --- a/src/app/views/events-render.test.ts +++ b/src/app/views/events-render.test.ts @@ -21,8 +21,8 @@ describe('renderEvents', () => { it.each([ ['en', 'loading', 'Loading event calendar...', 2], - ['en', 'heatmap', 'No upcoming events', 1], - ['zh', 'loading', '正在获取活动日历...', 1], + ['en', 'heatmap', 'No upcoming events', 2], + ['zh', 'loading', '正在获取活动日历...', 2], ['zh', 'heatmap', '近期暂无活动安排', 1], ] as const)( 'wraps the complete %s %s status within twenty columns', diff --git a/src/app/views/home.test.ts b/src/app/views/home.test.ts index 1ffaef4..98521b1 100644 --- a/src/app/views/home.test.ts +++ b/src/app/views/home.test.ts @@ -83,6 +83,10 @@ describe('homeView footer', () => { expect(hint).not.toContain(t().menu.hintMove); expect(hint).not.toContain(t().menu.hintOpen); }); + + it('does not offer Esc to go back from the root tab', () => { + expect(stripAnsi(homeView.footerHint(5, 80))).not.toContain('Esc'); + }); }); describe('renderHome (schedule-first dashboard)', () => { diff --git a/src/app/views/home.ts b/src/app/views/home.ts index 7691fb1..65f5fb3 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -248,7 +248,7 @@ export const homeView = { title: 'Home', footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string { - return passiveFooterHint(tabCount, cols); + return passiveFooterHint(tabCount, cols, false); }, async load(ctx: AppContext): Promise { diff --git a/src/app/views/settings-render.ts b/src/app/views/settings-render.ts index df65665..1527c18 100644 --- a/src/app/views/settings-render.ts +++ b/src/app/views/settings-render.ts @@ -2,7 +2,7 @@ import { space, type } from '../../core/theme.js'; import { wrapAnsiWithIndent } from '../../core/text.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; -export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'about'; +export type SettingsMode = 'menu' | 'language' | 'icon' | 'color' | 'reset' | 'about'; export interface SettingsViewState { mode: SettingsMode; @@ -40,6 +40,7 @@ export function renderSettings( case 'language': case 'icon': case 'color': + case 'reset': return state.subField?.render(bodyRows, cols) ?? []; case 'about': { const context = [ diff --git a/src/app/views/settings.test.ts b/src/app/views/settings.test.ts index 3ee0b3c..6c5b02b 100644 --- a/src/app/views/settings.test.ts +++ b/src/app/views/settings.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { settingsView } from './settings.js'; import { setLanguage } from '../../i18n/index.js'; import { resetIconCache } from '../../core/icons.js'; @@ -6,6 +9,7 @@ import { stripAnsi, visualWidth } from '../../core/text.js'; import type { AppContext } from '../view.js'; beforeAll(() => { + process.env['XDG_CONFIG_HOME'] = mkdtempSync(join(tmpdir(), 'nbtca-settings-')); setLanguage('en'); process.env['NBTCA_ICON_MODE'] = 'unicode'; resetIconCache(); @@ -57,7 +61,7 @@ describe('settingsView', () => { expect(settingsView.handleBack()).toBe(true); const menuOut = stripAnsi(settingsView.render(ctx).join('\n')); - expect(menuOut).toContain('Icon mode'); + expect(menuOut).toContain('Icon Mode'); }); it('aligns every Chinese about value on the same visual column', async () => { @@ -83,4 +87,56 @@ describe('settingsView', () => { await settingsView.load(); } }); + + function selectedLabel(ctx: AppContext): string { + const line = settingsView + .render(ctx) + .map(stripAnsi) + .find((candidate) => candidate.includes('→')); + return ( + line + ?.replace('→', '') + .trim() + .split(/\s{2,}/u)[0] ?? '' + ); + } + + it('keeps the cursor on the entry the user came back from', async () => { + const ctx = fakeCtx(); + await settingsView.load(); + settingsView.handleKey('\x1b[B'); + settingsView.handleKey('\x1b[B'); + settingsView.handleKey('\r'); + settingsView.handleBack(); + expect(selectedLabel(ctx)).toBe('Color Mode'); + + settingsView.handleKey('\x1b[F'); + settingsView.handleKey('\r'); + settingsView.handleKey('\r'); + expect(selectedLabel(ctx)).toBe('About'); + }); + + 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'); + settingsView.handleKey('\r'); + const confirm = stripAnsi(settingsView.render(ctx).join('\n')); + expect(confirm).toContain('Reset icon mode and color mode to their defaults?'); + expect(selectedLabel(ctx)).toBe('Cancel'); + + settingsView.handleKey('\r'); + expect(selectedLabel(ctx)).toBe('Reset to Defaults'); + expect(stripAnsi(settingsView.render(ctx).join('\n'))).not.toContain('reset'); + }); + + it('names the setting that changed', async () => { + const ctx = fakeCtx(); + await settingsView.load(); + settingsView.handleKey('\x1b[B'); + settingsView.handleKey('\r'); + settingsView.handleKey('\r'); + expect(stripAnsi(settingsView.render(ctx).join('\n'))).toMatch(/Icon mode (updated|changed)/u); + expect(selectedLabel(ctx)).toBe('Icon Mode'); + }); }); diff --git a/src/app/views/settings.ts b/src/app/views/settings.ts index 040af81..b213a2b 100644 --- a/src/app/views/settings.ts +++ b/src/app/views/settings.ts @@ -10,7 +10,7 @@ import { type ColorMode, type IconMode, } from '../../config/preferences.js'; -import { resetIconCache, pickIcon } from '../../core/icons.js'; +import { resetIconCache } from '../../core/icons.js'; import { APP_INFO, URLS } from '../../config/data.js'; import { t, @@ -48,7 +48,7 @@ function modeOptions(modes: readonly T[], curren })); } -function buildMenuField(statusMessage?: string): SettingsViewState { +function buildMenuField(statusMessage?: string, focus?: string): SettingsViewState { const trans = t(); const prefs = loadPreferences(); const currentLang = getCurrentLanguage(); @@ -66,12 +66,18 @@ function buildMenuField(statusMessage?: string): SettingsViewState { return { mode: 'menu', ...(statusMessage === undefined ? {} : { statusMessage }), - menuField: new ListField({ options }), + menuField: new ListField({ + options, + initialIndex: Math.max( + 0, + options.findIndex((option) => option.value === focus), + ), + }), }; } function goToMenu(statusMessage?: string): void { - state = buildMenuField(statusMessage); + state = buildMenuField(statusMessage, state.mode); } export const settingsView = { @@ -168,11 +174,17 @@ export const settingsView = { return; } if (result.selected === 'reset') { - const saved = resetPreferences(); - resetIconCache(); - applyColorModePreference(false); - resetCapabilities(); - goToMenu(saved ? trans.theme.reset : trans.theme.resetSessionOnly); + state = { + mode: 'reset', + subField: new ListField({ + title: trans.theme.resetConfirm, + options: [ + { value: 'reset', label: trans.theme.resetLabel }, + { value: 'cancel', label: trans.common.cancel }, + ], + initialIndex: 1, + }), + }; return; } if (result.selected === 'about') { @@ -191,10 +203,8 @@ export const settingsView = { row(trans.about.website, URLS.homepage), row(trans.about.email, URLS.email), '', - row( - trans.about.license, - `MIT ${pickIcon('·', '-')} ${trans.about.author}: m1ngsama`, - ), + row(trans.about.license, 'MIT'), + row(trans.about.author, 'm1ngsama'), ], backField: new ListField({ title: '', @@ -223,7 +233,7 @@ export const settingsView = { const saved = setIconMode(result.selected as IconMode); resetIconCache(); resetCapabilities(); - goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly); + goToMenu(saved ? trans.theme.iconUpdated : trans.theme.iconUpdatedSessionOnly); return; } case 'color': { @@ -232,7 +242,21 @@ export const settingsView = { const saved = setColorMode(result.selected as ColorMode); applyColorModePreference(false); resetCapabilities(); - goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly); + goToMenu(saved ? trans.theme.colorUpdated : trans.theme.colorUpdatedSessionOnly); + return; + } + case 'reset': { + const result = state.subField?.handleKey(key); + if (!result?.selected) return; + if (result.selected !== 'reset') { + goToMenu(); + return; + } + const saved = resetPreferences(); + resetIconCache(); + applyColorModePreference(false); + resetCapabilities(); + goToMenu(saved ? trans.theme.reset : trans.theme.resetSessionOnly); return; } case 'about': { diff --git a/src/core/components/menu.test.ts b/src/core/components/menu.test.ts index 4e953ea..c37415e 100644 --- a/src/core/components/menu.test.ts +++ b/src/core/components/menu.test.ts @@ -201,3 +201,14 @@ describe('renderMenu with a hint column', () => { expect(out.split('\n')[1]).toMatch(/\x1b\[2m/); }); }); + +describe('renderMenu width', () => { + it('ends every option where the rule ends', () => { + const options = [{ value: 'a', label: 'word '.repeat(20).trim(), hint: 'hint' }]; + const lines = renderMenu({ title: 'word '.repeat(16).trim(), options, selectedIndex: 0 }, 80) + .split('\n') + .filter(Boolean); + expect(lines.length).toBeGreaterThan(2); + for (const line of lines) expect(visualWidth(line)).toBeLessThanOrEqual(77); + }); +}); diff --git a/src/core/components/menu.ts b/src/core/components/menu.ts index a4d85b0..a4caad5 100644 --- a/src/core/components/menu.ts +++ b/src/core/components/menu.ts @@ -1,4 +1,4 @@ -import { glyph, type, space } from '../theme.js'; +import { glyph, type, space, bodyEdge } from '../theme.js'; import { visualWidth, padEndV, wrapAnsiToVisualWidth, clipAnsiToVisualWidth } from '../text.js'; import { pickIcon } from '../icons.js'; import { createPainter } from './painter.js'; @@ -107,7 +107,7 @@ function renderIndentedText( ): string[] { const width = normalizedWidth(cols); const indent = visualWidth(space.indent) < width ? space.indent : ''; - const contentWidth = Math.max(1, width - visualWidth(indent)); + const contentWidth = Math.max(1, bodyEdge(width) - visualWidth(indent)); return wrapAnsiToVisualWidth(style(label), contentWidth).map((line) => `${indent}${line}`); } @@ -163,7 +163,7 @@ export function renderMenuOption( const prefixes = [`${space.indent}${marker} `, `${marker} `, marker, '']; const prefix = prefixes.find((candidate) => visualWidth(candidate) < width) ?? ''; const continuation = ' '.repeat(visualWidth(prefix)); - const contentWidth = Math.max(1, width - 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 hint = cells.hint ? ` ${type.hint(cells.hint)}` : ''; diff --git a/src/core/logo.test.ts b/src/core/logo.test.ts index 0e77099..b8212d2 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, startupFitsTerminal } from './logo.js'; +import { buildLogoLines, startupFitsTerminal, startupOffset } from './logo.js'; import { stripAnsi } from './text.js'; import { resetIconCache } from './icons.js'; @@ -26,6 +26,18 @@ describe('startupFitsTerminal', () => { }); }); +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; diff --git a/src/core/logo.ts b/src/core/logo.ts index 105ebb1..d6546a1 100644 --- a/src/core/logo.ts +++ b/src/core/logo.ts @@ -4,13 +4,14 @@ import { dirname, join } from 'path'; import chalk from 'chalk'; import { useUnicodeIcons } from './icons.js'; import { APP_INFO } from '../config/data.js'; -import { typeReveal, materializeBraille } from './motion.js'; -import { brandGradient as brand, c } from './theme.js'; +import { typeReveal, materializeBraille, sleep } from './motion.js'; +import { brandGradient as brand, c, frameLeft, space } from './theme.js'; import { visualWidth } from './text.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TAGLINE = 'To be at the intersection of technology and liberal arts.'; +const FINAL_FRAME_MS = 400; function readArt(file: string): string | null { try { @@ -67,6 +68,17 @@ 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; @@ -96,18 +108,25 @@ export async function runStartup(): Promise { if (!process.stdout.isTTY) return; const art = loadArt(); if (!startupFitsTerminal(process.stdout.rows, process.stdout.columns, art)) return; + const cols = process.stdout.columns; const color = !process.env['NO_COLOR']; const skip = skipOnKeypress(); process.stdout.write('\n'); try { - await materializeBraille(art, (s) => paint(s, color), { - paintProgress: (s) => (color ? c.brand(s) : s), + await materializeBraille(art, (s) => indentBlock(paint(s, color), cols), { + paintProgress: (s) => indentBlock(color ? c.brand(s) : s, cols), signal: skip.signal, }); await typeReveal( - ['', color ? brand(TAGLINE) : TAGLINE, chalk.dim(`@nbtca/prompt v${APP_INFO.version}`), ''], + [ + '', + indentBlock(color ? brand(TAGLINE) : TAGLINE, cols), + indentBlock(chalk.dim(`@nbtca/prompt v${APP_INFO.version}`), cols), + '', + ], { signal: skip.signal }, ); + await sleep(FINAL_FRAME_MS, skip.signal); } finally { skip.release(); } diff --git a/src/core/text.test.ts b/src/core/text.test.ts index 650a4bb..15cd1c9 100644 --- a/src/core/text.test.ts +++ b/src/core/text.test.ts @@ -166,6 +166,16 @@ describe('wrapAnsiToVisualWidth kinsoku', () => { }); describe('wrapAnsiWithIndent', () => { + it('keeps the indent and wraps before the right margin', () => { + const lines = wrapAnsiWithIndent('word '.repeat(16).trim(), 80, ' '); + + expect(lines).toHaveLength(2); + for (const line of lines) { + expect(line.startsWith(' ')).toBe(true); + expect(visualWidth(line)).toBeLessThanOrEqual(77); + } + }); + it('drops an indent that would overflow a wide grapheme', () => { const lines = wrapAnsiWithIndent('界abc', 4, ' '); diff --git a/src/core/text.ts b/src/core/text.ts index 8572055..334e393 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -1,3 +1,5 @@ +import { bodyEdge } from './theme.js'; + const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); const MARK_RE = /^\p{Mark}$/u; const EMOJI_PRESENTATION_RE = /\p{Emoji_Presentation}/u; @@ -359,18 +361,10 @@ export function wrapAnsiToVisualWidth( export function wrapAnsiWithIndent(str: string, maxWidth: number, preferredIndent = ''): string[] { const width = Number.isFinite(maxWidth) - ? Math.max(1, Math.floor(maxWidth)) + ? bodyEdge(Math.max(1, Math.floor(maxWidth))) : Number.POSITIVE_INFINITY; - const indentWidth = visualWidth(preferredIndent); - const contentWidth = visualWidth(str); - let indent = - indentWidth >= width || (contentWidth > width - indentWidth && contentWidth <= width) - ? '' - : preferredIndent; - let lines = wrapAnsiToVisualWidth(str, Math.max(1, width - visualWidth(indent))); - if (indent && lines.some((line) => visualWidth(indent + line) > width)) { - indent = ''; - lines = wrapAnsiToVisualWidth(str, width); - } - return lines.map((line) => `${indent}${line}`); + const indent = visualWidth(preferredIndent) + 2 <= width ? preferredIndent : ''; + return wrapAnsiToVisualWidth(str, Math.max(1, width - visualWidth(indent))).map( + (line) => `${indent}${line}`, + ); } diff --git a/src/core/theme.ts b/src/core/theme.ts index f2f08d1..6b15f10 100644 --- a/src/core/theme.ts +++ b/src/core/theme.ts @@ -79,6 +79,20 @@ export const space = { indent: ' ', } as const; +export function frameWidth(cols: number): number { + return Math.min(cols, MAX_FRAME_COLS); +} + +export function frameLeft(cols: number): number { + return Math.floor((cols - frameWidth(cols)) / 2); +} + +const RIGHT_MARGIN_MIN_COLS = 40; + +export function bodyEdge(cols: number): number { + return cols >= RIGHT_MARGIN_MIN_COLS ? cols - space.indent.length : cols; +} + export const type = { heading: (s: string) => chalk.bold(s), label: (s: string) => s, diff --git a/src/features/about.ts b/src/features/about.ts index 67b4579..39874a6 100644 --- a/src/features/about.ts +++ b/src/features/about.ts @@ -1,7 +1,6 @@ import chalk from 'chalk'; import { APP_INFO, URLS } from '../config/data.js'; import { note } from '../core/components/note.js'; -import { pickIcon } from '../core/icons.js'; import { padEndV } from '../core/text.js'; import { t } from '../i18n/index.js'; @@ -18,7 +17,8 @@ export function showAbout(): void { link(trans.about.website, URLS.homepage), link(trans.about.email, URLS.email), '', - row(trans.about.license, `MIT ${pickIcon('·', '|')} ${trans.about.author}: m1ngsama`), + row(trans.about.license, 'MIT'), + row(trans.about.author, 'm1ngsama'), ].join('\n'); note(content, trans.about.title); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 5b52efd..23fb7a5 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -357,6 +357,11 @@ export interface Translations { reset: string; resetLabel: string; resetSessionOnly: string; + resetConfirm: string; + iconUpdated: string; + iconUpdatedSessionOnly: string; + colorUpdated: string; + colorUpdatedSessionOnly: string; usage: string; invalidValue: string; }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 21199b1..2c4e574 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -11,7 +11,7 @@ "current": "current", "moreAbove": "{count} more above", "moreBelow": "{count} more below", - "offline": "Offline, showing last fetched data" + "offline": "Offline, showing cached data" }, "help": { "title": "Keys", @@ -37,7 +37,7 @@ "status": "Status", "statusDesc": "Online status", "timetable": "My Timetable", - "timetableDesc": "Sign in to JWXT, choose a term and export ICS", + "timetableDesc": "Log in to JWXT, choose a term and export ICS", "links": "Links", "linksDesc": "website · GitHub · roadmap", "settings": "Settings", @@ -209,8 +209,8 @@ "menuTitle": "My Timetable", "actionExport": "Export ICS", "actionTerms": "List terms", - "actionLogin": "Sign in or refresh session", - "actionLogout": "Sign out", + "actionLogin": "Log in or refresh session", + "actionLogout": "Log out", "actionStatus": "Login status", "termPrompt": "Term code", "termPromptHint": "Leave empty for current, for example 2026:3", @@ -224,7 +224,7 @@ "sessionRefreshFailed": "The operation completed, but the saved session expiry could not be refreshed.", "candidateTerms": "Candidate JWXT terms (not every term necessarily has a personal timetable):", "semesterNumber": "Semester {number}", - "expiredRelogin": "The saved login session expired. Please sign in again.", + "expiredRelogin": "The saved login session expired. Please log in again.", "studentId": "Student id", "studentIdHint": "Sent only to the school authentication service", "password": "Password", @@ -242,7 +242,7 @@ "accountLocked": "The account is temporarily locked. Try later or use the official school page.", "accountInactive": "The account is inactive. Activate it on the official school page first.", "challenge": "The school requires a slider or browser challenge; CLI login stopped safely.", - "sessionExpired": "The login session expired. Please sign in again.", + "sessionExpired": "The login session expired. Please log in again.", "timeout": "The school service timed out.", "network": "Could not connect to the school service.", "profileIncomplete": "The school wants this account's profile completed first; finish it in a browser, then try again.", @@ -339,13 +339,18 @@ "modeOn": "On", "modeOff": "Off", "backToMenu": "Back to menu", - "iconMode": "Icon mode", - "colorMode": "Color mode", + "iconMode": "Icon Mode", + "colorMode": "Color Mode", "updated": "Theme setting updated", "updatedSessionOnly": "Theme setting updated for this session only (failed to save config)", "reset": "Theme settings reset", - "resetLabel": "Reset to defaults", + "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?", + "iconUpdated": "Icon mode updated", + "iconUpdatedSessionOnly": "Icon mode changed for this session only (failed to save config)", + "colorUpdated": "Color mode updated", + "colorUpdatedSessionOnly": "Color mode changed for this session only (failed to save config)", "usage": "Usage: nbtca theme | nbtca theme icon | nbtca theme color | nbtca theme reset", "invalidValue": "Invalid value. Use one of:" }, diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a3dcd11..e8b161b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -294,7 +294,7 @@ "weekdayFri": "五", "weekdaySat": "六", "weekdaySun": "日", - "todayHeading": "今日 · 周{weekday} · 第{week}教学周", + "todayHeading": "今日 · 周{weekday} · 第 {week} 教学周", "classDone": "已结束", "classLive": "进行中", "minutesRemaining": "还剩 {minutes} 分钟", @@ -346,6 +346,11 @@ "reset": "主题设置已重置", "resetLabel": "恢复默认", "resetSessionOnly": "主题设置仅在当前会话重置(无法写入配置文件)", + "resetConfirm": "把图标模式和颜色模式恢复为默认值?", + "iconUpdated": "图标模式已更新", + "iconUpdatedSessionOnly": "图标模式仅在当前会话生效(无法写入配置文件)", + "colorUpdated": "颜色模式已更新", + "colorUpdatedSessionOnly": "颜色模式仅在当前会话生效(无法写入配置文件)", "usage": "用法: nbtca theme | nbtca theme icon | nbtca theme color | nbtca theme reset", "invalidValue": "无效取值,可选:" },