mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 16:15:40 +00:00
The stadium pill holds SVG usage and time rings, pill-shaped tail bars and the payload's texts, laid out in physical px from canvasW, canvasH and dpi with the GDI bubble's formulas (ui/layout.js, unit-tested). The palette is CSS custom properties per theme; fills come from Rust. The ring arc, weekly fill and weekly percent pulse at 95% and above on an 80 ms step, and the time progress advances every minute. Only the pill takes pointer input, so the corners are dead. A press that moves 3 physical px starts an OS drag after arming the move end; an earlier release is a click. Right-click opens the native menu and Ctrl+wheel resizes.
268 lines
9.0 KiB
JavaScript
268 lines
9.0 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,
|
|
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('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');
|
|
});
|
|
});
|