mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-23 16:16:39 +00:00
The tail percent is drawn at weight 600 but its column was measured at 400, so "100%" was clipped at larger bubble sizes. The layout now asks the measure callback for the weight the text is drawn in.
284 lines
9.6 KiB
JavaScript
284 lines
9.6 KiB
JavaScript
// Bubble layout checked against values worked out from the Rust layout
|
|
// formulas (the GDI bubble's `compute_bubble_layout`) with the same
|
|
// stand-in text measurement.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
|
|
import {
|
|
brighten,
|
|
cssPx,
|
|
headLabelText,
|
|
headMetrics,
|
|
pulseActive,
|
|
pulseT,
|
|
remainingFraction,
|
|
ringDash,
|
|
scale,
|
|
TAIL_PCT_WEIGHT,
|
|
tailLayout,
|
|
timeFillWidth,
|
|
usageFillWidth,
|
|
} from '../../ui/layout.js';
|
|
|
|
/**
|
|
* Stand-in for canvas `measureText`: 0.55 em per character, truncated.
|
|
* @param {string} text Text to measure.
|
|
* @param {number} px Font size.
|
|
* @returns {number} Width.
|
|
*/
|
|
const measure = (text, px) => Math.trunc((px * text.length * 11) / 20);
|
|
|
|
/**
|
|
* Rect shorthand.
|
|
* @param {number} l Left.
|
|
* @param {number} t Top.
|
|
* @param {number} r Right.
|
|
* @param {number} b Bottom.
|
|
* @returns {{l: number, t: number, r: number, b: number}} The rect.
|
|
*/
|
|
const rect = (l, t, r, b) => ({ l, t, r, b });
|
|
|
|
const CASES = [
|
|
{
|
|
name: '200 logical at 96 dpi',
|
|
canvas: { canvasW: 200, canvasH: 66, dpi: 96 },
|
|
head: {
|
|
ringCx: 33, ringCy: 33, ringRadius: 26.5, ringStroke: 3, timeRingRadius: 19.5, timeRingStroke: 2,
|
|
labelRect: rect(4, 18, 62, 29), pctRect: rect(4, 31, 62, 48), fonts: { big: 15, small: 9, main: 9 },
|
|
},
|
|
tail: {
|
|
usagePctRect: rect(165, 23, 186, 34), usageBarRect: rect(72, 25, 157, 31),
|
|
timeTextRect: rect(165, 33, 186, 44), timeBarRect: rect(72, 37, 157, 40),
|
|
},
|
|
},
|
|
{
|
|
name: '360 logical at 96 dpi',
|
|
canvas: { canvasW: 360, canvasH: 138, dpi: 96 },
|
|
head: {
|
|
ringCx: 69, ringCy: 69, ringRadius: 62.5, ringStroke: 3, timeRingRadius: 55.5, timeRingStroke: 2,
|
|
labelRect: rect(4, 39, 134, 59), pctRect: rect(4, 63, 134, 98), fonts: { big: 33, small: 18, main: 18 },
|
|
},
|
|
tail: {
|
|
usagePctRect: rect(305, 53, 346, 73), usageBarRect: rect(144, 57, 297, 69),
|
|
timeTextRect: rect(305, 68, 346, 88), timeBarRect: rect(144, 75, 297, 80),
|
|
},
|
|
},
|
|
{
|
|
// 300 px wide: `innerWidth * devicePixelRatio` would drift here.
|
|
name: '200 logical at 144 dpi',
|
|
canvas: { canvasW: 300, canvasH: 99, dpi: 144 },
|
|
head: {
|
|
ringCx: 49.5, ringCy: 49.5, ringRadius: 40.5, ringStroke: 4, timeRingRadius: 30.5, timeRingStroke: 3,
|
|
labelRect: rect(6, 27, 93, 43), pctRect: rect(6, 46, 93, 72), fonts: { big: 23, small: 13, main: 13 },
|
|
},
|
|
tail: {
|
|
usagePctRect: rect(248, 35, 279, 51), usageBarRect: rect(108, 38, 236, 47),
|
|
timeTextRect: rect(248, 50, 279, 66), timeBarRect: rect(108, 56, 236, 60),
|
|
},
|
|
},
|
|
{
|
|
name: '360 logical at 144 dpi',
|
|
canvas: { canvasW: 540, canvasH: 207, dpi: 144 },
|
|
head: {
|
|
ringCx: 103.5, ringCy: 103.5, ringRadius: 94.5, ringStroke: 4, timeRingRadius: 84.5, timeRingStroke: 3,
|
|
labelRect: rect(6, 59, 201, 88), pctRect: rect(6, 95, 201, 147), fonts: { big: 49, small: 26, main: 26 },
|
|
},
|
|
tail: {
|
|
usagePctRect: rect(459, 81, 519, 110), usageBarRect: rect(216, 86, 447, 104),
|
|
timeTextRect: rect(459, 103, 519, 132), timeBarRect: rect(216, 113, 447, 121),
|
|
},
|
|
},
|
|
{
|
|
name: '140 logical at 120 dpi',
|
|
canvas: { canvasW: 175, canvasH: 57, dpi: 120 },
|
|
head: {
|
|
ringCx: 28.5, ringCy: 28.5, ringRadius: 21, ringStroke: 3, timeRingRadius: 13, timeRingStroke: 2,
|
|
labelRect: rect(5, 13, 52, 26), pctRect: rect(5, 28, 52, 43), fonts: { big: 13, small: 11, main: 11 },
|
|
},
|
|
tail: {
|
|
usagePctRect: rect(132, 17, 158, 30), usageBarRect: rect(64, 20, 122, 27),
|
|
timeTextRect: rect(132, 29, 158, 42), timeBarRect: rect(64, 34, 122, 37),
|
|
},
|
|
},
|
|
];
|
|
|
|
describe('headMetrics', () => {
|
|
for (const c of CASES) {
|
|
it(`matches the Rust layout for ${c.name}`, () => {
|
|
assert.deepEqual(headMetrics(c.canvas), c.head);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('tailLayout', () => {
|
|
for (const c of CASES) {
|
|
it(`matches the Rust layout for ${c.name}`, () => {
|
|
assert.deepEqual(tailLayout(c.canvas, measure), c.tail);
|
|
});
|
|
}
|
|
|
|
it('shrinks the text column before the bars drop below their minimum', () => {
|
|
const tail = tailLayout({ canvasW: 200, canvasH: 66, dpi: 96 }, () => 1000);
|
|
// Content 72..186 is 114 wide: 114 - 8 gap - 8 bar minimum = 98.
|
|
assert.deepEqual(tail.usagePctRect, rect(88, 23, 186, 34));
|
|
assert.deepEqual(tail.usageBarRect, rect(72, 25, 80, 31));
|
|
});
|
|
|
|
it('collapses the text column to nothing when even the bars do not fit', () => {
|
|
const tail = tailLayout({ canvasW: 80, canvasH: 66, dpi: 96 }, () => 50);
|
|
assert.equal(tail.usagePctRect.l, tail.usagePctRect.r);
|
|
assert.equal(tail.usageBarRect.r - tail.usageBarRect.l, 8);
|
|
});
|
|
|
|
it('reserves the percent column at the weight it is drawn in', () => {
|
|
const regular = 40;
|
|
const semibold = 61.3;
|
|
/** @type {Array<number | undefined>} */
|
|
const pctWeights = [];
|
|
const tail = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, (text, _px, weight) => {
|
|
if (text === '100%') {
|
|
pctWeights.push(weight);
|
|
}
|
|
return weight === TAIL_PCT_WEIGHT ? semibold : regular;
|
|
});
|
|
assert.deepEqual(pctWeights, [600]);
|
|
assert.ok(tail.usagePctRect.r - tail.usagePctRect.l >= Math.ceil(semibold));
|
|
});
|
|
|
|
it('rounds fractional measurements up', () => {
|
|
const exact = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 40);
|
|
const fractional = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 39.2);
|
|
assert.deepEqual(fractional, exact);
|
|
});
|
|
});
|
|
|
|
describe('scale and cssPx', () => {
|
|
it('truncates like the Rust scale', () => {
|
|
assert.equal(scale(200, 96), 200);
|
|
assert.equal(scale(200, 144), 300);
|
|
assert.equal(scale(5, 120), 6);
|
|
assert.equal(scale(66, 168), 115);
|
|
});
|
|
|
|
it('converts physical px to CSS px from the payload DPI', () => {
|
|
assert.equal(cssPx(300, 144), 200);
|
|
assert.equal(cssPx(99, 144), 66);
|
|
assert.equal(cssPx(66, 96), 66);
|
|
});
|
|
});
|
|
|
|
describe('ringDash', () => {
|
|
const r = 10;
|
|
const c = 2 * Math.PI * r;
|
|
|
|
it('starts the usage arc at 12 o\'clock', () => {
|
|
assert.deepEqual(ringDash(r, 0.25, 'from-top'), { dashArray: `${c / 4} ${c}`, dashOffset: 0 });
|
|
});
|
|
|
|
it('ends the remaining arc at 12 o\'clock', () => {
|
|
const dash = ringDash(r, 0.25, 'to-top');
|
|
assert.equal(dash?.dashArray, `${c / 4} ${c}`);
|
|
assert.ok(Math.abs((dash?.dashOffset ?? 0) + (c * 3) / 4) < 1e-9);
|
|
});
|
|
|
|
it('draws a full ring at 1 and clamps above it', () => {
|
|
assert.deepEqual(ringDash(r, 1, 'to-top'), { dashArray: `${c} ${c}`, dashOffset: -0 });
|
|
assert.deepEqual(ringDash(r, 2, 'from-top'), ringDash(r, 1, 'from-top'));
|
|
});
|
|
|
|
it('draws nothing at zero, below it or for NaN', () => {
|
|
assert.equal(ringDash(r, 0, 'from-top'), null);
|
|
assert.equal(ringDash(r, -0.5, 'to-top'), null);
|
|
assert.equal(ringDash(r, Number.NaN, 'from-top'), null);
|
|
});
|
|
});
|
|
|
|
describe('remainingFraction', () => {
|
|
const now = 1_800_000_000_000;
|
|
|
|
it('is null without a reset time', () => {
|
|
assert.equal(remainingFraction(null, 18_000, now), null);
|
|
});
|
|
|
|
it('is the remaining share of the window', () => {
|
|
assert.equal(remainingFraction(now + 9_000_000, 18_000, now), 0.5);
|
|
assert.equal(remainingFraction(now + 3_600_000, 18_000, now), 0.2);
|
|
});
|
|
|
|
it('clamps past resets to 0 and far resets to 1', () => {
|
|
assert.equal(remainingFraction(now - 1_000, 18_000, now), 0);
|
|
assert.equal(remainingFraction(now + 36_000_000, 18_000, now), 1);
|
|
});
|
|
|
|
it('is null for a non-positive window length', () => {
|
|
assert.equal(remainingFraction(now, 0, now), null);
|
|
});
|
|
});
|
|
|
|
describe('pulse', () => {
|
|
it('follows the Rust triangle wave', () => {
|
|
assert.equal(pulseT(0), 0);
|
|
assert.equal(pulseT(6), 0.5);
|
|
assert.equal(pulseT(12), 1);
|
|
assert.equal(pulseT(18), 0.5);
|
|
assert.equal(pulseT(24), 0);
|
|
assert.equal(pulseT(36), 1);
|
|
});
|
|
|
|
it('runs only at 95% and above', () => {
|
|
assert.equal(pulseActive(null, null), false);
|
|
assert.equal(pulseActive(94.9, 10), false);
|
|
assert.equal(pulseActive(95, null), true);
|
|
assert.equal(pulseActive(null, 99), true);
|
|
});
|
|
|
|
it('brightens toward white by at most 30%', () => {
|
|
assert.equal(brighten('#A01818', 0), '#A01818');
|
|
// 160 + 95 * 0.3 = 188.5 -> 189; 24 + 231 * 0.3 = 93.3 -> 93.
|
|
assert.equal(brighten('#A01818', 1), '#BD5D5D');
|
|
// Half way: 0.15 of the distance.
|
|
assert.equal(brighten('#000000', 0.5), '#262626');
|
|
assert.equal(brighten('#eaeaea', 1), '#F0F0F0');
|
|
assert.equal(brighten('#FFFFFF', 1), '#FFFFFF');
|
|
assert.equal(brighten('#123456', 5), brighten('#123456', 1));
|
|
});
|
|
|
|
it('leaves a malformed colour unchanged', () => {
|
|
assert.equal(brighten('red', 1), 'red');
|
|
});
|
|
});
|
|
|
|
describe('bar fills', () => {
|
|
it('floors a small usage fill at one bar height', () => {
|
|
assert.equal(usageFillWidth(100, 6, 1), 6);
|
|
assert.equal(usageFillWidth(100, 6, 50), 50);
|
|
assert.equal(usageFillWidth(100, 6, 150), 100);
|
|
assert.equal(usageFillWidth(4, 6, 1), 4);
|
|
});
|
|
|
|
it('draws no usage fill without a value or at zero', () => {
|
|
assert.equal(usageFillWidth(100, 6, null), 0);
|
|
assert.equal(usageFillWidth(100, 6, 0), 0);
|
|
assert.equal(usageFillWidth(0, 6, 50), 0);
|
|
});
|
|
|
|
it('sizes the time fill from the remaining fraction', () => {
|
|
assert.equal(timeFillWidth(100, 0.25), 25);
|
|
assert.equal(timeFillWidth(100, null), 0);
|
|
assert.equal(timeFillWidth(100, 0), 0);
|
|
});
|
|
});
|
|
|
|
describe('headLabelText', () => {
|
|
it('keeps a countdown that fits', () => {
|
|
assert.equal(headLabelText('2h', 10, 58), '2h');
|
|
assert.equal(headLabelText('…', 5, 58), '…');
|
|
});
|
|
|
|
it('falls back to 5H when empty or too wide', () => {
|
|
assert.equal(headLabelText('', 0, 58), '5H');
|
|
assert.equal(headLabelText('23h 59m', 59, 58), '5H');
|
|
});
|
|
});
|