diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index 1735770..80efd6c 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -7,6 +7,6 @@ "start": "bun src/main.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.10.3" + "@profullstack/hqtui": "^0.11.0" } } diff --git a/apps/demo/package.json b/apps/demo/package.json index 0f5df99..b24abcd 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui-demo", - "version": "0.10.3", + "version": "0.11.0", "description": "The HQTUI reference dashboard: a btop-grade terminal system monitor. Runs on real system metrics or a deterministic simulation.", "license": "MIT", "type": "module", @@ -27,7 +27,7 @@ "audit:scroll": "bun scripts/scrollaudit.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.10.3" + "@profullstack/hqtui": "^0.11.0" }, "publishConfig": { "access": "public" diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index a3edd8c..41d0a03 100755 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -52,7 +52,7 @@ function parseArgs(argv: string[]): Options { case "-h": case "--help": printHelp(); process.exit(0); case "-v": - case "--version": console.log("hqtui-demo 0.10.3"); process.exit(0); + case "--version": console.log("hqtui-demo 0.11.0"); process.exit(0); } } return options; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 07c6556..da1ee92 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -170,7 +170,7 @@ export default async function Home() { High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++
Terminal dashboards that diff --git a/apps/web/package.json b/apps/web/package.json index 3e24cd6..4475082 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "dependencies": { "@base-ui/react": "1.7.0", "@profullstack/footer": "latest", - "@profullstack/hqtui": "^0.10.3", + "@profullstack/hqtui": "^0.11.0", "@profullstack/libsql-pg": "^0.1.3", "@profullstack/x402-gateway": "^0.11.0", "class-variance-authority": "0.7.1", diff --git a/bun.lock b/bun.lock index d9d7a95..effc017 100644 --- a/bun.lock +++ b/bun.lock @@ -21,12 +21,12 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.10.3", + "version": "0.11.0", "bin": { "hqtui-demo": "./bin/hqtui-demo.mjs", }, "dependencies": { - "@profullstack/hqtui": "^0.10.3", + "@profullstack/hqtui": "^0.11.0", }, }, "apps/web": { @@ -35,7 +35,7 @@ "dependencies": { "@base-ui/react": "1.7.0", "@profullstack/footer": "latest", - "@profullstack/hqtui": "^0.10.3", + "@profullstack/hqtui": "^0.11.0", "@profullstack/libsql-pg": "^0.1.3", "@profullstack/x402-gateway": "^0.11.0", "class-variance-authority": "0.7.1", @@ -61,12 +61,12 @@ "name": "@profullstack/hqtui-examples", "version": "0.1.0", "dependencies": { - "@profullstack/hqtui": "^0.10.3", + "@profullstack/hqtui": "^0.11.0", }, }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.10.3", + "version": "0.11.0", "bin": { "hqtui": "./bin/hqtui.mjs", }, diff --git a/examples/package.json b/examples/package.json index 34150e4..7e5e3c7 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@profullstack/hqtui": "^0.10.3" + "@profullstack/hqtui": "^0.11.0" } } diff --git a/packages/hqtui/README.md b/packages/hqtui/README.md index 788a087..93da4ad 100644 --- a/packages/hqtui/README.md +++ b/packages/hqtui/README.md @@ -105,13 +105,38 @@ activity, sessions, services and the full widget catalogue. | | | |---|---| | **Layout** | rows, columns, grid with spans, `"40%"`, `"2fr"`, `auto`, min/max, padding, gaps, clipping, responsive breakpoints | -| **Widgets** | panel, table, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, tooltip, badge, spinner, divider | +| **Widgets** | panel, table, data grid, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, tooltip, badge, spinner, divider | | **Graphics** | Braille canvas (2×4 pixels per cell), block/half-block/quadrant/ASCII modes, gradients, software alpha blending | | **Color** | 24-bit truecolor, automatic 256 and 16-colour quantization, `NO_COLOR`, monochrome and high-contrast modes | | **Themes** | dark (default), dracula, nord, tokyo night, gruvbox, matrix, monochrome, high contrast, light — plus `defineTheme()` | | **Input** | normalized keys with modifiers, SGR mouse (click, drag, scroll, move), bracketed paste, focus events, Tab focus traversal | | **Testing** | headless renderer: `renderToText`, `renderToScreen`, `renderToAnsi`, `renderToHtml` — no TTY required | +## Data grid + +`table` fits every column into the width. A **data grid** keeps each column readable +and scrolls on both axes instead: a frozen header, a row-number gutter, a cell cursor, +a sort marker, and arrows when columns are off to either side. It is the widget a CSV +viewer, a query result or a wide log needs. + +```ts +ui.dataGrid({ + columns: header.map((title) => ({ title })), + rows, // string[][], parsed however you like + order, // optional: row indices, sorted/filtered, never copied + selectedRow, selectedColumn, // the cursor; the grid scrolls to keep it on screen + sort: { column: 2, descending: true }, + onSelectCell: (row, column) => { selectedRow = row; selectedColumn = column; }, + onHeaderClick: (column) => toggleSort(column), + onScroll: (delta) => { selectedRow += delta * 3; }, +}); +``` + +The grid only draws: parsing, sorting and filtering stay with the caller, which keeps +the library free of dependencies. `onLayout` reports what fit (`capacity`, visible +columns) so page keys move by a real screenful. `dataGridWidths`, `dataGridLayout` and +`dataGridCellAt` are exported for callers that draw their own. + ## Icons The [OpenIcon](https://logicsrc.com/openicon) pack is built in and on by diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index 67e5ad3..9ba5a67 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.10.3", + "version": "0.11.0", "description": "High Quality Terminal UI for TypeScript. btop-grade dashboards with a one-import API, dark by default, zero runtime dependencies.", "license": "MIT", "type": "module", diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index b799751..2193724 100644 --- a/packages/hqtui/src/cli.ts +++ b/packages/hqtui/src/cli.ts @@ -14,7 +14,7 @@ import { detectCapabilities } from "./capabilities.ts"; import { themeList, themes } from "./theme.ts"; import { BrailleCanvas } from "./graphics/braille.ts"; -const VERSION = "0.10.3"; +const VERSION = "0.11.0"; function help(): void { console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index 6a79f2e..825f5af 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -16,7 +16,7 @@ export { App, createApp, type AppOptions, type RenderArgs, type RenderFn, type FrameStats } from "./app.ts"; export { Container, GridContainer } from "./ui.ts"; export type { - RenderContext, ContainerOptions, PanelOptions, GridOptions, CellOptions, HitRegion, HitEvent, ScrollHandlers, MarkdownCopy, + RenderContext, ContainerOptions, PanelOptions, GridOptions, CellOptions, HitRegion, HitEvent, ScrollHandlers, DataGridHandlers, MarkdownCopy, } from "./ui.ts"; export { dispatchHit, countClicks, DOUBLE_CLICK_MS } from "./ui.ts"; diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index 3a62723..dece21e 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -128,6 +128,18 @@ export interface RenderContext { } /** Mouse behaviour shared by every scrollable widget. */ +export interface DataGridHandlers { + /** Wheel over the grid. `delta` is -1 up, 1 down. */ + onScroll?: (delta: number) => void; + /** Click on a body cell: its position in the displayed order, and its column (-1 on the gutter). */ + onSelectCell?: (row: number, column: number) => void; + /** Double-click on a body cell, after `onSelectCell` for the same press. */ + onActivateCell?: (row: number, column: number) => void; + /** Click on a column title. */ + onHeaderClick?: (column: number) => void; + onFocus?: () => void; +} + export interface ScrollHandlers { /** Wheel over this widget. `delta` is -1 up, 1 down. */ onScroll?: (delta: number) => void; @@ -533,6 +545,36 @@ export class Container { }, this.sizeOf(options, "fill", intrinsic)); } + /** + * A data grid (widgets/data-grid.ts): cells that scroll on both axes under + * a cell cursor. Clicks are reported as cells, in the displayed order, so a + * sorted or filtered view needs no translating; a header click names the + * column, which is where a sort toggle goes. + */ + dataGrid(options: W.DataGridOptions & ContainerOptions & DataGridHandlers): this { + return this.add((s) => { + const frame = W.drawDataGrid(s, options); + const { onScroll, onSelectCell, onActivateCell, onHeaderClick, onFocus } = options; + if (!onScroll && !onSelectCell && !onActivateCell && !onHeaderClick && !onFocus) return; + this.ctx.hit({ + rect: s.hitRect(), + onScroll: onScroll ? (delta) => onScroll(delta) : undefined, + onClick: (x, y, _button, clicks = 1) => { + onFocus?.(); + const column = W.dataGridCellAt(frame, x); + if (y === 0) { + if (column >= 0) onHeaderClick?.(column); + return; + } + const row = frame.rowOffset + y - 1; + if (row >= frame.total) return; + onSelectCell?.(row, column); + if (clicks >= 2) onActivateCell?.(row, column); + }, + }); + }, this.sizeOf(options, "fill", (options.order?.length ?? options.rows.length) + 1)); + } + list(options: W.ListOptions & ContainerOptions & ScrollHandlers): this { return this.add((s) => { W.drawList(s, options); diff --git a/packages/hqtui/src/widgets/data-grid.ts b/packages/hqtui/src/widgets/data-grid.ts new file mode 100644 index 0000000..0bd1302 --- /dev/null +++ b/packages/hqtui/src/widgets/data-grid.ts @@ -0,0 +1,262 @@ +import type { Align, Surface } from "../surface.ts"; +import { Attr } from "../buffer.ts"; +import type { Color } from "../color.ts"; +import { mix } from "../color.ts"; +import { fit, stringWidth, truncate } from "../unicode.ts"; +import { elevate } from "../theme.ts"; +import { drawScrollbar } from "./scrollbar.ts"; +import { hoverBg, resolveOffset } from "./table.ts"; + +/** + * A data grid: rows and columns of text that scroll on both axes, with a cell + * cursor, a frozen header, a row-number gutter and a sort marker. It is what a + * CSV viewer, a query result or a log table needs and `table` is not: `table` + * fits every column into the width, a grid keeps each column readable and lets + * the ones that do not fit scroll in from the side. + * + * The grid only draws. Parsing, sorting and filtering belong to the caller, + * which hands over the cells as strings and, optionally, `order`: the row + * indices to show, in the order to show them. A sorted, filtered view of a + * 100,000-row file is then one index array, and the rows are never copied. + */ +export interface DataGridColumn { + title: string; + align?: Align; + /** A fixed width, in cells. Otherwise the column is as wide as its widest sampled value. */ + width?: number; + /** Cap for a measured width. Defaults to the grid's `maxColumnWidth`. */ + max?: number; + color?: Color; +} + +export interface DataGridOptions { + columns: DataGridColumn[]; + /** Row-major cell text. A short row is drawn with empty cells. */ + rows: readonly (readonly string[])[]; + /** Indices into `rows` to show, in display order. Defaults to every row in order. */ + order?: readonly number[]; + /** The cursor: a position in the displayed order, and a column index. -1 hides it. */ + selectedRow?: number; + selectedColumn?: number; + /** First displayed row and first column drawn after the gutter. */ + rowOffset?: number; + columnOffset?: number; + /** Scroll so the cursor cell stays on screen. Default true. */ + followSelection?: boolean; + /** A gutter with each row's 1-based number in `rows`, so a sorted row still says where it came from. Default true. */ + rowNumbers?: boolean; + /** Marks a column header ▲ or ▼. */ + sort?: { column: number; descending?: boolean } | null; + /** Widest a measured column may grow. Default 40. */ + maxColumnWidth?: number; + /** Rows sampled when measuring column widths. Default 1,000. */ + sample?: number; + zebra?: boolean; + scrollbar?: boolean; + headerColor?: Color; + /** The displayed row under the mouse. */ + hovered?: number; + /** Told what was drawn, so a caller can page by a screenful and map its own clicks. */ + onLayout?: (layout: DataGridFrame) => void; +} + +export interface DataGridSlot { + /** Column index. */ + index: number; + /** Left edge inside the grid, in cells. */ + x: number; + width: number; +} + +export interface DataGridLayout { + /** Gutter width, separator included; 0 without row numbers. */ + gutter: number; + /** The first column drawn, after any adjustment to keep the cursor visible. */ + columnOffset: number; + slots: DataGridSlot[]; + /** Columns exist to the left or right of what is drawn. */ + more: { left: boolean; right: boolean }; +} + +export interface DataGridFrame extends DataGridLayout { + rowOffset: number; + /** Body rows that fit, header excluded. */ + capacity: number; + /** Rows in the displayed order. */ + total: number; +} + +const SEPARATOR = "│"; +const widthCache = new WeakMap