Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 80 additions & 18 deletions src/app/views/schedule-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,13 +448,78 @@ describe('renderSchedule', () => {
timetable: busyTimetable,
},
campusDateTime('2026-09-07', '07:00'),
19,
12,
80,
).map(stripAnsi);

expect(lines.filter((line) => line.includes('08:00'))).toHaveLength(1);
});

it('shows the week agenda instead of one day when it fits below a hundred columns', () => {
const lines = renderSchedule(
{ mode: 'hub', key: '2026-3', weekOne: '2026-09-07', timetable: busyTimetable },
campusDateTime('2026-09-07', '07:00'),
25,
100,
).map(stripAnsi);
const out = lines.join('\n');

expect(out).toMatch(/Mon\S? +08:00 Math/);
expect(out).toMatch(/ +10:00 Physics/);
expect(out).toContain('Sun');
expect(lines.some((line) => line.trimStart().startsWith('←'))).toBe(false);
});

it('hides [w] while the hub already shows the whole week grid', () => {
const render = (rows: number, cols: number): string =>
stripAnsi(
renderSchedule(
{ mode: 'hub', key: '2026-3', weekOne: '2026-09-07', timetable: busyTimetable },
campusDateTime('2026-09-07', '07:00'),
rows,
cols,
).join('\n'),
);

expect(render(45, 120)).toMatch(/19:00.19:45/);
expect(render(45, 120)).not.toContain('[w]');
expect(render(25, 100)).toContain('[w]');
});

it.each([
['en', 'Saved: '],
['zh', '已保存:'],
] as const)(
'keeps the grid after an export and shows the path on one line (%s)',
(lang, prefix) => {
setLanguage(lang);
try {
const exportedPath = `/Users/someone/${'nested-directory/'.repeat(8)}timetable-2026-3.ics`;
const lines = renderSchedule(
{
mode: 'hub',
key: '2026-3',
weekOne: '2026-09-07',
timetable: busyTimetable,
exportedPath,
},
campusDateTime('2026-09-07', '07:00'),
35,
120,
).map(stripAnsi);
const status = lines.filter((line) => line.includes(prefix));

expect(lines.join('\n')).toContain('19:00');
expect(status).toHaveLength(1);
expect(status[0]).toMatch(/^ {3}\S.*\/Users\/someone\/.*….*\/timetable-2026-3\.ics$/);
expect(lines.every((line) => visualWidth(line) <= 120 - 3)).toBe(true);
expect(lines.length).toBeLessThanOrEqual(35);
} finally {
setLanguage('en');
}
},
);

