From d132e1cb3063dd9ac9df016c62f7a00aec8ad8c6 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 26 Sep 2026 23:21:53 +0800 Subject: [PATCH 1/4] fix(tui): keep content inside the frame and let help scroll - Wrap indented text, menus, footers and the context path at the rule's right end instead of the terminal edge, and keep the indent when a line only fits without it. - Scroll the ? overlay with the usual keys and show its position. - Put the keys back into footer hints and drop Esc from Home, where it quits rather than going back. --- src/app/app.test.ts | 29 +++++++++++++ src/app/app.ts | 57 ++++++++++++------------ src/app/chrome.test.ts | 67 +++++++++++++++++++---------- src/app/chrome.ts | 62 ++++++++++++++------------ src/app/fields/list-field.ts | 4 +- src/app/frame.test.ts | 11 +---- src/app/frame.ts | 8 +--- src/app/help.ts | 4 +- src/app/views/docs-render.ts | 4 +- src/app/views/docs.test.ts | 2 +- src/app/views/events-render.test.ts | 4 +- src/app/views/home.test.ts | 4 ++ src/app/views/home.ts | 2 +- src/core/components/menu.test.ts | 11 +++++ src/core/components/menu.ts | 6 +-- src/core/text.test.ts | 10 +++++ src/core/text.ts | 20 +++------ src/core/theme.ts | 14 ++++++ src/i18n/locales/en.json | 2 +- 19 files changed, 202 insertions(+), 119 deletions(-) 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/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/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/i18n/locales/en.json b/src/i18n/locales/en.json index 21199b1..3503119 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", From f66f8a18ad0b8ef85e916e785041001226966973 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 26 Sep 2026 23:22:14 +0800 Subject: [PATCH 2/4] fix(settings): keep the cursor, confirm reset, name what changed - Return to the entry you left after a submenu, a pick or About. - Ask before restoring defaults, with Cancel preselected. - Report "Icon mode updated" or "Color mode updated" instead of a generic theme message. - Show the author on its own About row instead of after an ASCII colon. - Title-case the settings menu items in English. --- src/app/views/settings-render.ts | 3 +- src/app/views/settings.test.ts | 58 +++++++++++++++++++++++++++++++- src/app/views/settings.ts | 54 ++++++++++++++++++++--------- src/features/about.ts | 4 +-- src/i18n/index.ts | 5 +++ src/i18n/locales/en.json | 11 ++++-- src/i18n/locales/zh.json | 5 +++ 7 files changed, 118 insertions(+), 22 deletions(-) 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/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 3503119..57fd289 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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..c3cc7bd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "无效取值,可选:" }, From 7f532fcfd13f3f0112d88806b91afa52a28b44be Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 26 Sep 2026 23:22:20 +0800 Subject: [PATCH 3/4] fix(startup): align the logo with the app frame and hold it briefly Start the logo, tagline and version at the frame's left edge plus the indent, centered with the frame on wide terminals, so nothing jumps when the app opens. Keep the final frame on screen for 400 ms; any key still skips it. --- src/core/logo.test.ts | 14 +++++++++++++- src/core/logo.ts | 29 ++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) 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(); } From bb655c599bff4c0c42e4736eee9feadfecc62aca Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 26 Sep 2026 23:22:28 +0800 Subject: [PATCH 4/4] fix(i18n): say "log in/out" everywhere and space the zh week number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use "Log in" and "Log out" in every English string, matching the `login`/`logout` commands, and write "第 2 教学周" like "第 13 期". --- src/i18n/locales/en.json | 10 +++++----- src/i18n/locales/zh.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 57fd289..2c4e574 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c3cc7bd..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} 分钟",