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
13 changes: 12 additions & 1 deletion packages/hqtui/src/widgets/meters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ export function drawGraph(surface: Surface, options: GraphOptions): void {
let plotSurface = surface;
const axisColor = options.axisColor ?? theme.muted;

/**
* Whether a row at the bottom belongs to the time axis rather than the plot.
*
* Decided before the y-axis labels are written, because the minimum marks the
* bottom of the *plot* and the time axis takes that row away. Writing it at
* `height - 1` regardless put it on the time-axis row, immediately left of the
* first time label and against it: "$0" and "08-10" rendered as "$008-10".
*/
const timeAxisRow = Boolean(options.timeAxis) && surface.height > 2;

if (options.axis) {
// Match the window the plot itself will use, so the labels stay truthful.
const columns = (options.mode ?? "braille") === "braille" ? surface.width * 2 : surface.width;
Expand All @@ -172,7 +182,8 @@ export function drawGraph(surface: Surface, options: GraphOptions): void {
const labelWidth = Math.max(stringWidth(format(max)), stringWidth(format(min))) + 1;
surface.text(0, 0, fit(format(max), labelWidth, "right"), { fg: axisColor });
if (surface.height > 1) {
surface.text(0, surface.height - 1, fit(format(min), labelWidth, "right"), { fg: axisColor });
const bottom = timeAxisRow ? surface.height - 2 : surface.height - 1;
surface.text(0, bottom, fit(format(min), labelWidth, "right"), { fg: axisColor });
}
plotSurface = surface.sub(labelWidth, 0, surface.width - labelWidth, surface.height);
}
Expand Down
66 changes: 66 additions & 0 deletions packages/hqtui/test/graph-axis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderToText } from "../src/testing.ts";

/**
* The y-axis minimum marks the bottom of the plot. When a time axis is present
* it takes the last row, so the minimum belongs one row above it — otherwise
* the two labels sit side by side on the same row and read as one number.
*/
const VALUES = [0, 20, 45, 30, 80, 65, 90, 40, 55, 70];
const TIME = ["08-10", "08-14", "08-18", "08-22", "08-26"];

function frame(options: Record<string, unknown>, width = 60, height = 10): string[] {
return renderToText(({ ui }) => ui.graph({ values: VALUES, ...options }), { width, height })
.split("\n");
}

test("the y-axis minimum sits on the plot's bottom row, not the time axis", () => {
const lines = frame({ axis: true, min: 0, max: 100, timeAxis: TIME });
const axisRow = lines[lines.length - 2] ?? "";
const timeRow = lines[lines.length - 1] ?? "";

assert.match(axisRow, /^\s*0/, "the minimum is one row above the time axis");
assert.match(timeRow, /08-10/, "the time axis has the bottom row to itself");
// The bug: the minimum written immediately left of the first time label, so
// the row read "008-10". A digit directly against the label is the signature;
// `0*` would have matched the correct output too, since it allows none.
assert.doesNotMatch(timeRow, /\d08-10/, "no y-axis label fused onto the time axis");
});

test("without a time axis the minimum keeps the bottom row", () => {
const lines = frame({ axis: true, min: 0, max: 100 });
assert.match(lines[lines.length - 1] ?? "", /^\s*0/);
});

test("the maximum stays on the top row either way", () => {
assert.match(frame({ axis: true, min: 0, max: 100 })[0] ?? "", /100/);
assert.match(frame({ axis: true, min: 0, max: 100, timeAxis: TIME })[0] ?? "", /100/);
});

test("a formatted axis does not fuse either", () => {
const lines = frame({
axis: true,
min: 0,
max: 6000,
timeAxis: TIME,
axisFormat: (v: number) => `$${Math.round(v / 1000)}K`,
});
const timeRow = lines[lines.length - 1] ?? "";
assert.match(lines[lines.length - 2] ?? "", /\$0K/);
// This is the exact shape seen on hqtui.com/apps: "$008-10".
assert.doesNotMatch(timeRow, /\$0K?08-10/);
assert.match(timeRow, /08-10/);
});

test("a two-row graph with a time axis does not lose the minimum off-surface", () => {
// Too short to give the time axis its own row above the minimum; the point is
// that it renders rather than writing outside the surface.
assert.doesNotThrow(() => frame({ axis: true, min: 0, max: 100, timeAxis: TIME }, 40, 2));
assert.doesNotThrow(() => frame({ axis: true, min: 0, max: 100, timeAxis: TIME }, 40, 3));
});

test("the time axis alone still owns the bottom row", () => {
const lines = frame({ timeAxis: TIME });
assert.match(lines[lines.length - 1] ?? "", /08-10/);
});
248 changes: 248 additions & 0 deletions ports/conformance/fixtures/widgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -11075,6 +11075,254 @@
]
}
},
{
"name": "graph-axis-timeaxis",
"width": 34,
"height": 8,
"result": {
"width": 34,
"height": 8,
"chars": [
[
1,
32
],
[
1,
49
],
[
2,
48
],
[
203,
32
],
[
1,
48
],
[
1,
10276
],
[
1,
10258
],
[
1,
10274
],
[
1,
10276
],
[
1,
10258
],
[
1,
10274
],
[
1,
10276
],
[
3,
10258
],
[
1,
10276
],
[
1,
10432
],
[
1,
10276
],
[
1,
10260
],
[
1,
10258
],
[
1,
10276
],
[
1,
10260
],
[
2,
10258
],
[
1,
10274
],
[
1,
10276
],
[
1,
10258
],
[
1,
10274
],
[
1,
10276
],
[
1,
10260
],
[
1,
10274
],
[
1,
10372
],
[
1,
10336
],
[
1,
10276
],
[
1,
10258
],
[
4,
32
],
[
1,
54
],
[
1,
48
],
[
1,
115
],
[
12,
32
],
[
1,
51
],
[
1,
48
],
[
1,
115
],
[
10,
32
],
[
1,
48
],
[
1,
115
]
],
"fg": [
[
4,
22702973
],
[
200,
29806811
],
[
4,
22702973
],
[
30,
22587135
],
[
4,
29806811
],
[
3,
22702973
],
[
12,
29806811
],
[
3,
22702973
],
[
10,
29806811
],
[
2,
22702973
]
],
"bg": [
[
272,
17106698
]
],
"attrs": [
[
272,
0
]
],
"clusters": [],
"text": [
" 100 ",
" ",
" ",
" ",
" ",
" ",
" 0⠤⠒⠢⠤⠒⠢⠤⠒⠒⠒⠤⣀⠤⠔⠒⠤⠔⠒⠒⠢⠤⠒⠢⠤⠔⠢⢄⡠⠤⠒",
" 60s 30s 0s"
]
}
},
{
"name": "sparkline-widget",
"width": 30,
Expand Down
Binary file modified ports/conformance/generate.ts
Binary file not shown.
12 changes: 10 additions & 2 deletions ports/cpp/src/widgets.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -499,12 +499,20 @@ void draw_graph(Surface surface, const Graph &o) {
return std::abs(v) >= 1000 ? number(std::floor(v / 100 + .5) / 10) + "k"
: number(v);
};
// Whether the bottom row belongs to the time axis rather than the plot.
// Decided before the y-axis labels are written: the minimum marks the bottom
// of the *plot*, and the time axis takes that row away. Writing it at
// height - 1 regardless put it against the first time label, so "$0" and
// "08-10" rendered as "$008-10".
const bool time_axis_row = !o.time_axis.empty() && s.rect().height > 2;
if (o.axis) {
double hi = o.max.value_or(highFor(s.rect().width * mult));
int lw = std::max(width(label(hi)), width(label(o.min))) + 1;
text(s, 0, 0, fit(label(hi), lw, HQ_RIGHT), t.muted);
if (s.rect().height > 1)
text(s, 0, s.rect().height - 1, fit(label(o.min), lw, HQ_RIGHT), t.muted);
if (s.rect().height > 1) {
const int bottom = time_axis_row ? s.rect().height - 2 : s.rect().height - 1;
text(s, 0, bottom, fit(label(o.min), lw, HQ_RIGHT), t.muted);
}
s = s.sub({lw, 0, std::max(0, s.rect().width - lw), s.rect().height});
}
// The time axis costs the bottom row, and the plot gets what is left.
Expand Down
12 changes: 12 additions & 0 deletions ports/cpp/tests/conformance_widgets.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,18 @@ bool draw_scene(const std::string &name, Surface s) {
draw_graph(s, g);
return true;
}
// Both axes together. Each was covered alone, which is how the y-axis minimum
// came to be drawn onto the time-axis row with no fixture noticing.
if (name == "graph-axis-timeaxis") {
Graph g;
g.series = {{kSeries, 0, "", false}};
g.axis = true;
g.min = 0;
g.max = 100;
g.time_axis = {"60s", "30s", "0s"};
draw_graph(s, g);
return true;
}
if (name == "table-scrollbar") {
Table table;
table.columns = {{"#", -1, 1, 0, HQ_RIGHT}, {"VALUE"}};
Expand Down
Loading