2361775e9f
- Introduced `selector-helpers.ts` to centralize reusable list, dashboard, and selection utilities. - Refactored `AgentDashboard`, `ExtensionList`, `HistoryResultsList`, and `TreeList` to use standardized rendering and navigation helpers. - Encapsulated viewport padding, scrolling logic, and key matching to reduce code duplication across TUI components. - Maintained consistent scrollbar themes and keyboard behaviors while simplifying component-specific implementations.
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
/**
|
|
* Shared scaffolding for the TUI selector/list/dashboard components: viewport
|
|
* windowing, scrollbar-aware row widths, ScrollView rendering, selection
|
|
* clamping, search-character classification, tab-cycling keys, and full-screen
|
|
* padding. Behaviour is identical to the per-component copies these helpers
|
|
* replace.
|
|
*/
|
|
import { extractPrintableText, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
|
|
import { theme } from "../theme/theme";
|
|
|
|
/**
|
|
* Render `rows` through a {@link ScrollView} with the shared list theme (muted
|
|
* track / accent thumb) and an "auto" scrollbar, positioned at `scrollOffset`.
|
|
* Returns the rendered lines for the caller to append to its output.
|
|
*/
|
|
export function renderScrollableList(
|
|
rows: readonly string[],
|
|
options: { width: number; totalRows: number; scrollOffset: number },
|
|
): readonly string[] {
|
|
const sv = new ScrollView(rows, {
|
|
height: rows.length,
|
|
scrollbar: "auto",
|
|
totalRows: options.totalRows,
|
|
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
});
|
|
sv.setScrollOffset(options.scrollOffset);
|
|
return sv.render(options.width);
|
|
}
|
|
|
|
/**
|
|
* Center a viewport window of `maxVisible` rows on `selectedIndex` within a
|
|
* list of `total` rows, clamped to valid bounds. Used by the selection-centered
|
|
* list panes (history search, tree selector).
|
|
*/
|
|
export function centeredWindow(
|
|
selectedIndex: number,
|
|
total: number,
|
|
maxVisible: number,
|
|
): { startIndex: number; endIndex: number } {
|
|
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
|
|
const endIndex = Math.min(startIndex + maxVisible, total);
|
|
return { startIndex, endIndex };
|
|
}
|
|
|
|
/**
|
|
* Width available for row content, reserving the rightmost column for the
|
|
* scrollbar when the list overflows its visible window.
|
|
*/
|
|
export function contentRowWidth(width: number, total: number, maxVisible: number): number {
|
|
const overflow = total > maxVisible;
|
|
return Math.max(0, width - (overflow ? 1 : 0));
|
|
}
|
|
|
|
/**
|
|
* Clamp `selectedIndex` into `[0, total)` and nudge `scrollOffset` so the
|
|
* selection stays within the visible window of `maxVisible` rows. Returns the
|
|
* adjusted pair; on an empty list both reset to 0.
|
|
*/
|
|
export function clampSelection(
|
|
selectedIndex: number,
|
|
scrollOffset: number,
|
|
total: number,
|
|
maxVisible: number,
|
|
): { selectedIndex: number; scrollOffset: number } {
|
|
if (total === 0) {
|
|
return { selectedIndex: 0, scrollOffset: 0 };
|
|
}
|
|
|
|
const selected = Math.max(0, Math.min(selectedIndex, total - 1));
|
|
|
|
let scroll = scrollOffset;
|
|
if (selected < scroll) {
|
|
scroll = selected;
|
|
} else if (selected >= scroll + maxVisible) {
|
|
scroll = selected - maxVisible + 1;
|
|
}
|
|
|
|
return { selectedIndex: selected, scrollOffset: scroll };
|
|
}
|
|
|
|
/**
|
|
* Classify a key event for search-query text entry. Returns the single
|
|
* printable character to append to the query, or `null` when the key is not a
|
|
* searchable character: non-printable, multi-byte, or a reserved `j`/`k`
|
|
* navigation key.
|
|
*/
|
|
export function searchableChar(data: string): string | null {
|
|
const printableText = extractPrintableText(data);
|
|
if (printableText && printableText.length === 1) {
|
|
const printableCharCode = printableText.charCodeAt(0);
|
|
if (printableCharCode > 32 && printableCharCode < 127) {
|
|
if (printableText === "j" || printableText === "k") {
|
|
return null;
|
|
}
|
|
return printableText;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Handle the shared tab-cycling keys: Tab/Right advance to the next tab,
|
|
* Shift+Tab/Left to the previous. Invokes `switchTab` with the direction and
|
|
* returns true when the key was consumed.
|
|
*/
|
|
export function handleTabSwitchKey(data: string, switchTab: (direction: 1 | -1) => void): boolean {
|
|
if (matchesKey(data, "tab") || matchesKey(data, "right")) {
|
|
switchTab(1);
|
|
return true;
|
|
}
|
|
if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
|
|
switchTab(-1);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Pad `lines` with blank rows up to `rows` so a full-screen overlay covers the
|
|
* viewport instead of letting the transcript peek through below it. Copies
|
|
* before padding — the source array may be component-owned and must not be
|
|
* mutated.
|
|
*/
|
|
export function padLinesToHeight(lines: readonly string[], rows: number): readonly string[] {
|
|
if (lines.length >= rows) return lines;
|
|
const padded = lines.slice();
|
|
while (padded.length < rows) padded.push("");
|
|
return padded;
|
|
}
|