@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
type Keybinding,
|
type Keybinding,
|
||||||
Spacer,
|
Spacer,
|
||||||
|
sliceByColumn,
|
||||||
Text,
|
Text,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
visibleWidth,
|
visibleWidth,
|
||||||
@@ -37,6 +38,59 @@ interface FlatNode {
|
|||||||
isVirtualRootChild: boolean;
|
isVirtualRootChild: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HorizontalViewportRow {
|
||||||
|
gutter: string;
|
||||||
|
body: string;
|
||||||
|
anchorCol: number;
|
||||||
|
bodyWidth: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TREE_GUTTER_WIDTH = 2;
|
||||||
|
const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4;
|
||||||
|
const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20;
|
||||||
|
const MIN_ANCHOR_CONTEXT_WIDTH = 2;
|
||||||
|
const MAX_ANCHOR_CONTEXT_WIDTH = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tree rows into a horizontally clipped viewport.
|
||||||
|
*
|
||||||
|
* The tree gutter is always kept visible. The row bodies are shifted left only
|
||||||
|
* when the selected row's anchor (the start of its entry text after tree
|
||||||
|
* indentation/markers) would otherwise be too far right to see useful content.
|
||||||
|
*/
|
||||||
|
function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] {
|
||||||
|
const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH);
|
||||||
|
const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0);
|
||||||
|
const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth);
|
||||||
|
const selectedRow = rows.find((row) => row.isSelected);
|
||||||
|
|
||||||
|
// Only pan horizontally when needed to keep enough selected-row content visible after its anchor.
|
||||||
|
let horizontalScroll = 0;
|
||||||
|
if (selectedRow && maxHorizontalScroll > 0) {
|
||||||
|
const minVisibleAnchorContentWidth = Math.min(
|
||||||
|
MAX_VISIBLE_ANCHOR_CONTENT_WIDTH,
|
||||||
|
Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)),
|
||||||
|
);
|
||||||
|
if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) {
|
||||||
|
const anchorContextWidth = Math.min(
|
||||||
|
MAX_ANCHOR_CONTEXT_WIDTH,
|
||||||
|
Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)),
|
||||||
|
);
|
||||||
|
horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clip only the body; the fixed-width gutter remains visible as navigation context.
|
||||||
|
return rows.map((row) => {
|
||||||
|
const line =
|
||||||
|
horizontalScroll > 0
|
||||||
|
? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m`
|
||||||
|
: row.gutter + row.body;
|
||||||
|
return truncateToWidth(line, width, "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Filter mode for tree display */
|
/** Filter mode for tree display */
|
||||||
export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
||||||
|
|
||||||
@@ -619,6 +673,7 @@ class TreeList implements Component {
|
|||||||
);
|
);
|
||||||
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
|
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
|
||||||
|
|
||||||
|
const renderedRows: HorizontalViewportRow[] = [];
|
||||||
for (let i = startIndex; i < endIndex; i++) {
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
const flatNode = this.filteredNodes[i];
|
const flatNode = this.filteredNodes[i];
|
||||||
const entry = flatNode.node.entry;
|
const entry = flatNode.node.entry;
|
||||||
@@ -682,14 +737,18 @@ class TreeList implements Component {
|
|||||||
? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `)
|
? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `)
|
||||||
: "";
|
: "";
|
||||||
const content = this.getEntryDisplayText(flatNode.node, isSelected);
|
const content = this.getEntryDisplayText(flatNode.node, isSelected);
|
||||||
|
const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker;
|
||||||
let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content;
|
const anchorCol = visibleWidth(prefixPart);
|
||||||
|
let gutter = cursor;
|
||||||
|
let body = prefixPart + label + labelTimestamp + content;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
line = theme.bg("selectedBg", line);
|
gutter = theme.bg("selectedBg", gutter);
|
||||||
|
body = theme.bg("selectedBg", body);
|
||||||
}
|
}
|
||||||
lines.push(truncateToWidth(line, width));
|
renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push(...renderHorizontalViewport(renderedRows, width));
|
||||||
lines.push(
|
lines.push(
|
||||||
truncateToWidth(
|
truncateToWidth(
|
||||||
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`),
|
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`),
|
||||||
|
|||||||
@@ -106,4 +106,4 @@ export {
|
|||||||
TUI,
|
TUI,
|
||||||
} from "./tui.ts";
|
} from "./tui.ts";
|
||||||
// Utilities
|
// Utilities
|
||||||
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts";
|
export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts";
|
||||||
|
|||||||
Reference in New Issue
Block a user