it('wraps the full shortcut bar within a narrow terminal', () => {
const lines = renderSchedule(
{
Expand Down Expand Up @@ -524,7 +589,7 @@ describe('renderSchedule', () => {
gridCursor: { weekday: 6, period: 1 },
},
campusDateTime('2026-09-07', '07:00'),
15,
12,
40,
);
const switcher = lines.find((line) => stripAnsi(line).includes('[Sat]'));
Expand Down Expand Up @@ -575,8 +640,8 @@ describe('renderSchedule', () => {
}
});

it('threads the real terminal column width down to the grid, so a wide terminal stops truncating real course names', () => {
const longName = '习近平新时代中国特色社会主义思想概论';
it('prefers the agenda over a grid that would clip names, until the frame cannot grow', () => {
const longName = '习近平新时代中国特色社会主义';
const longNameTimetable: Timetable = {
...timetable,
meetings: [
Expand Down Expand Up @@ -613,18 +678,14 @@ describe('renderSchedule', () => {
},
campusDateTime('2026-09-07', '07:00'),
45,
210,
120,
).map((l) => stripAnsi(l));
const narrowHeadingIdx = narrowLines.findIndex((l) => l.includes('This week'));
const wideHeadingIdx = wideLines.findIndex((l) => l.includes('This week'));
const narrowGridRow = defined(
narrowLines.slice(narrowHeadingIdx).find((l) => l.trim().startsWith('08:00')),
);
const wideGridRow = defined(
wideLines.slice(wideHeadingIdx).find((l) => l.trim().startsWith('08:00')),
);
expect(narrowGridRow).not.toContain(longName);
expect(wideGridRow).toContain(longName);
const gridRow = (lines: string[]): string | undefined =>
lines.find((l) => /^\s*08:00.08:45 /.test(l));

expect(gridRow(narrowLines)).toBeUndefined();
expect(narrowLines.join('\n')).toMatch(new RegExp(`08:00 ${longName}`));
expect(gridRow(wideLines)).toContain(longName);
});
});

Expand Down Expand Up @@ -819,11 +880,12 @@ describe('renderSchedule', () => {
20,
);
const plain = lines.map(stripAnsi);
const densityLines = plain.filter((line) => /^\s*(?:█\s*)+$/.test(line));
const weekNumbers = plain
.filter((line) => /^\s+\d+(?:\s+\d+)*$/.test(line))
.flatMap((line) => line.trim().split(/\s+/).map(Number));

expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true);
expect(densityLines).toHaveLength(2);
expect(densityLines.join('').match(/█/g)).toHaveLength(18);
expect(weekNumbers).toEqual(weeks);
expect(plain.some((line) => line.includes('This week'))).toBe(true);
});

Expand Down
137 changes: 99 additions & 38 deletions src/app/views/schedule-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type TimetableMeeting,
type Weekday,
} from '@nbtca/nbtcal/timetable';
import { c, type, space, glyph } from '../../core/theme.js';
import { c, type, space, glyph, MAX_FRAME_COLS } from '../../core/theme.js';
import { pickIcon } from '../../core/icons.js';
import { t, fmt } from '../../i18n/index.js';
import { type ListField, renderListFieldWithContext } from '../fields/list-field.js';
Expand All @@ -22,11 +22,18 @@ import {
renderDayTimeline,
renderDaySwitcher,
renderWeekAgenda,
weekGridFullWidth,
lineBudget,
} from '../../features/schedule-render.js';
import type { AcademicWindow, OnBreak } from '@nbtca/nbtcal';
import type { GridCursor } from './schedule-grid-cursor.js';
import { sanitizeTerminalLine, visualWidth, wrapAnsiWithIndent } from '../../core/text.js';
import {
sanitizeTerminalLine,
truncate,
truncateStart,
visualWidth,
wrapAnsiWithIndent,
} from '../../core/text.js';
import { isoDayDifference } from '../../core/calendar-day.js';
import { loadingLines } from '../../core/components/spinner.js';

Expand All @@ -49,6 +56,7 @@ export interface ScheduleViewState {
mode: ScheduleMode;
errorMessage?: string;
statusMessage?: string;
exportedPath?: string;
idField?: TextField;
passwordField?: TextField;
weekOneField?: TextField;
Expand Down Expand Up @@ -202,8 +210,6 @@ function hubPreGridLines(
return { inlineLines, fallbackLines: [...lines, weekHeading], week, tt };
}

const MIN_GRID_COLS = 100;

function fittingWeekGrid(
tt: Timetable,
week: number,
Expand All @@ -212,7 +218,7 @@ function fittingWeekGrid(
cols: number,
cursor: GridCursor | undefined,
): string[] | null {
if (cols < MIN_GRID_COLS) return null;
if (cols < Math.min(MAX_FRAME_COLS, weekGridFullWidth(tt, week, now))) return null;
const grid = renderWeekGrid(tt, week, now, cols, cursor).split('\n');
return grid.length <= rows ? grid : null;
}
Expand All @@ -239,28 +245,36 @@ function renderDayFallback(
];
}

function renderAdaptiveWeekGrid(
interface HubWeek {
lines: string[];
grid: boolean;
}

function renderHubWeek(
inlineLines: string[],
fallbackLines: string[],
tt: Timetable,
week: number,
todayWd: Weekday,
now: Date,
bodyRows: number,
rows: number,
cols: number,
cursor: GridCursor | undefined,
reservedRows: number,
): string[] {
const grid = fittingWeekGrid(
tt,
week,
now,
bodyRows - reservedRows - inlineLines.length,
cols,
cursor,
);
if (grid) return [...inlineLines, ...grid];
return [...fallbackLines, ...renderDayFallback(tt, week, todayWd, now, cols, cursor)];
): HubWeek {
const layouts: [string[] | null, boolean][] = [
[fittingWeekGrid(tt, week, now, rows, cols, cursor), true],
[renderWeekAgenda(tt, week, now, cols, cursor), false],
];
for (const [layout, grid] of layouts) {
if (!layout) continue;
for (const head of [inlineLines, inlineLines.filter((line) => line !== ''), fallbackLines]) {
if (head.length + layout.length <= rows) return { lines: [...head, ...layout], grid };
}
}
return {
lines: [...fallbackLines, ...renderDayFallback(tt, week, todayWd, now, cols, cursor)],
grid: false,
};
}

function renderWeekBody(
Expand Down Expand Up @@ -293,55 +307,102 @@ function renderWeekBody(
}

const MIN_HUB_CONTENT_ROWS = 4;
const MIN_PATH_COLS = 12;

function renderHubBody(
function middleClip(text: string, width: number): string {
if (visualWidth(text) <= width) return text;
const mark = pickIcon('…', '...');
const room = width - visualWidth(mark);
const tail = Math.ceil((room * 2) / 3);
return `${truncate(text, room - tail, '')}${mark}${truncateStart(text, tail, '')}`;
}

function hubStatusLines(state: ScheduleViewState, cols: number): string[] {
if (state.exportedPath !== undefined) {
const template = t().timetable.exportSaved;
const path = sanitizeTerminalLine(state.exportedPath);
const room = lineBudget(cols) - visualWidth(space.indent + fmt(template, { path: '' }));
return room < MIN_PATH_COLS
? hintLines(fmt(template, { path }), cols)
: [hint(fmt(template, { path: middleClip(path, room) }))];
}
return state.statusMessage ? hintLines(state.statusMessage, cols) : [];
}

function layoutHub(
state: ScheduleViewState,
now: Date,
bodyRows: number,
cols: number,
): string[] {
const tt = state.timetable;
shortcuts: readonly HubShortcut[],
): HubWeek {
const pre = hubPreGridLines(state, now, cols);
const shortcuts = tt ? hubShortcuts(tt) : [];
const status = hubStatusLines(state, cols);
const rows = Math.max(0, Math.floor(bodyRows));

const build = (shortcutLines: string[], gap = true): { content: string[]; tail: string[] } => {
const build = (shortcutLines: string[], gap = true): HubWeek & { tail: string[] } => {
const tail: string[] = [];
if (gap && pre && (state.statusMessage || shortcutLines.length > 0)) tail.push('');
if (state.statusMessage) {
tail.push(...hintLines(state.statusMessage, cols));
if (gap && pre && (status.length > 0 || shortcutLines.length > 0)) tail.push('');
if (status.length > 0) {
tail.push(...status);
if (shortcutLines.length > 0) tail.push('');
}
tail.push(...shortcutLines);
const content = pre
? renderAdaptiveWeekGrid(
const week = pre
? renderHubWeek(
pre.inlineLines,
pre.fallbackLines,
pre.tt,
pre.week,
createTimetableSchedule(pre.tt).weekdayAt(now),
now,
rows,
rows - tail.length,
cols,
state.gridCursor,
tail.length,
)
: [];
return { content, tail };
: { lines: [], grid: false };
return { ...week, tail };
};

const labelled = renderShortcutLines(shortcuts, cols);
const full = build(labelled);
if (full.content.length + full.tail.length <= rows) return [...full.content, ...full.tail];
if (full.lines.length + full.tail.length <= rows) {
return { lines: [...full.lines, ...full.tail], grid: full.grid };
}
const tight = build(labelled, false);
if (rows - tight.tail.length >= MIN_HUB_CONTENT_ROWS) {
const content = tight.content.filter((line) => line !== '');
return [...content.slice(0, rows - tight.tail.length), ...tight.tail];
const content = tight.lines.filter((line) => line !== '');
return {
lines: [...content.slice(0, rows - tight.tail.length), ...tight.tail],
grid: tight.grid,
};
}

const compact = build(renderShortcutLines(shortcuts, cols, true));
if (compact.tail.length >= rows) return rows > 0 ? compact.tail.slice(-rows) : [];
return [...compact.content.slice(0, rows - compact.tail.length), ...compact.tail];
if (compact.tail.length >= rows) {
return { lines: rows > 0 ? compact.tail.slice(-rows) : [], grid: false };
}
return {
lines: [...compact.lines.slice(0, rows - compact.tail.length), ...compact.tail],
grid: compact.grid,
};
}

function renderHubBody(
state: ScheduleViewState,
now: Date,
bodyRows: number,
cols: number,
): string[] {
const shortcuts = state.timetable ? hubShortcuts(state.timetable) : [];
const withGrid = layoutHub(
state,
now,
bodyRows,
cols,
shortcuts.filter((shortcut) => shortcut.key !== 'w'),
);
return withGrid.grid ? withGrid.lines : layoutHub(state, now, bodyRows, cols, shortcuts).lines;
}

const TERM_PROGRESS_WIDTH = 20;
Expand Down
Loading
Loading