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
2 changes: 1 addition & 1 deletion apps/benchmark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"start": "bun src/main.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.3.0"
"@profullstack/hqtui": "^0.4.0"
}
}
4 changes: 2 additions & 2 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui-demo",
"version": "0.3.0",
"version": "0.4.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",
Expand All @@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.3.0"
"@profullstack/hqtui": "^0.4.0"
},
"publishConfig": {
"access": "public"
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function parseArgs(argv: string[]): Options {
case "-h":
case "--help": printHelp(); process.exit(0);
case "-v":
case "--version": console.log("hqtui-demo 0.3.0"); process.exit(0);
case "--version": console.log("hqtui-demo 0.4.0"); process.exit(0);
}
}
return options;
Expand Down
4 changes: 2 additions & 2 deletions apps/demo/src/screens/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const TREE = [
export function componentsScreen(ui: Container, state: DemoState, theme: Theme): void {
const gap = panelGap(state);
ui.row({ size: "1fr", gap }, (row) => {
row.column({ gap }, (left) => {
row.column({ gap, bordered: true }, (left) => {
left.panel({ title: "Buttons & Inputs", size: 13 }, (p) => {
p.row({ size: 1, gap: 1 }, (r) => {
r.button({ label: "Primary", width: 11, size: 11, onPress: () => { state.showModal = true; } });
Expand Down Expand Up @@ -116,7 +116,7 @@ export function componentsScreen(ui: Container, state: DemoState, theme: Theme):
});
});

row.column({ gap }, (right) => {
row.column({ gap, bordered: true }, (right) => {
right.panel({ title: "Process Tree", size: 13 }, (p) => {
p.row({ size: 1 }, (r) => {
r.text("Name", { fg: theme.muted, bold: true });
Expand Down
4 changes: 2 additions & 2 deletions apps/demo/src/screens/graphics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function graphicsScreen(ui: Container, state: DemoState, theme: Theme): v
const c = wave(240, t / 2, 17);

ui.row({ size: "1fr", gap }, (row) => {
row.column({ gap }, (left) => {
row.column({ gap, bordered: true }, (left) => {
left.panel({ title: "Braille (2×4 pixels per cell)" }, (p) => {
p.graph({ values: a, min: 0, max: 100, fill: true, color: theme.accent, grid: true });
});
Expand All @@ -27,7 +27,7 @@ export function graphicsScreen(ui: Container, state: DemoState, theme: Theme): v
});
});

row.column({ gap }, (right) => {
row.column({ gap, bordered: true }, (right) => {
right.panel({ title: "Multi-series" }, (p) => {
p.multiGraph(
[
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function networkScreen(ui: Container, state: DemoState, theme: Theme): vo
});
});

row.column({ width: "0.7fr", gap }, (column) => {
row.column({ width: "0.7fr", gap, bordered: true }, (column) => {
column.panel({ title: "Listening Ports", subtitle: String(t.listeners.length), borderColor: theme.warning }, (p) => {
const listeners = pane(state, "network.listeners", t.listeners.length);
p.table({
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function servicesScreen(ui: Container, state: DemoState, theme: Theme): v
});
});

row.column({ width: "0.85fr", gap }, (column) => {
row.column({ width: "0.85fr", gap, bordered: true }, (column) => {
column.panel({ title: "Kernel", size: 11, borderColor: theme.accent }, (p) => {
const k = t.kernel;
p.keyValues([
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): v
});
});

row.column({ width: "0.8fr", gap }, (column) => {
row.column({ width: "0.8fr", gap, bordered: true }, (column) => {
column.panel({ title: "Failed Logins", borderColor: theme.danger }, (p) => {
if (t.failedLogins.length === 0) {
p.label("None recorded.");
Expand Down
4 changes: 2 additions & 2 deletions apps/demo/src/screens/traffic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
});

ui.row({ size: "1fr", gap }, (row) => {
row.column({ gap }, (column) => {
row.column({ gap, bordered: true }, (column) => {
column.panel({
title: "HTTP",
subtitle: http ? `${num(http.requestsPerSecond, 1)} req/s` : "no access log",
Expand Down Expand Up @@ -136,7 +136,7 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
});
});

row.column({ width: "0.85fr", gap }, (column) => {
row.column({ width: "0.85fr", gap, bordered: true }, (column) => {
column.panel({ title: "SSH Activity", subtitle: String(t.ssh.length), borderColor: theme.warning }, (p) => {
if (t.ssh.length === 0) {
p.label("No sshd events in the journal.");
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function worldScreen(ui: Container, state: DemoState, theme: Theme): void
});
});

row.column({ width: 34, gap }, (column) => {
row.column({ width: 34, gap, bordered: true }, (column) => {
column.panel({ title: "Selection", size: 9, borderColor: theme.accent }, (p) => {
if (!selected) {
p.label("Nothing selected.");
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export default async function Home() {
High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++
</p>
<Badge variant="secondary" className="mb-5 font-mono text-xs">
v0.3.0 · {COUNT} language demos · MIT
v0.4.0 · {COUNT} language demos · MIT
</Badge>
<p className="text-balance text-3xl font-bold tracking-tight sm:text-5xl">
Terminal dashboards that
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"dependencies": {
"@base-ui/react": "1.7.0",
"@profullstack/hqtui": "^0.3.0",
"@profullstack/hqtui": "^0.4.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.37.0",
Expand Down
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"version": "0.1.0",
"type": "module",
"dependencies": {
"@profullstack/hqtui": "^0.3.0"
"@profullstack/hqtui": "^0.4.0"
}
}
2 changes: 1 addition & 1 deletion packages/hqtui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui",
"version": "0.3.0",
"version": "0.4.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",
Expand Down
2 changes: 1 addition & 1 deletion packages/hqtui/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { detectCapabilities } from "./capabilities.ts";
import { themeList, themes } from "./theme.ts";
import { BrailleCanvas } from "./graphics/braille.ts";

const VERSION = "0.3.0";
const VERSION = "0.4.0";

function help(): void {
console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript
Expand Down
31 changes: 26 additions & 5 deletions packages/hqtui/src/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,14 +358,35 @@ export class Surface {
const fg = options.borderColor ?? this.theme.border;
const bg = options.bg;

if (options.fill !== false && bg !== undefined) {
this.fill({ bg });
}

const sides = resolveSides(options.sides);
const any = sides.top || sides.right || sides.bottom || sides.left;
const drawsBorder = style !== "none" && any && this.width >= 2 && this.height >= 1;

if (options.fill !== false && bg !== undefined) {
if (drawsBorder && options.collapse) {
// The border ring is left to the border drawing below, which merges
// with whatever a neighbour already put in the shared column. Filling
// it here first would erase that border, and the merge would then find
// a blank cell and simply overwrite it -- which is why a panel with a
// background collapsed its seams into a corner rather than a junction.
// Every cell skipped here is painted by the border, in the same bg.
const top = sides.top ? 1 : 0;
const bottom = this.height > 1 && sides.bottom ? 1 : 0;
const left = sides.left ? 1 : 0;
const right = sides.right ? 1 : 0;
this.fillRect(
left,
top,
Math.max(0, this.width - left - right),
Math.max(0, this.height - top - bottom),
{ bg },
);
} else {
this.fill({ bg });
}
}

if (style === "none" || !any || this.width < 2 || this.height < 1) {
if (!drawsBorder) {
return this.inset(style === "none" || !any ? 0 : 1);
}

Expand Down
56 changes: 46 additions & 10 deletions packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,21 @@ export interface ContainerOptions {
min?: number;
max?: number;
background?: Color;
/**
* Treat this container's own edges as panel borders when collapsing.
*
* Collapsing merges a seam only where two bordered siblings meet, and a row
* or column is not itself bordered -- so wrapping a stack of panels in a
* column, which is the only way to put a stack beside one tall panel, used to
* make the seam down the middle of the screen the one seam that could never
* merge. Set this on such a wrapper and its edges join in.
*
* It is a declaration rather than something inferred, because the children
* are built only once the layout has been solved and the seam has to be
* known before that. True only if every child is a panel filling the
* container across the seam; otherwise a neighbour's border lands on content.
*/
bordered?: boolean;
}

export interface PanelOptions extends ContainerOptions {
Expand Down Expand Up @@ -238,7 +253,7 @@ export class Container {
const container = new Container(surface, this.ctx, "row", options);
build?.(container);
container.flush();
}, this.sizeOf(options, "fill"));
}, this.sizeOf(options, "fill"), options.bordered ?? false);
}

/** A vertical container. */
Expand All @@ -247,7 +262,7 @@ export class Container {
const container = new Container(surface, this.ctx, "column", options);
build?.(container);
container.flush();
}, this.sizeOf(options, "fill"));
}, this.sizeOf(options, "fill"), options.bordered ?? false);
}

/**
Expand All @@ -263,7 +278,7 @@ export class Container {
const container = new GridContainer(surface, this.ctx, options);
build?.(container);
container.flush();
}, this.sizeOf(options, "fill"));
}, this.sizeOf(options, "fill"), options.bordered ?? false);
}

/** A bordered panel. The callback receives its interior as a column. */
Expand Down Expand Up @@ -702,7 +717,11 @@ export class GridContainer {
private surface: Surface;
private ctx: RenderContext;
private options: GridOptions;
private cells: { options: PanelOptions & CellOptions; draw: (surface: Surface) => void }[] = [];
private cells: {
options: PanelOptions & CellOptions;
draw: (surface: Surface) => void;
bordered: boolean;
}[] = [];

constructor(surface: Surface, ctx: RenderContext, options: GridOptions) {
this.surface = options.padding ? surface.inset(options.padding) : surface;
Expand All @@ -720,39 +739,56 @@ export class GridContainer {
return new Array(Math.max(1, count)).fill("1fr");
}

private push(options: PanelOptions & CellOptions, draw: (surface: Surface) => void): this {
this.cells.push({ options, draw });
private push(
options: PanelOptions & CellOptions,
draw: (surface: Surface) => void,
bordered = false,
): this {
this.cells.push({ options, draw, bordered });
return this;
}

/**
* The gap between tracks, minus one where the whole grid is panels and
* collapsing is on, so neighbouring borders land in the same column and
* merge. A grid is one pair of track sizes rather than a list of siblings,
* so this is all or nothing: a single cell that is not a panel would have a
* neighbour's border drawn across its content.
*/
private seam(): number {
const gap = this.options.gap ?? 0;
if (!this.ctx.collapseBorders || gap !== 0 || this.cells.length < 2) return gap;
return this.cells.every((cell) => cell.bordered) ? -1 : gap;
}

/** A panel occupying the next free cell (or several, with colSpan/rowSpan). */
panel(options: PanelOptions & CellOptions = {}, build?: (panel: Container) => void): this {
return this.push(options, (surface) => {
const container = new Container(surface, this.ctx, "column");
container.panel(options, build);
container.flush();
});
}, (options.border ?? "rounded") !== "none");
}

cell(options: CellOptions & ContainerOptions = {}, build?: (cell: Container) => void): this {
return this.push(options, (surface) => {
const container = new Container(surface, this.ctx, "column", options);
build?.(container);
container.flush();
});
}, options.bordered ?? false);
}

row(options: CellOptions & ContainerOptions = {}, build?: (row: Container) => void): this {
return this.push(options, (surface) => {
const container = new Container(surface, this.ctx, "row", options);
build?.(container);
container.flush();
});
}, options.bordered ?? false);
}

flush(): void {
if (this.cells.length === 0 || isEmpty(this.surface.rect)) return;
const gap = this.options.gap ?? 0;
const gap = this.seam();
const columnSpec = this.track(this.options.columns, Math.min(this.cells.length, 3));
const rowCount = Array.isArray(this.options.rows)
? this.options.rows.length
Expand Down
Loading