From 9c94bb278ec535d2b431bc535a851408f60ba70c Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 00:56:36 +0800 Subject: [PATCH 1/8] fix(color): honor NO_COLOR in auto color mode --- src/config/preferences.test.ts | 17 ++++++++++++++++- src/config/preferences.ts | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/config/preferences.test.ts b/src/config/preferences.test.ts index 7a4fbd3..1810385 100644 --- a/src/config/preferences.test.ts +++ b/src/config/preferences.test.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyColorModePreference } from './preferences.js'; const originalColorMode = process.env['NBTCA_COLOR_MODE']; @@ -39,4 +39,19 @@ describe('applyColorModePreference', () => { expect(process.env['NO_COLOR']).toBe(originalNoColor); expect(process.env['FORCE_COLOR']).toBe(originalForceColor); }); + + it('honors an inherited NO_COLOR in auto mode', async () => { + vi.resetModules(); + process.env['NO_COLOR'] = '1'; + process.env['NBTCA_COLOR_MODE'] = 'auto'; + const freshChalk = (await import('chalk')).default; + freshChalk.level = 3; + const fresh = await import('./preferences.js'); + fresh.applyColorModePreference(false); + expect(freshChalk.level).toBe(0); + + process.env['NBTCA_COLOR_MODE'] = 'on'; + fresh.applyColorModePreference(false); + expect(freshChalk.level).toBe(3); + }); }); diff --git a/src/config/preferences.ts b/src/config/preferences.ts index cdfe9dd..c21dcff 100644 --- a/src/config/preferences.ts +++ b/src/config/preferences.ts @@ -106,5 +106,5 @@ export function applyColorModePreference(forcePlain: boolean): void { else process.env['NO_COLOR'] = inheritedNoColor; if (inheritedForceColor === undefined) delete process.env['FORCE_COLOR']; else process.env['FORCE_COLOR'] = inheritedForceColor; - chalk.level = detectedColorLevel; + chalk.level = inheritedNoColor ? 0 : detectedColorLevel; } From 1821c456f2ed8766a627bb4705d33f8601fa81b9 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 00:56:36 +0800 Subject: [PATCH 2/8] fix(layout): keep banners, heatmap, and charts inside the body edge --- src/app/chrome.ts | 6 +-- src/core/theme.ts | 4 ++ src/features/body-edge.test.ts | 66 +++++++++++++++++++++++++++ src/features/calendar-heatmap.test.ts | 6 +-- src/features/calendar-heatmap.ts | 11 ++--- src/features/calendar.ts | 4 +- src/features/schedule-render.test.ts | 9 ++-- src/features/schedule-render.ts | 14 +++--- 8 files changed, 94 insertions(+), 26 deletions(-) create mode 100644 src/features/body-edge.test.ts diff --git a/src/app/chrome.ts b/src/app/chrome.ts index 9ceb22d..cf9b5dc 100644 --- a/src/app/chrome.ts +++ b/src/app/chrome.ts @@ -1,4 +1,4 @@ -import { c, type, space, glyph, brandMark, bodyEdge } from '../core/theme.js'; +import { c, type, space, glyph, brandMark, bodyEdge, bodyWidth } from '../core/theme.js'; import { pickIcon } from '../core/icons.js'; import { t } from '../i18n/index.js'; import type { ViewId } from './keys.js'; @@ -73,9 +73,7 @@ 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) - ? bodyEdge(Math.max(1, Math.floor(cols))) - : Number.POSITIVE_INFINITY; + const width = bodyWidth(cols); for (let start = 0; start < segments.length; start += 1) { const shown = segments.slice(start); const last = shown.length - 1; diff --git a/src/core/theme.ts b/src/core/theme.ts index 6b15f10..fae1b17 100644 --- a/src/core/theme.ts +++ b/src/core/theme.ts @@ -93,6 +93,10 @@ export function bodyEdge(cols: number): number { return cols >= RIGHT_MARGIN_MIN_COLS ? cols - space.indent.length : cols; } +export function bodyWidth(cols: number): number { + return Number.isFinite(cols) ? bodyEdge(Math.max(1, Math.floor(cols))) : Number.POSITIVE_INFINITY; +} + export const type = { heading: (s: string) => chalk.bold(s), label: (s: string) => s, diff --git a/src/features/body-edge.test.ts b/src/features/body-edge.test.ts new file mode 100644 index 0000000..5c9fec3 --- /dev/null +++ b/src/features/body-edge.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { HeatmapBucket } from '@nbtca/nbtcal'; +import type { TimetableMeeting } from '@nbtca/nbtcal/timetable'; +import { renderCountdownBanner, renderEventBriefLines, type Event } from './calendar.js'; +import { renderHeatmap } from './calendar-heatmap.js'; +import { renderDaySwitcher, renderNextClassBanner, renderTermDensity } from './schedule-render.js'; +import { setLanguage, type Language } from '../i18n/index.js'; +import { resetIconCache } from '../core/icons.js'; +import { bodyEdge } from '../core/theme.js'; +import { visualWidth } from '../core/text.js'; + +beforeAll(() => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); +}); + +const now = new Date('2026-09-27T09:00:00'); +const event: Event = { + date: '09-27', + time: '21:30', + title: 'CTF 训练赛', + location: '', + description: '', + startDate: new Date('2026-09-27T21:34:00'), + recurring: false, + uid: 'ctf', +}; +const meeting: TimetableMeeting = { + sourceId: null, + courseName: '高等数学A', + teacherNames: [], + location: '教3-201', + weekday: 1, + startPeriod: 1, + endPeriod: 2, + weeks: Array.from({ length: 19 }, (_, index) => index + 1), + kind: 'regular', +}; +const buckets: HeatmapBucket[] = Array.from({ length: 365 }, (_, index) => ({ + date: new Date(Date.UTC(2025, 8, 28 + index)).toISOString().slice(0, 10), + count: index % 3, +})); + +const renderers: Record string> = { + countdownBanner: (cols) => renderCountdownBanner(event, now, cols), + eventBrief: (cols) => renderEventBriefLines(event, now, cols).join('\n'), + heatmap: (cols) => renderHeatmap(buckets, now, { cols }), + nextClassBanner: (cols) => + renderNextClassBanner({ meeting, start: new Date('2026-09-28T16:00:00') }, now, cols), + daySwitcher: (cols) => renderDaySwitcher(1, 7, cols), + termDensity: (cols) => renderTermDensity([meeting], '2026-09-07', 3, cols), +}; + +describe.each(['zh', 'en'])('body edge (%s)', (language) => { + beforeAll(() => { + setLanguage(language); + }); + + it.each(Object.keys(renderers).flatMap((name) => [40, 60].map((cols) => [name, cols] as const)))( + '%s stays inside the rule at %i columns', + (name, cols) => { + const lines = (renderers[name]?.(cols) ?? '').split('\n'); + for (const line of lines) expect(visualWidth(line), line).toBeLessThanOrEqual(bodyEdge(cols)); + }, + ); +}); diff --git a/src/features/calendar-heatmap.test.ts b/src/features/calendar-heatmap.test.ts index ba71719..95e9e81 100644 --- a/src/features/calendar-heatmap.test.ts +++ b/src/features/calendar-heatmap.test.ts @@ -148,7 +148,7 @@ describe('renderHeatmap', () => { it('keeps full month names when the weeks are wide enough', () => { setLanguage('zh'); try { - const labels = monthLabels(114); + const labels = monthLabels(117); expect(labels).toHaveLength(12); expect(labels.every((label) => label.endsWith('月'))).toBe(true); } finally { @@ -181,7 +181,7 @@ describe('renderHeatmap', () => { it('reduces the visible week window to fit a 40-column terminal', () => { const lines = renderHeatmap(buckets, today, { color: false, cols: 40 }).split('\n'); - expect(lines.every((line) => visualWidth(stripAnsi(line)) <= 40)).toBe(true); - expect(visualWidth(stripAnsi(lines[2] ?? ''))).toBe(40); + expect(lines.every((line) => visualWidth(stripAnsi(line)) <= 37)).toBe(true); + expect(visualWidth(stripAnsi(lines[2] ?? ''))).toBe(37); }); }); diff --git a/src/features/calendar-heatmap.ts b/src/features/calendar-heatmap.ts index 1995df5..cf55e1b 100644 --- a/src/features/calendar-heatmap.ts +++ b/src/features/calendar-heatmap.ts @@ -1,6 +1,6 @@ import type { HeatmapBucket } from '@nbtca/nbtcal'; import { pickIcon } from '../core/icons.js'; -import { c, space, type } from '../core/theme.js'; +import { bodyWidth, c, space, type } from '../core/theme.js'; import { t, getCurrentLanguage } from '../i18n/index.js'; import { visualWidth } from '../core/text.js'; @@ -68,12 +68,9 @@ export function renderHeatmap( ): string { const useColor = options?.color === true; const trans = t(); - const cellWidth = - options?.cols !== undefined && options.cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2; - const availableColumns = - options?.cols === undefined - ? MAX_WEEK_COLUMNS - : Math.floor((options.cols - GRID_PREFIX_WIDTH) / cellWidth); + const cols = bodyWidth(options?.cols ?? Number.POSITIVE_INFINITY); + const cellWidth = cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2; + const availableColumns = Math.floor((cols - GRID_PREFIX_WIDTH) / cellWidth); const numCols = Math.max(1, Math.min(MAX_WEEK_COLUMNS, availableColumns)); const countByDate = new Map(); diff --git a/src/features/calendar.ts b/src/features/calendar.ts index 63729a5..ec3cf93 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -8,7 +8,7 @@ import { } from '@nbtca/nbtcal'; import type { Calendar, CalendarEvent, FeedValidators, HeatmapBucket } from '@nbtca/nbtcal'; import chalk from 'chalk'; -import { c, type, space, glyph } from '../core/theme.js'; +import { c, type, space, glyph, bodyWidth } from '../core/theme.js'; import { pickIcon } from '../core/icons.js'; import { padEndV, @@ -278,7 +278,7 @@ export function recurringMark(e: Event): string { } function hangingLines(marker: string, content: string, cols: number): string[] { - const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; + const width = bodyWidth(cols); const prefixes = [`${space.indent}${marker} `, `${marker} `, marker, '']; const prefix = prefixes.find((candidate) => visualWidth(candidate) < width) ?? ''; const continuation = ' '.repeat(visualWidth(prefix)); diff --git a/src/features/schedule-render.test.ts b/src/features/schedule-render.test.ts index c1f9158..b5eeadf 100644 --- a/src/features/schedule-render.test.ts +++ b/src/features/schedule-render.test.ts @@ -149,8 +149,8 @@ describe('renderNextClassBanner', () => { ), ); - expect(visualWidth(out)).toBeLessThanOrEqual(40); - expect(out).toContain('Advanced'); + expect(visualWidth(out)).toBeLessThanOrEqual(37); + expect(out).toContain('Advanc'); expect(out).toContain('1h 30m'); expect(out).not.toContain('Building 12 Room 304'); done(); @@ -1099,9 +1099,10 @@ describe('renderDaySwitcher', () => { it('shows a balanced weekday window around a late-week selection', () => { const out = stripAnsi(renderDaySwitcher(5, 1, 40)); - expect(visualWidth(out)).toBeLessThanOrEqual(40); + expect(visualWidth(out)).toBeLessThanOrEqual(37); expect(out).toContain('[Fri]'); - expect(out).toContain('Sun'); + expect(out).toContain('Thu'); + expect(out).toContain('Sat'); expect(out).not.toContain('Mon'); }); }); diff --git a/src/features/schedule-render.ts b/src/features/schedule-render.ts index 871ec66..cad2c18 100644 --- a/src/features/schedule-render.ts +++ b/src/features/schedule-render.ts @@ -8,7 +8,7 @@ import type { } from '@nbtca/nbtcal/timetable'; import { campusDateTime, campusIsoDate, createTimetableSchedule } from '@nbtca/nbtcal/timetable'; import { countdownParts, isCountdownUrgent, type Countdown } from './calendar-query.js'; -import { c, type, space, glyph, MAX_FRAME_COLS } from '../core/theme.js'; +import { c, type, space, glyph, MAX_FRAME_COLS, bodyWidth } from '../core/theme.js'; import { pickIcon } from '../core/icons.js'; import { padEndV, truncate, visualWidth, wrapAnsiToVisualWidth } from '../core/text.js'; import { addLocalDays, parseLocalMonday } from '../core/calendar-day.js'; @@ -48,11 +48,12 @@ function span(m: TimetableMeeting, periods: readonly TimetablePeriod[]): string export function renderNextClassBanner( next: Pick | null, now: Date, - cols = Number.POSITIVE_INFINITY, + terminalCols = Number.POSITIVE_INFINITY, labelled = true, ): string { const trans = t(); if (!next) return ''; + const cols = bodyWidth(terminalCols); const p = countdownParts(next.start, now); const when = formatClassCountdown(p); const styleWhen = isCountdownUrgent(p) ? c.warn : type.hint; @@ -288,8 +289,9 @@ export function renderDayTimeline( export function renderDaySwitcher( selectedWeekday: number, todayWeekday: number, - cols = Number.POSITIVE_INFINITY, + terminalCols = Number.POSITIVE_INFINITY, ): string { + const cols = bodyWidth(terminalCols); const leftArrow = pickIcon('←', '<'); const rightArrow = pickIcon('→', '>'); const todayMark = pickIcon('•', '*'); @@ -596,7 +598,7 @@ export function renderMeetingDetail( } rows.push([trans.timetable.detailWeeks, formatWeekRange(meeting.weeks)]); - const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; + const width = bodyWidth(cols); const indent = visualWidth(space.indent) < width ? space.indent : ''; const contentWidth = Math.max(1, width - visualWidth(indent)); const labelWidth = rows.reduce((w, [label]) => Math.max(w, visualWidth(label)), 0); @@ -634,7 +636,7 @@ export function renderUnresolvedItems( cols = Number.POSITIVE_INFINITY, ): string { const trans = t(); - const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; + const width = bodyWidth(cols); const indent = visualWidth(space.indent) < width ? space.indent : ''; const contentWidth = Math.max(1, width - visualWidth(indent)); if (items.length === 0) { @@ -726,7 +728,7 @@ export function renderTermDensity( ): string { const trans = t().timetable; const lang = getCurrentLanguage(); - const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY; + const width = bodyWidth(cols); const indent = visualWidth(space.indent) < width ? space.indent : ''; const contentWidth = Math.max(1, width - visualWidth(indent)); const wrap = (text: string): string[] => From 8f1483e9f5fcece849ee3e0dcda363b90c8aafab Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 00:57:58 +0800 Subject: [PATCH 3/8] fix(schedule): keep the week grid open when Enter hits an empty cell --- src/app/views/schedule-grid-cursor.test.ts | 4 +++- src/app/views/schedule-grid-cursor.ts | 3 ++- src/app/views/schedule.test.ts | 9 +++++++++ src/app/views/schedule.ts | 1 + 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/app/views/schedule-grid-cursor.test.ts b/src/app/views/schedule-grid-cursor.test.ts index 6d7e817..00391e3 100644 --- a/src/app/views/schedule-grid-cursor.test.ts +++ b/src/app/views/schedule-grid-cursor.test.ts @@ -120,7 +120,9 @@ describe('handleGridKey', () => { }); it('is a no-op on Enter when the cursor cell is empty', () => { - expect(handleGridKey(KEY_ENTER_CR, { weekday: 2, period: 1 }, tt, 1)).toEqual({ kind: 'none' }); + expect(handleGridKey(KEY_ENTER_CR, { weekday: 2, period: 1 }, tt, 1)).toEqual({ + kind: 'emptyCell', + }); }); it('is a no-op for any other key', () => { diff --git a/src/app/views/schedule-grid-cursor.ts b/src/app/views/schedule-grid-cursor.ts index bb8e880..4d7f3f0 100644 --- a/src/app/views/schedule-grid-cursor.ts +++ b/src/app/views/schedule-grid-cursor.ts @@ -69,6 +69,7 @@ export function moveCursorPeriod( export type GridKeyResult = | { kind: 'moveCursor'; cursor: GridCursor } | { kind: 'openDetail'; meeting: TimetableMeeting } + | { kind: 'emptyCell' } | { kind: 'none' }; export function handleGridKey( @@ -85,7 +86,7 @@ export function handleGridKey( return { kind: 'moveCursor', cursor: moveCursorPeriod(cursor, tt.periods, 1) }; if (key === KEY_ENTER_CR || key === KEY_ENTER_LF) { const meeting = createTimetableSchedule(tt).meetingAt(week, cursor.weekday, cursor.period); - return meeting ? { kind: 'openDetail', meeting } : { kind: 'none' }; + return meeting ? { kind: 'openDetail', meeting } : { kind: 'emptyCell' }; } return { kind: 'none' }; } diff --git a/src/app/views/schedule.test.ts b/src/app/views/schedule.test.ts index 50cdc66..8302c2a 100644 --- a/src/app/views/schedule.test.ts +++ b/src/app/views/schedule.test.ts @@ -405,6 +405,15 @@ describe('scheduleView — hub navigation', () => { expect(out).toContain(t().timetable.hubLogout); }); + it('stays in the week grid when Enter lands on an empty cell', async () => { + const ctx = await loadIntoHub(); + scheduleView.handleKey('w', ctx); + scheduleView.handleKey('\r', ctx); + const out = stripAnsi(scheduleView.render(ctx).join('\n')); + expect(out).toContain(t().timetable.hubWeek); + expect(out).not.toContain(t().timetable.hubLogout); + }); + it('opens a meeting detail card on Enter when the cursor cell has a class, and returns to the hub on any key', async () => { const ctx = await loadIntoHub({ meetings: [ diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index 875ebe0..bcdfc37 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -651,6 +651,7 @@ export const scheduleView = { }; return; } + if (nav.kind === 'emptyCell') return; returnToHub(); return; } From 1bc0feb590bf49c14ccd92e46bc0886254c6b31b Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 01:03:35 +0800 Subject: [PATCH 4/8] fix(docs): center search excerpts on the query --- src/app/views/docs.test.ts | 31 ++++++++++++++++++++++++++ src/app/views/docs.ts | 1 + src/core/components/menu.test.ts | 15 +++++++++++++ src/core/components/menu.ts | 18 ++++++++++++---- src/core/text.test.ts | 37 ++++++++++++++++++++++++++++++++ src/core/text.ts | 27 +++++++++++++++++++++++ 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/app/views/docs.test.ts b/src/app/views/docs.test.ts index 3bbc6bc..0b9d685 100644 --- a/src/app/views/docs.test.ts +++ b/src/app/views/docs.test.ts @@ -290,6 +290,37 @@ describe('docsView', () => { } }); + it('keeps the query visible in a long search excerpt', async () => { + searchDocumentsMock.mockResolvedValueOnce([ + { + name: 'printing.md', + path: 'tutorial/manual/printing.md', + type: 'file', + title: 'Printing', + summary: '', + excerpt: + 'The lab opens every weekday evening, bring your own laptop and charger, and the printer needs paper', + route: '/tutorial/manual/printing', + score: 80, + section: 'tutorial', + }, + ]); + vi.resetModules(); + const { docsView: freshDocsView } = await import('./docs.js'); + const ctx = fakeCtx(); + await freshDocsView.load(ctx); + freshDocsView.handleKey('\x1b[B', ctx); + freshDocsView.handleKey('\r', ctx); + freshDocsView.handleKey('printer', ctx); + freshDocsView.handleKey('\r', ctx); + await new Promise((resolve) => setTimeout(resolve, 0)); + const line = freshDocsView + .render(ctx) + .map(stripAnsi) + .find((candidate) => candidate.includes('Printing')); + expect(line).toContain('printer'); + }); + it('rebuilds cached search results in place after a language change', async () => { setLanguage('zh'); fetchSectionsMock.mockClear(); diff --git a/src/app/views/docs.ts b/src/app/views/docs.ts index 94009d6..6436c4c 100644 --- a/src/app/views/docs.ts +++ b/src/app/views/docs.ts @@ -239,6 +239,7 @@ function buildSearchResultsField( ? sanitizeTerminalLine(result.path.split('/').slice(0, -1).join('/')) : undefined), ), + hintFocus: currentSearchQuery, })), { value: '__back__', label: backLabel() }, ]; diff --git a/src/core/components/menu.test.ts b/src/core/components/menu.test.ts index 07be8c9..d9f9f75 100644 --- a/src/core/components/menu.test.ts +++ b/src/core/components/menu.test.ts @@ -204,6 +204,21 @@ describe('renderMenu with a hint column', () => { expect(lines[0]).toContain('…'); }); + it('windows a hint around its focus text instead of cutting the focus off', () => { + const docs = [ + { + value: 'a', + label: 'Printing', + hint: 'The lab opens every weekday evening and the printer needs paper from you', + hintColumn: true, + hintFocus: 'PRINTER', + }, + ]; + const line = stripAnsi(renderMenu({ options: docs, selectedIndex: 0 }, 60)); + expect(line).toContain('printer'); + expect(visualWidth(line)).toBeLessThanOrEqual(57); + }); + it('drops a summary column that would be too narrow to read', () => { const docs = [ { value: 'a', label: 'Infrastructure and projects', hint: 'Servers, serverless functions' }, diff --git a/src/core/components/menu.ts b/src/core/components/menu.ts index e94de6a..400c0f3 100644 --- a/src/core/components/menu.ts +++ b/src/core/components/menu.ts @@ -1,5 +1,11 @@ import { glyph, type, space, bodyEdge } from '../theme.js'; -import { visualWidth, padEndV, wrapAnsiToVisualWidth, clipAnsiToVisualWidth } from '../text.js'; +import { + visualWidth, + padEndV, + wrapAnsiToVisualWidth, + clipAnsiToVisualWidth, + excerptAround, +} from '../text.js'; import { pickIcon } from '../icons.js'; import { createPainter } from './painter.js'; import { startRawInput } from './input-session.js'; @@ -72,6 +78,7 @@ export interface MenuOption { hint?: string; dim?: boolean; hintColumn?: boolean; + hintFocus?: string; } export interface MenuColumns { @@ -114,10 +121,11 @@ function renderIndentedText( const MIN_LABEL_COLUMN = 24; const MIN_HINT_COLUMN = 16; +const ellipsis = (): string => pickIcon('…', '~'); + function clipWithEllipsis(value: string, width: number): string { if (visualWidth(value) <= width) return value; - const ellipsis = pickIcon('…', '~'); - return clipAnsiToVisualWidth(value, width - visualWidth(ellipsis)) + ellipsis; + return clipAnsiToVisualWidth(value, width - visualWidth(ellipsis())) + ellipsis(); } function fitColumns(columns: MenuColumns, contentWidth: number): MenuColumns | undefined { @@ -144,7 +152,9 @@ function optionCells( return fitted ? { label: padEndV(clipWithEllipsis(option.label, fitted.label), fitted.label), - hint: clipWithEllipsis(hint, fitted.hint), + hint: option.hintFocus + ? excerptAround(hint, option.hintFocus, fitted.hint, ellipsis()) + : clipWithEllipsis(hint, fitted.hint), } : { label: option.label, hint: '' }; } diff --git a/src/core/text.test.ts b/src/core/text.test.ts index f0fdac0..e2ba458 100644 --- a/src/core/text.test.ts +++ b/src/core/text.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { clipAnsiToVisualWidth, + excerptAround, padEndV, sanitizeTerminalLine, sanitizeTerminalText, @@ -204,3 +205,39 @@ describe('sanitizeTerminalText', () => { expect(sanitizeTerminalLine(' title\n\tlocation \u001B[31m')).toBe('title location'); }); }); + +describe('excerptAround', () => { + const text = '机房在教学楼三楼,开放时间为每周一到周五的晚上,如需使用打印机请提前预约并自带纸张'; + + it('keeps text that already fits', () => { + expect(excerptAround('short 打印', '打印', 20)).toBe('short 打印'); + }); + + it('moves the window so the match stays visible', () => { + const out = excerptAround(text, '打印', 24); + expect(out).toContain('打印'); + expect(out.startsWith('...')).toBe(true); + expect(out.endsWith('...')).toBe(true); + expect(visualWidth(out)).toBeLessThanOrEqual(24); + }); + + it('matches case-insensitively and falls back to the start', () => { + const english = 'The lab opens every weekday evening and the Printer needs paper from you'; + expect(excerptAround(english, 'printer', 30)).toContain('Printer'); + expect(excerptAround(english, 'missing', 20)).toBe(truncate(english, 20)); + }); + + it('shows the tail without a trailing marker when the match is near the end', () => { + const out = excerptAround(text, '纸张', 20); + expect(out.endsWith('纸张')).toBe(true); + expect(visualWidth(out)).toBeLessThanOrEqual(20); + }); + + it('never splits a grapheme', () => { + const out = excerptAround(`${'👨‍👩‍👧 '.repeat(10)}needle`, 'needle', 14); + expect(out).toContain('needle'); + const segments = Array.from(new Intl.Segmenter().segment(out), (part) => part.segment); + expect(segments.filter((part) => part.includes('\u200d'))).not.toContain('👨‍👩‍'); + expect(visualWidth(out)).toBeLessThanOrEqual(14); + }); +}); diff --git a/src/core/text.ts b/src/core/text.ts index 516bd76..b39b212 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -127,6 +127,33 @@ export function truncateStart(str: string, maxWidth: number, marker = '...'): st return fittedMarker + value; } +function indexOfIgnoreCase(text: string, needle: string): number { + const target = needle.toLowerCase(); + for (let index = 0; index + needle.length <= text.length; index += 1) { + if (text.slice(index, index + needle.length).toLowerCase() === target) return index; + } + return -1; +} + +export function excerptAround( + text: string, + needle: string, + maxWidth: number, + marker = '...', +): string { + if (visualWidth(text) <= maxWidth) return text; + const at = needle ? indexOfIgnoreCase(text, needle) : -1; + const markerWidth = visualWidth(marker); + if (at <= 0 || visualWidth(text.slice(0, at + needle.length)) + markerWidth <= maxWidth) { + return truncate(text, maxWidth, marker); + } + const rest = text.slice(at); + const available = maxWidth - markerWidth; + const lead = Math.max(Math.floor(available / 4), available - visualWidth(rest)); + const head = truncateStart(text.slice(0, at), lead + markerWidth, marker); + return head + truncate(rest, maxWidth - visualWidth(head), marker); +} + interface WrapToken { raw: string; width: number; From 3de7ed0eba0bf5892f1ab2dbd497ab608c7afbae Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 01:17:00 +0800 Subject: [PATCH 5/8] fix(schedule): keep the course name whole under the grid cursor --- src/features/schedule-render.test.ts | 22 ++++++++++++++++++++- src/features/schedule-render.ts | 29 ++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/features/schedule-render.test.ts b/src/features/schedule-render.test.ts index b5eeadf..d2cc2a4 100644 --- a/src/features/schedule-render.test.ts +++ b/src/features/schedule-render.test.ts @@ -23,7 +23,7 @@ import { setLanguage } from '../i18n/index.js'; import { resetIconCache } from '../core/icons.js'; import { campusDateTime } from '@nbtca/nbtcal/timetable'; import { stripAnsi, visualWidth } from '../core/text.js'; -import { space } from '../core/theme.js'; +import { bodyEdge, space } from '../core/theme.js'; beforeAll(() => { setLanguage('en'); @@ -648,6 +648,26 @@ describe('renderWeekGrid', () => { done(); }); + it.each([1, 4, 7] as const)( + 'keeps a column-filling name whole under the cursor on weekday %i', + (weekday) => { + const meetings = ([1, 2, 3, 4, 5, 6, 7] as const).map((wd) => + mk({ courseName: 'Calculus', location: null, weekday: wd, weeks: [1] }), + ); + const at = { weekday, period: 1 }; + const now = campusDateTime('2026-09-07', '09:00'); + const plain = stripAnsi(renderWeekGrid(meetings, periods, 1, now, 100)).split('\n'); + const cursor = stripAnsi(renderWeekGrid(meetings, periods, 1, now, 100, at)).split('\n'); + const row = (lines: string[]) => findLine(lines, (line) => line.includes('08:00')); + expect(row(plain)).not.toContain('…'); + expect(row(cursor)).toContain('[Calculus]'); + expect(visualWidth(row(cursor))).toBeLessThanOrEqual(bodyEdge(100)); + const bars = (line: string) => [...line.matchAll(/\|/g)].map((match) => match.index); + expect(bars(row(cursor))).toEqual(bars(row(plain))); + done(); + }, + ); + it("keeps the bracket cursor on today's own column", () => { const out = stripAnsi( renderWeekGrid([], periods, 1, campusDateTime('2026-09-07', '09:00'), 100, { diff --git a/src/features/schedule-render.ts b/src/features/schedule-render.ts index cad2c18..4cae7ce 100644 --- a/src/features/schedule-render.ts +++ b/src/features/schedule-render.ts @@ -357,6 +357,7 @@ function spreadWidths(ideal: readonly number[], available: number): number[] { } const GRID_SEP_W = 3; +const GRID_CURSOR_TAIL_W = 1; interface GridMetrics { rowHeadW: number; @@ -388,7 +389,9 @@ function weekGridMetrics(timetable: Timetable, weekNumber: number, now: Date): G export function weekGridFullWidth(timetable: Timetable, weekNumber: number, now: Date): number { const { rowHeadW, totalIdealColW } = weekGridMetrics(timetable, weekNumber, now); - return visualWidth(space.indent) * 2 + rowHeadW + totalIdealColW + 6 * GRID_SEP_W; + return ( + visualWidth(space.indent) * 2 + rowHeadW + totalIdealColW + 6 * GRID_SEP_W + GRID_CURSOR_TAIL_W + ); } export function renderWeekGrid( @@ -406,11 +409,12 @@ export function renderWeekGrid( const connector = pickIcon('│', '|'); const emptyGlyph = pickIcon('·', '.'); const sepGlyph = pickIcon('│', '|'); - const sep = type.hint(` ${sepGlyph} `); + const gap = ` ${sepGlyph} `; + const sep = type.hint(gap); const { rowHeadW, idealColWidths, totalIdealColW } = weekGridMetrics(timetable, weekNumber, now); const availableForCols = Math.max( 0, - lineBudget(cols) - visualWidth(space.indent) - rowHeadW - 6 * GRID_SEP_W, + lineBudget(cols) - visualWidth(space.indent) - rowHeadW - 6 * GRID_SEP_W - GRID_CURSOR_TAIL_W, ); const colWidths = totalIdealColW <= availableForCols @@ -437,9 +441,9 @@ export function renderWeekGrid( const sorted = [...periods].sort((a, b) => a.period - b.period); sorted.forEach((p, i) => { - const rowHead = type.hint(padEndV(rangeLabel(p.start, p.end), rowHeadW)); const nameCells: string[] = []; const locCells: string[] = []; + let widened = -1; for (let wdIdx = 0; wdIdx < 7; wdIdx++) { const wd = wdIdx + 1; const colW = colWidths[wdIdx] ?? 3; @@ -454,7 +458,10 @@ export function renderWeekGrid( const paddedLoc = centerInWidth(clip(rawLoc, colW), colW); if (isCursor) { - nameCells.push(type.active(centerInWidth(`[${clip(rawName, colW - 2)}]`, colW))); + const wide = visualWidth(rawName) > colW - 2; + if (wide) widened = wdIdx; + const inner = wide ? colW : colW - 2; + nameCells.push(type.active(centerInWidth(`[${clip(rawName, inner)}]`, inner + 2))); locCells.push(starting ? type.body(paddedLoc) : type.hint(paddedLoc)); } else if (starting) { nameCells.push(isToday ? type.active(paddedName) : type.body(paddedName)); @@ -464,7 +471,17 @@ export function renderWeekGrid( locCells.push(type.hint(paddedLoc)); } } - lines.push(space.indent + rowHead + nameCells.join(sep)); + const rowHead = type.hint( + padEndV(rangeLabel(p.start, p.end), rowHeadW - (widened === 0 ? 1 : 0)), + ); + const nameRow = nameCells + .map((cell, index) => + index === 0 + ? cell + : type.hint(gap.slice(index - 1 === widened ? 1 : 0, index === widened ? 2 : 3)) + cell, + ) + .join(''); + lines.push(space.indent + rowHead + nameRow); lines.push(space.indent + blankHead + locCells.join(sep)); const next = sorted[i + 1]; From 12a6431752a1413d4d0c9ead9fe3959dc6bf8d88 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 01:17:19 +0800 Subject: [PATCH 6/8] fix(docs): right-align archived year counts --- src/app/views/docs.test.ts | 29 +++++++++++++++++++++++++++++ src/app/views/docs.ts | 14 ++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/app/views/docs.test.ts b/src/app/views/docs.test.ts index 0b9d685..cfcfc46 100644 --- a/src/app/views/docs.test.ts +++ b/src/app/views/docs.test.ts @@ -248,6 +248,35 @@ describe('docsView', () => { } }); + it('right-aligns the archived year counts', async () => { + fetchSectionsMock.mockResolvedValueOnce([ + { + key: 'archived', + label: 'Archived', + count: 11, + files: [ + ...Array.from({ length: 10 }, (_, index) => ({ + name: `a${index}.md`, + path: `archived/2026/a${index}.md`, + type: 'file' as const, + })), + { name: 'b.md', path: 'archived/2025/b.md', type: 'file' as const }, + ], + }, + ]); + vi.resetModules(); + const { docsView: freshDocsView } = await import('./docs.js'); + const ctx = fakeCtx(); + await freshDocsView.load(ctx); + freshDocsView.handleKey('\r', ctx); + const lines = freshDocsView.render(ctx).map((line) => stripAnsi(line).trimEnd()); + const ten = lines.find((line) => line.includes('2026')) ?? ''; + const one = lines.find((line) => line.includes('2025')) ?? ''; + expect(ten.endsWith('10')).toBe(true); + expect(one.endsWith(' 1')).toBe(true); + expect(one.length).toBe(ten.length); + }); + it('rebuilds cached archived navigation in place after a language change', async () => { setLanguage('zh'); fetchSectionsMock.mockClear(); diff --git a/src/app/views/docs.ts b/src/app/views/docs.ts index 6436c4c..078f510 100644 --- a/src/app/views/docs.ts +++ b/src/app/views/docs.ts @@ -121,14 +121,19 @@ async function openUrlFromView( ctx.rerender(); } +function countHints(counts: readonly number[]): string[] { + const width = Math.max(0, ...counts.map((count) => String(count).length)); + return counts.map((count) => String(count).padStart(width)); +} + function buildSectionsField(): ListField { const trans = t(); - const countWidth = Math.max(0, ...sections.map((sec) => String(sec.count).length)); + const counts = countHints(sections.map((sec) => sec.count)); const options = [ - ...sections.map((sec) => ({ + ...sections.map((sec, index) => ({ value: sec.key, label: sec.label, - hint: String(sec.count).padStart(countWidth), + hint: counts[index] ?? '', })), { value: '__search__', label: trans.docs.searchPrompt }, { value: '__refresh__', label: trans.docs.refreshCache }, @@ -168,8 +173,9 @@ function buildArchivedGroupsField( if (bYear) return 1; return a.localeCompare(b); }); + const counts = countHints(sortedKeys.map((k) => groups.get(k)?.length ?? 0)); const options = [ - ...sortedKeys.map((k) => ({ value: k, label: k, hint: String(groups.get(k)?.length ?? 0) })), + ...sortedKeys.map((k, index) => ({ value: k, label: k, hint: counts[index] ?? '' })), { value: '__back__', label: backLabel() }, ]; return new ListField({ title: trans.docs.categoryArchived, options, maxVisible, initialIndex }); From b75233f2439419cd1c743b1593b0ec9079e32680 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 01:17:19 +0800 Subject: [PATCH 7/8] fix(ui): follow the icon mode for separators and polish zh punctuation and en casing --- src/app/chrome.ts | 12 ++++---- src/app/fields/list-field.ts | 3 +- src/app/views/docs.ts | 12 +++++--- src/app/views/events-render.test.ts | 10 +++--- src/app/views/events.test.ts | 1 + src/app/views/events.ts | 12 +++++--- src/app/views/home.ts | 2 +- src/app/views/schedule-render.ts | 13 ++++++-- src/app/views/schedule.ts | 2 +- src/core/theme.ts | 1 + src/features/calendar-heatmap.test.ts | 2 +- src/features/calendar.ts | 7 ++--- src/features/docs.ts | 12 ++++++-- src/features/schedule-render.ts | 10 +++--- src/i18n/index.ts | 6 ++-- src/i18n/locales/en.json | 20 ++++++------ src/i18n/locales/zh.json | 44 +++++++++++++-------------- 17 files changed, 93 insertions(+), 76 deletions(-) diff --git a/src/app/chrome.ts b/src/app/chrome.ts index cf9b5dc..af1c105 100644 --- a/src/app/chrome.ts +++ b/src/app/chrome.ts @@ -30,7 +30,7 @@ function renderRule(cols: number): string { } function renderTabs(views: { id: ViewId; title: string }[], active: ViewId, cols: number): string { - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const full = space.indent + views @@ -125,10 +125,10 @@ export function fitFooterHint(cols: number, ...candidates: string[]): string { export function captureFooterHint(cols = Number.POSITIVE_INFINITY): string { const trans = t(); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); return fitFooterHint( cols, - `${glyph.enter()} ${trans.common.confirm} ${dot} Esc ${trans.common.back} ${dot} Ctrl+C ${trans.common.exit}`, + `${glyph.enter()} ${trans.menu.hintConfirm} ${dot} Esc ${trans.menu.hintBack} ${dot} Ctrl+C ${trans.menu.hintQuit}`, `${glyph.enter()} Esc Ctrl+C`, 'Ctrl+C Esc', 'Ctrl+C', @@ -136,7 +136,7 @@ export function captureFooterHint(cols = Number.POSITIVE_INFINITY): string { } export function digitTabHint(tabCount: number): string { - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); return tabCount > 1 ? `1-${tabCount} / Tab ${t().menu.hintTabs} ${dot} ` : ''; } @@ -146,7 +146,7 @@ export function passiveFooterHint( canGoBack = true, ): string { const trans = t(); - const dot = pickIcon('·', '-'); + 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} ` : ''; @@ -163,7 +163,7 @@ export function passiveFooterHint( function interactiveFooterHint(tabCount: number, cols: number): string { const trans = t(); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : ''; const move = `${glyph.updown()} ${trans.menu.hintMove} ${dot} ${glyph.enter()} ${trans.menu.hintOpen}`; const quit = `q ${trans.menu.hintQuit}`; diff --git a/src/app/fields/list-field.ts b/src/app/fields/list-field.ts index f121da6..4c60916 100644 --- a/src/app/fields/list-field.ts +++ b/src/app/fields/list-field.ts @@ -7,7 +7,6 @@ import { type MenuOption, } from '../../core/components/menu.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'; @@ -104,7 +103,7 @@ export class ListField { ].filter((part): part is string => part !== null); lines.push( ...renderIndentedOutput( - type.hint(parts.join(` ${pickIcon('·', '-')} `)), + type.hint(parts.join(` ${glyph.sep()} `)), cols, `${space.indent}${' '.repeat(visualWidth(glyph.cursor()) + 1)}`, ), diff --git a/src/app/views/docs.ts b/src/app/views/docs.ts index 078f510..c6c5546 100644 --- a/src/app/views/docs.ts +++ b/src/app/views/docs.ts @@ -197,7 +197,7 @@ function buildArchivedFilesField( ...docLabel(f), ...optionalHint( subDirs.size > 1 - ? [sanitizeTerminalLine(sub), f.summary].filter(Boolean).join(' · ') + ? [sanitizeTerminalLine(sub), f.summary].filter(Boolean).join(` ${glyph.sep()} `) : f.summary, ), }; @@ -205,7 +205,7 @@ function buildArchivedFilesField( { value: '__back__', label: backLabel() }, ]; return new ListField({ - title: `${trans.docs.categoryArchived} · ${groupKey}`, + title: `${trans.docs.categoryArchived} ${glyph.sep()} ${groupKey}`, options, maxVisible, initialIndex, @@ -250,7 +250,11 @@ function buildSearchResultsField( { value: '__back__', label: backLabel() }, ]; return new ListField({ - title: fmt(trans.docs.searchResultsTitle, { query: currentSearchQuery, count: matches.length }), + title: fmt(trans.docs.searchResultsTitle, { + query: currentSearchQuery, + count: matches.length, + sep: glyph.sep(), + }), options, maxVisible, initialIndex, @@ -688,7 +692,7 @@ export const docsView = { } if (state.mode === 'reader' && !state.readerLinksField) { const trans = t(); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const hasLinks = (state.readerLinks?.length ?? 0) > 0; const linkHint = hasLinks ? `f ${trans.docs.readerLinksHint} ${dot} ` : ''; const pageHint = `${glyph.updown()} PgUp/PgDn ${dot} `; diff --git a/src/app/views/events-render.test.ts b/src/app/views/events-render.test.ts index 7e090f7..721d9e1 100644 --- a/src/app/views/events-render.test.ts +++ b/src/app/views/events-render.test.ts @@ -62,7 +62,7 @@ describe('renderEvents', () => { it.each([ [ 'Events', - ['Events', 'This week', 'This month', 'Search', 'Past events', 'Activity (last 12 months)'], + ['Events', 'This week', 'This month', 'Search', 'Past events', 'Activity (Last 12 Months)'], ], [ '活动', @@ -145,7 +145,7 @@ describe('renderEvents', () => { '\n', ), ); - expect(out).not.toContain('Activity (last 12 months)'); + expect(out).not.toContain('Activity (Last 12 Months)'); expect(out).not.toContain('Less'); }); @@ -393,7 +393,7 @@ describe('renderEvents — adaptive hub density', () => { 45, ).join('\n'), ); - expect(out).toContain('Activity (last 12 months)'); + expect(out).toContain('Activity (Last 12 Months)'); expect(out).toContain('Less'); expect(out).toContain('NWDC'); expect(out).toContain('Events'); // the hub menu itself @@ -415,7 +415,7 @@ describe('renderEvents — adaptive hub density', () => { 20, ).join('\n'), ); - expect(out).not.toContain('Activity (last 12 months)'); + expect(out).not.toContain('Activity (Last 12 Months)'); }); it('never collapses a multi-line renderer output into one array entry, even in the expanded layout', () => { @@ -632,7 +632,7 @@ describe('renderEvents — list/detail/search/error modes', () => { expect(line).not.toContain('\n'); } expect(lines.length).toBeGreaterThan(10); - expect(stripAnsi(lines.join('\n'))).toContain('Activity (last 12 months)'); + expect(stripAnsi(lines.join('\n'))).toContain('Activity (Last 12 Months)'); }); it('passes the terminal width through to heatmap rendering', () => { diff --git a/src/app/views/events.test.ts b/src/app/views/events.test.ts index da28f3a..3872eca 100644 --- a/src/app/views/events.test.ts +++ b/src/app/views/events.test.ts @@ -394,6 +394,7 @@ describe('eventsView navigation', () => { expect(old).toBeGreaterThan(next); expect(lines[old]).toContain(t().calendar.endedLabel); expect(lines[next]).not.toContain(t().calendar.endedLabel); + expect(lines).toContain(' Results for “meetup” · 2'); }); it('marks recurring events in lists and lines up all-day titles with timed ones', async () => { diff --git a/src/app/views/events.ts b/src/app/views/events.ts index 6ef4c14..149e9fb 100644 --- a/src/app/views/events.ts +++ b/src/app/views/events.ts @@ -5,7 +5,7 @@ import { ListField, computeMaxVisible } from '../fields/list-field.js'; import { TextField } from '../fields/text-field.js'; import { hubShowsHeatmap, renderEvents, type EventsViewState } from './events-render.js'; import { setVimKeysActive } from '../../core/vim-keys.js'; -import { pickIcon } from '../../core/icons.js'; +import { glyph } from '../../core/theme.js'; import { padEndV, visualWidth } from '../../core/text.js'; import type { MenuOption } from '../../core/components/menu.js'; import { fmt, t } from '../../i18n/index.js'; @@ -74,7 +74,7 @@ function showList( isEnded: (event: CalendarEvent) => boolean = () => false, ): void { const trans = t(); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const display = events.map(toDisplayEvent); const whenWidth = Math.max(0, ...display.map((event) => visualWidth(eventWhen(event)))); const options: MenuOption[] = [ @@ -125,7 +125,7 @@ function showDetail(raw: CalendarEvent): void { const trans = t(); listState = state; const e = toDisplayEvent(raw); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); state = { mode: 'detail', detailTitle: e.title, @@ -335,7 +335,11 @@ export const eventsView = { const trans = t(); showList( ctx, - `${trans.calendar.search}: ${query}`, + fmt(trans.calendar.searchResultsTitle, { + query, + count: upcoming.length + past.length, + sep: glyph.sep(), + }), [...upcoming, ...past], fmt(trans.calendar.searchNoResultsFor, { query }), (event) => hasEnded(event, now), diff --git a/src/app/views/home.ts b/src/app/views/home.ts index 65f5fb3..e7c9365 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -174,7 +174,7 @@ export function renderHome(data: HomeData, now: Date, bodyRows = 100, cols = 80) if (unresolvedCount > 0) { lines.push( ...wrappedIndentedLines( - `${pickIcon('⚠', '!')} ${trans.timetable.hubUnresolved} · ${unresolvedCount}`, + `${pickIcon('⚠', '!')} ${trans.timetable.hubUnresolved} ${glyph.sep()} ${unresolvedCount}`, cols, c.warn, ), diff --git a/src/app/views/schedule-render.ts b/src/app/views/schedule-render.ts index 9f473fb..ad70763 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -186,6 +186,7 @@ function hubPreGridLines( fmt(trans.timetable.termStartsIn, { date: state.weekOne, days: String(daysUntil(now, state.weekOne)), + sep: glyph.sep(), }), ), ); @@ -201,6 +202,7 @@ function hubPreGridLines( fmt(trans.timetable.todayHeading, { weekday: weekdayShortLabel(todayWd), week: String(week), + sep: glyph.sep(), }), ), ...renderTodayTimeline(today, tt.periods, now, cols).split('\n'), @@ -455,7 +457,10 @@ function renderPublicBody( } else if (w.status === 'onBreak') { lines.push( ...headingLines( - fmt(trans.timetable.onBreak, { title: sanitizeTerminalLine(w.breakTitle) }), + fmt(trans.timetable.onBreak, { + title: sanitizeTerminalLine(w.breakTitle), + sep: glyph.sep(), + }), cols, ), ); @@ -464,7 +469,11 @@ function renderPublicBody( w.semester === '1' ? trans.timetable.semester1 : trans.timetable.semester2; lines.push( ...headingLines( - `${fmt(trans.timetable.academicYearSuffix, { year: sanitizeTerminalLine(w.academicYear) })} · ${semesterLabel} · ${fmt(trans.timetable.weekLabel2, { week: String(w.currentWeek) })}`, + [ + fmt(trans.timetable.academicYearSuffix, { year: sanitizeTerminalLine(w.academicYear) }), + semesterLabel, + fmt(trans.timetable.weekLabel2, { week: String(w.currentWeek) }), + ].join(` ${glyph.sep()} `), cols, ), ); diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index bcdfc37..7b7c0d0 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -70,7 +70,7 @@ function gridKeys(): { days: string; periods: string; enter: string } { function gridFooterHint(tabCount: number, cols: number): string { const trans = t(); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const keys = gridKeys(); const local = `${keys.days} ${trans.timetable.footerDay} ${dot} ${keys.periods} ${trans.timetable.footerClass} ${dot} ${keys.enter} ${trans.timetable.footerDetail}`; const labelled = `Esc ${trans.menu.hintBack} ${dot} q ${trans.menu.hintQuit}`; diff --git a/src/core/theme.ts b/src/core/theme.ts index fae1b17..6093b02 100644 --- a/src/core/theme.ts +++ b/src/core/theme.ts @@ -66,6 +66,7 @@ export const glyph = { rule: () => pickIcon('─', '-'), bar: () => pickIcon('│', '|'), bullet: () => pickIcon('·', '.'), + sep: () => pickIcon('·', '-'), dot: () => pickIcon('●', '*'), updown: () => pickIcon('↑↓', 'up/down'), enter: () => pickIcon('⏎', 'enter'), diff --git a/src/features/calendar-heatmap.test.ts b/src/features/calendar-heatmap.test.ts index 95e9e81..3f38942 100644 --- a/src/features/calendar-heatmap.test.ts +++ b/src/features/calendar-heatmap.test.ts @@ -43,7 +43,7 @@ describe('renderHeatmap', () => { it('contains the title from i18n', () => { const output = renderHeatmap(buckets, today, { color: false }); - expect(output).toContain('Activity (last 12 months)'); + expect(output).toContain('Activity (Last 12 Months)'); }); it('contains legend words', () => { diff --git a/src/features/calendar.ts b/src/features/calendar.ts index ec3cf93..557baae 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -293,7 +293,7 @@ export function renderEventBriefLines( now: Date, cols = Number.POSITIVE_INFINITY, ): string[] { - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const proximity = eventProximity(e.startDate, now); const dateStyle = proximity === 'today' @@ -303,8 +303,7 @@ export function renderEventBriefLines( : proximity === 'week' ? type.body : type.hint; - const marker = - proximity === 'today' ? type.active(pickIcon('●', '*')) : dateStyle(pickIcon('·', '-')); + 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))}`; return hangingLines(marker, content, cols); @@ -326,7 +325,7 @@ export function renderCountdownBanner( ? trans.calendar.startingNow : `${trans.calendar.inPrefix} ${formatDuration(p)}`; const whenStyled = isCountdownUrgent(p) ? c.warn(when) : type.hint(when); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const content = `${type.label(trans.calendar.next)} ${dot} ${type.body(event.title)} ${dot} ${whenStyled}`; return hangingLines(type.active(glyph.dot()), content, cols).join('\n'); } diff --git a/src/features/docs.ts b/src/features/docs.ts index 22f428c..cd36478 100644 --- a/src/features/docs.ts +++ b/src/features/docs.ts @@ -8,6 +8,7 @@ import { warning, createSpinner } from '../core/ui.js'; import { pickIcon } from '../core/icons.js'; import { spawn, execFileSync } from 'child_process'; import { URLS } from '../config/data.js'; +import { glyph } from '../core/theme.js'; import { t, fmt, getCurrentLanguage, type Translations } from '../i18n/index.js'; import { enterScreen, breadcrumb } from '../core/transitions.js'; import { sanitizeTerminalLine, sanitizeTerminalText, truncate } from '../core/text.js'; @@ -939,13 +940,18 @@ async function displayWithLess(doc: RenderedDoc, filePath: string): Promise { spinner.stop(); const subDirs = new Set(groupFiles.map((f) => f.path.split('/')[2]).filter(Boolean)); const fileSelected = await runMenu({ - title: `${trans.docs.categoryArchived} · ${groupKey}`, + title: `${trans.docs.categoryArchived} ${glyph.sep()} ${groupKey}`, options: [ ...groupFiles.map((f) => { const sub = f.path.split('/').slice(2, -1).join('/'); diff --git a/src/features/schedule-render.ts b/src/features/schedule-render.ts index 4cae7ce..b603959 100644 --- a/src/features/schedule-render.ts +++ b/src/features/schedule-render.ts @@ -58,7 +58,7 @@ export function renderNextClassBanner( const when = formatClassCountdown(p); const styleWhen = isCountdownUrgent(p) ? c.warn : type.hint; const whenStyled = styleWhen(when); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const marker = type.active(glyph.dot()); const separator = ` ${dot} `; const detailedPrefix = labelled @@ -152,7 +152,7 @@ function renderTimeline( const today = campusIsoDate(now); const minute = Math.floor(now.getTime() / 60_000) * 60_000; - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const rule = pickIcon('─', '-'); const midConnector = pickIcon('┼', '+'); const topConnector = pickIcon('┬', '+'); @@ -504,7 +504,7 @@ export function renderWeekAgenda( const todayWd = schedule.weekdayAt(now); const width = lineBudget(cols); const todayMark = pickIcon('•', '*'); - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const pointer = glyph.cursor(); const labels = WEEKDAY_KEYS.map( (_, i) => `${weekdayShortLabel(i + 1)}${i + 1 === todayWd ? todayMark : ''}`, @@ -661,7 +661,7 @@ export function renderUnresolvedItems( .map((part) => `${indent}${part}`) .join('\n'); } - const dot = pickIcon('·', '-'); + const dot = glyph.sep(); const lines: string[] = []; for (const item of items) { const name = item.sourceFields.kcmc ?? trans.timetable.unresolvedUnknownItem; @@ -782,7 +782,7 @@ export function renderTermDensity( ]; const summaryLines: string[] = []; for (const part of summary) { - const joined = `${summaryLines.at(-1) ?? ''} ${pickIcon('·', '-')} ${part}`; + const joined = `${summaryLines.at(-1) ?? ''} ${glyph.sep()} ${part}`; if (summaryLines.length > 0 && visualWidth(joined) <= contentWidth) { summaryLines[summaryLines.length - 1] = joined; } else { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 80be2e9..0861701 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -12,9 +12,7 @@ export type Language = 'zh' | 'en'; export interface Translations { common: { back: string; - exit: string; cancel: string; - confirm: string; loading: string; error: string; success: string; @@ -35,14 +33,13 @@ export interface Translations { timetable: string; timetableDesc: string; links: string; - linksDesc: string; settings: string; - settingsDesc: string; chooseAction: string; hintMove: string; hintTabs: string; hintBack: string; hintOpen: string; + hintConfirm: string; hintQuit: string; }; about: { @@ -109,6 +106,7 @@ export interface Translations { searchPrompt: string; searchPlaceholder: string; searchNoResults: string; + searchResultsTitle: string; exportIcs: string; exportSuccess: string; exportError: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 93de048..c37e6ec 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1,9 +1,7 @@ { "common": { "back": "Back", - "exit": "Exit", "cancel": "Cancel", - "confirm": "Confirm", "loading": "Loading...", "error": "Error", "success": "Success", @@ -39,14 +37,13 @@ "timetable": "My Timetable", "timetableDesc": "Log in to JWXT, choose a term and export ICS", "links": "Links", - "linksDesc": "website · GitHub · roadmap", "settings": "Settings", - "settingsDesc": "language · theme · about", "chooseAction": "nbtca", "hintMove": "move", "hintTabs": "switch", "hintBack": "back", "hintOpen": "open", + "hintConfirm": "confirm", "hintQuit": "quit" }, "about": { @@ -76,7 +73,7 @@ "viewDetail": "Select an event for details:", "noDescription": "No additional details", "heatmap": { - "title": "Activity (last 12 months)", + "title": "Activity (Last 12 Months)", "legendLess": "Less", "legendMore": "More" }, @@ -95,8 +92,9 @@ "thisMonth": "This Month", "search": "Search", "searchPrompt": "Search events", - "searchPlaceholder": "keyword…", + "searchPlaceholder": "Enter keyword...", "searchNoResults": "No matching events", + "searchResultsTitle": "Results for “{query}” {sep} {count}", "exportIcs": "Export .ics", "exportSuccess": "Saved", "exportError": "Could not write the .ics file", @@ -147,7 +145,7 @@ "tocTitle": "Table of Contents", "tableHint": "diagrams and tables render better in browser", "mermaidHint": "open in browser to view", - "endOfDocument": "end of document · q quit · / search", + "endOfDocument": "end of document {sep} q quit {sep} / search", "githubRateLimited": "GitHub API rate limit reached. Resets at {time}.", "githubForbidden": "GitHub API access denied (403).", "githubTokenHint": "Tip: set GITHUB_TOKEN for a higher rate limit.", @@ -165,7 +163,7 @@ "offlineError": "Couldn't load the docs. You may be offline.", "offlineHint": "Try again later, or read them at https://docs.nbtca.space", "retryLoad": "Retry", - "searchResultsTitle": "Results for “{query}” · {count}", + "searchResultsTitle": "Results for “{query}” {sep} {count}", "searchProgress": "Searching every document: {done} of {total} checked" }, "links": { @@ -288,7 +286,7 @@ "semester2": "Term 2", "weekLabel2": "Week {week}", "academicYearSuffix": "{year}", - "onBreak": "On break · {title}", + "onBreak": "On break {sep} {title}", "publicUnavailable": "Academic calendar not available yet", "daysUntilBreak": "{days} days until {title}", "publicLoginAction": "Log in to see my timetable", @@ -301,13 +299,13 @@ "weekdayFri": "Fri", "weekdaySat": "Sat", "weekdaySun": "Sun", - "todayHeading": "Today · {weekday} · Week {week}", + "todayHeading": "Today {sep} {weekday} {sep} Week {week}", "classDone": "Done", "classLive": "In progress", "minutesRemaining": "{minutes} min left", "timelineEnd": "End of day", "termNotStarted": "Term hasn't started yet", - "termStartsIn": "Classes begin {date} · {days} days to go", + "termStartsIn": "Classes begin {date} {sep} {days} days to go", "hubTermDensity": "Term density", "termDensityTitle": "Term density", "termDensityThisWeek": "This week", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 42ab766..f855c11 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1,9 +1,7 @@ { "common": { "back": "返回", - "exit": "退出", "cancel": "取消", - "confirm": "确认", "loading": "加载中...", "error": "错误", "success": "成功", @@ -39,14 +37,13 @@ "timetable": "我的课表", "timetableDesc": "登录教务、查询学期并导出 ICS", "links": "链接", - "linksDesc": "官网 · GitHub · 路线图", "settings": "设置", - "settingsDesc": "语言 · 主题 · 关于", "chooseAction": "nbtca", "hintMove": "移动", "hintTabs": "切换", "hintBack": "返回", "hintOpen": "打开", + "hintConfirm": "确认", "hintQuit": "退出" }, "about": { @@ -73,7 +70,7 @@ "untitledEvent": "未命名活动", "tbdLocation": "待定", "subscribeHint": "订阅日历", - "viewDetail": "选择活动查看详情:", + "viewDetail": "选择活动查看详情:", "noDescription": "暂无详细信息", "heatmap": { "title": "近一年活跃度", @@ -84,7 +81,7 @@ "pastEvents": "历史活动", "pastEventsDesc": "近期活动记录", "noPastEvents": "近一年没有已结束的活动", - "viewPastDetail": "选择活动查看详情:", + "viewPastDetail": "选择活动查看详情:", "next": "下一场", "recentActivity": "近期安排", "browse": "浏览", @@ -95,8 +92,9 @@ "thisMonth": "本月", "search": "搜索", "searchPrompt": "搜索活动", - "searchPlaceholder": "关键词…", + "searchPlaceholder": "输入关键词...", "searchNoResults": "没有匹配的活动", + "searchResultsTitle": "“{query}”的搜索结果 {sep} {count} 场", "exportIcs": "导出 .ics", "exportSuccess": "已保存", "exportError": "无法写入 .ics 文件", @@ -147,12 +145,12 @@ "tocTitle": "目录", "tableHint": "图表与表格在浏览器中效果更佳", "mermaidHint": "在浏览器中查看图表", - "endOfDocument": "文档结束 · q 退出 · / 搜索", + "endOfDocument": "文档结束 {sep} q 退出 {sep} / 搜索", "githubRateLimited": "GitHub API 速率限制已达上限,将在 {time} 重置。", "githubForbidden": "GitHub API 拒绝访问 (403)。", - "githubTokenHint": "提示: 设置 GITHUB_TOKEN 环境变量可获得更高的速率限制。", - "fetchDirFailed": "无法获取目录内容: {error}", - "fetchFileFailed": "无法获取文件内容: {error}", + "githubTokenHint": "提示:设置 GITHUB_TOKEN 环境变量可获得更高的速率限制。", + "fetchDirFailed": "无法获取目录内容:{error}", + "fetchFileFailed": "无法获取文件内容:{error}", "searchPrompt": "搜索文档", "searchPlaceholder": "输入关键词...", "searching": "正在搜索文档...", @@ -165,7 +163,7 @@ "offlineError": "无法加载文档目录,可能是网络没有连接。", "offlineHint": "请稍后重试,或者到 https://docs.nbtca.space 阅读", "retryLoad": "重试", - "searchResultsTitle": "“{query}”的搜索结果 · {count} 篇", + "searchResultsTitle": "“{query}”的搜索结果 {sep} {count} 篇", "searchProgress": "正在逐篇检索文档,已检索 {done} / {total} 篇" }, "links": { @@ -177,7 +175,7 @@ "opening": "正在打开...", "opened": "已在浏览器中打开", "error": "无法打开浏览器", - "openManually": "请手动打开: {url}" + "openManually": "请手动打开:{url}" }, "status": { "checking": "正在检查服务状态...", @@ -288,7 +286,7 @@ "semester2": "第二学期", "weekLabel2": "第 {week} 周", "academicYearSuffix": "{year}学年", - "onBreak": "假期中 · {title}", + "onBreak": "假期中 {sep} {title}", "publicUnavailable": "校历数据暂不可用", "daysUntilBreak": "距离{title}还有 {days} 天", "publicLoginAction": "登录查看我的课表", @@ -301,13 +299,13 @@ "weekdayFri": "五", "weekdaySat": "六", "weekdaySun": "日", - "todayHeading": "今日 · 周{weekday} · 第 {week} 教学周", + "todayHeading": "今日 {sep} 周{weekday} {sep} 第 {week} 教学周", "classDone": "已结束", "classLive": "进行中", "minutesRemaining": "还剩 {minutes} 分钟", "timelineEnd": "放学", "termNotStarted": "本学期尚未开始", - "termStartsIn": "{date} 开学 · 还有 {days} 天", + "termStartsIn": "{date} 开学 {sep} 还有 {days} 天", "hubTermDensity": "学期活跃度", "termDensityTitle": "学期活跃度", "termDensityThisWeek": "本周", @@ -365,7 +363,7 @@ "colorUpdated": "颜色模式已更新", "colorUpdatedSessionOnly": "颜色模式仅在当前会话生效(无法写入配置文件)", "usage": "用法: nbtca theme | nbtca theme icon | nbtca theme color | nbtca theme reset", - "invalidValue": "无效取值,可选:" + "invalidValue": "无效取值,可选:" }, "language": { "selectLanguage": "语言", @@ -375,10 +373,10 @@ "changedSessionOnly": "语言仅在当前会话生效(无法写入配置文件)" }, "update": { - "available": "有新版本可用: {current} → {latest}", - "upToDate": "已是最新版本 ({version})", + "available": "有新版本可用:{current} → {latest}", + "upToDate": "已是最新版本({version})", "checkFailed": "无法检查更新", - "command": "运行: npm i -g @nbtca/prompt" + "command": "运行:npm i -g @nbtca/prompt" }, "cli": { "usage": "用法:", @@ -415,16 +413,16 @@ "flagTerm": "选择学年学期(例如 2026:3)", "flagOutput": "ICS 输出路径", "flagWeekOne": "第一教学周周一(教务未返回日期时使用)", - "unknownCommand": "未知命令: {command}", + "unknownCommand": "未知命令:{command}", "unknownCommandHint": "运行 `nbtca --help` 查看可用命令。", - "unknownFlag": "未知选项: {flag}", + "unknownFlag": "未知选项:{flag}", "unknownFlagHint": "运行 `nbtca --help` 查看可用选项。", "invalidFlag": "选项 {flag} 对此命令无效。", "invalidFlagHint": "运行 `nbtca --help` 查看命令用法。", "invalidLang": "无效语言。请使用 `zh` 或 `en`。", "invalidNext": "无效的 --next 值。请使用 --next=<数字>(>= 1)。", "eventsHeatmapConflict": "--heatmap 不能与 --today、--week、--month、--next 或 --search 同时使用。", - "eventsRangeConflict": "活动时间范围只能选择一个: --today、--week 或 --month。", + "eventsRangeConflict": "活动时间范围只能选择一个:--today、--week 或 --month。", "unexpectedArguments": "{command} 不接受多余参数。", "requiresTty": "交互模式需要 TTY 终端。", "requiresTtyHint": "使用 `nbtca --help` 查看命令模式。" From 985d8e08e44ecec9d6274cf204575968c6a24479 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 27 Sep 2026 01:30:14 +0800 Subject: [PATCH 8/8] fix(docs): say the browser fallback in one localized sentence --- src/app/views/docs.ts | 4 +--- src/i18n/index.ts | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/zh.json | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/views/docs.ts b/src/app/views/docs.ts index c6c5546..3b58ee8 100644 --- a/src/app/views/docs.ts +++ b/src/app/views/docs.ts @@ -114,9 +114,7 @@ async function openUrlFromView( state = { ...state, - errorMessage: sanitizeTerminalLine( - `${t().docs.browserError}. ${fmt(t().links.openManually, { url })}`, - ), + errorMessage: sanitizeTerminalLine(fmt(t().docs.browserErrorManual, { url })), }; ctx.rerender(); } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 0861701..eef876c 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -152,6 +152,7 @@ export interface Translations { opening: string; browserOpened: string; browserError: string; + browserErrorManual: string; retry: string; endOfDocument: string; githubRateLimited: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c37e6ec..3d03f56 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -141,6 +141,7 @@ "opening": "Opening browser...", "browserOpened": "Knowledge base opened in browser", "browserError": "Failed to open browser", + "browserErrorManual": "Failed to open browser. Open manually: {url}", "retry": "Retry?", "tocTitle": "Table of Contents", "tableHint": "diagrams and tables render better in browser", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f855c11..0100702 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -141,6 +141,7 @@ "opening": "正在打开浏览器...", "browserOpened": "已在浏览器中打开知识库", "browserError": "无法打开浏览器", + "browserErrorManual": "无法打开浏览器,请手动打开:{url}", "retry": "是否重试?", "tocTitle": "目录", "tableHint": "图表与表格在浏览器中效果更佳",