Skip to content
18 changes: 8 additions & 10 deletions src/app/chrome.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -127,18 +125,18 @@ 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',
);
}

export function digitTabHint(tabCount: number): string {
const dot = pickIcon('·', '-');
const dot = glyph.sep();
return tabCount > 1 ? `1-${tabCount} / Tab ${t().menu.hintTabs} ${dot} ` : '';
}

Expand All @@ -148,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} ` : '';
Expand All @@ -165,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}`;
Expand Down
3 changes: 1 addition & 2 deletions src/app/fields/list-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)}`,
),
Expand Down
60 changes: 60 additions & 0 deletions src/app/views/docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -290,6 +319,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();
Expand Down
31 changes: 20 additions & 11 deletions src/app/views/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,24 @@ async function openUrlFromView(

state = {
...state,
errorMessage: sanitizeTerminalLine(
`${t().docs.browserError}. ${fmt(t().links.openManually, { url })}`,
),
errorMessage: sanitizeTerminalLine(fmt(t().docs.browserErrorManual, { url })),
};
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 },
Expand Down Expand Up @@ -168,8 +171,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 });
Expand All @@ -191,15 +195,15 @@ 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,
),
};
}),
{ value: '__back__', label: backLabel() },
];
return new ListField({
title: `${trans.docs.categoryArchived} · ${groupKey}`,
title: `${trans.docs.categoryArchived} ${glyph.sep()} ${groupKey}`,
options,
maxVisible,
initialIndex,
Expand Down Expand Up @@ -239,11 +243,16 @@ function buildSearchResultsField(
? sanitizeTerminalLine(result.path.split('/').slice(0, -1).join('/'))
: undefined),
),
hintFocus: currentSearchQuery,
})),
{ 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,
Expand Down Expand Up @@ -681,7 +690,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} `;
Expand Down
10 changes: 5 additions & 5 deletions src/app/views/events-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'],
],
[
'活动',
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions src/app/views/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
12 changes: 8 additions & 4 deletions src/app/views/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/app/views/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
4 changes: 3 additions & 1 deletion src/app/views/schedule-grid-cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/app/views/schedule-grid-cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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' };
}
Loading
Loading