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
29 changes: 29 additions & 0 deletions src/app/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
});
57 changes: 30 additions & 27 deletions src/app/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -105,7 +112,7 @@ export async function runApp(): Promise<void> {
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);
Expand Down Expand Up @@ -154,15 +161,8 @@ export async function runApp(): Promise<void> {
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;
Expand All @@ -172,6 +172,10 @@ export async function runApp(): Promise<void> {
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
Expand All @@ -187,27 +191,26 @@ export async function runApp(): Promise<void> {
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];
Expand Down
67 changes: 44 additions & 23 deletions src/app/chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand All @@ -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');
});
});

Expand All @@ -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);
Expand Down
62 changes: 35 additions & 27 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 } 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';
Expand All @@ -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));
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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] ??
''
);
Expand All @@ -140,41 +142,47 @@ 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',
);
}

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(
Expand Down Expand Up @@ -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);
}
4 changes: 2 additions & 2 deletions src/app/fields/list-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);
}

Expand Down
Loading
Loading