mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 04:14:52 +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.
344 lines
12 KiB
JavaScript
344 lines
12 KiB
JavaScript
// Pure bubble geometry and colour helpers. Every length is in physical
|
||
// pixels, driven by the canvas size and DPI that Rust sends in the payload,
|
||
// never by `innerWidth * devicePixelRatio`. Integer steps truncate toward
|
||
// zero, like the Rust `i32` arithmetic they mirror.
|
||
|
||
/** The DPI the OS reports for 100% scaling. */
|
||
export const BASE_DPI = 96;
|
||
|
||
/** Width the head label may not exceed; wider labels fall back to this. */
|
||
export const HEAD_LABEL_FALLBACK = '5H';
|
||
|
||
/** Widest countdown text the tail reserves room for. */
|
||
const COUNTDOWN_TEMPLATE = '999d';
|
||
|
||
/** Widest percent text the tail reserves room for. */
|
||
const PCT_TEMPLATE = '100%';
|
||
|
||
/** Usage at or above this percent pulses. */
|
||
export const PULSE_THRESHOLD_PCT = 95;
|
||
|
||
/** Pulse period in ticks and the share of the way to white at the peak. */
|
||
const PULSE_PERIOD = 24;
|
||
const PULSE_STRENGTH = 0.3;
|
||
|
||
/**
|
||
* @typedef {object} Canvas
|
||
* @property {number} canvasW Window width in physical px.
|
||
* @property {number} canvasH Window height in physical px.
|
||
* @property {number} dpi Window DPI (96 at 100% scaling).
|
||
*/
|
||
|
||
/**
|
||
* Rectangle with exclusive right and bottom edges, like Win32 `RECT`.
|
||
* @typedef {object} Rect
|
||
* @property {number} l Left edge.
|
||
* @property {number} t Top edge.
|
||
* @property {number} r Right edge.
|
||
* @property {number} b Bottom edge.
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} FontSizes
|
||
* @property {number} big Head percent, bold.
|
||
* @property {number} small Head label and tail percent, semibold.
|
||
* @property {number} main Tail countdown, regular.
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} HeadMetrics
|
||
* @property {number} ringCx Ring centre x.
|
||
* @property {number} ringCy Ring centre y.
|
||
* @property {number} ringRadius Centre-line radius of the usage ring.
|
||
* @property {number} ringStroke Usage ring stroke width.
|
||
* @property {number} timeRingRadius Centre-line radius of the time ring.
|
||
* @property {number} timeRingStroke Time ring stroke width.
|
||
* @property {Rect} labelRect Head label (countdown or "5H").
|
||
* @property {Rect} pctRect Head percent.
|
||
* @property {FontSizes} fonts Font sizes in physical px.
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} TailLayout
|
||
* @property {Rect} usagePctRect Weekly percent text.
|
||
* @property {Rect} usageBarRect Weekly usage bar.
|
||
* @property {Rect} timeTextRect Weekly countdown text.
|
||
* @property {Rect} timeBarRect Weekly remaining-time bar.
|
||
*/
|
||
|
||
/**
|
||
* Measures `text` set at `fontPx` (regular weight) in physical px.
|
||
* @callback MeasureText
|
||
* @param {string} text Text to measure.
|
||
* @param {number} fontPx Font size in physical px.
|
||
* @returns {number} Width in physical px.
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} RingDash
|
||
* @property {string} dashArray Value for `stroke-dasharray`.
|
||
* @property {number} dashOffset Value for `stroke-dashoffset`.
|
||
*/
|
||
|
||
/**
|
||
* Integer division truncating toward zero, like Rust `i32 / i32`.
|
||
* @param {number} a Dividend.
|
||
* @param {number} b Divisor.
|
||
* @returns {number} Truncated quotient.
|
||
*/
|
||
function idiv(a, b) {
|
||
return Math.trunc(a / b);
|
||
}
|
||
|
||
/**
|
||
* Clamp `v` into `[lo, hi]`.
|
||
* @param {number} v Value.
|
||
* @param {number} lo Lower bound.
|
||
* @param {number} hi Upper bound.
|
||
* @returns {number} Clamped value.
|
||
*/
|
||
function clamp(v, lo, hi) {
|
||
return Math.min(Math.max(v, lo), hi);
|
||
}
|
||
|
||
/**
|
||
* Scale a logical (96-DPI) length to `dpi`, truncating.
|
||
* @param {number} logical Length at 96 DPI.
|
||
* @param {number} dpi Target DPI.
|
||
* @returns {number} Physical length.
|
||
*/
|
||
export function scale(logical, dpi) {
|
||
return idiv(logical * dpi, BASE_DPI);
|
||
}
|
||
|
||
/**
|
||
* Convert a physical length to CSS px for a window at `dpi`.
|
||
* @param {number} physical Length in physical px.
|
||
* @param {number} dpi Window DPI.
|
||
* @returns {number} Length in CSS px.
|
||
*/
|
||
export function cssPx(physical, dpi) {
|
||
return (physical * BASE_DPI) / dpi;
|
||
}
|
||
|
||
/**
|
||
* Font sizes for a canvas.
|
||
* @param {Canvas} canvas Window canvas.
|
||
* @returns {FontSizes} Sizes in physical px.
|
||
*/
|
||
export function fontSizes(canvas) {
|
||
const { canvasH: h, dpi } = canvas;
|
||
const big = Math.max(idiv(h * 24, 100), scale(11, dpi));
|
||
const small = Math.max(idiv(big * 55, 100), scale(9, dpi));
|
||
return { big, small, main: small };
|
||
}
|
||
|
||
/**
|
||
* Geometry of the head: the usage and time rings and the two head texts.
|
||
* The head is the left `canvasH × canvasH` square of the stadium.
|
||
* @param {Canvas} canvas Window canvas.
|
||
* @returns {HeadMetrics} Head geometry in physical px.
|
||
*/
|
||
export function headMetrics(canvas) {
|
||
const { canvasH: h, dpi } = canvas;
|
||
const headD = h;
|
||
const headPad = scale(5, dpi);
|
||
const ringStroke = clamp(scale(3, dpi), 2, 4);
|
||
const ringOuter = headD / 2 - headPad;
|
||
const ringRadius = ringOuter - ringStroke / 2;
|
||
const timeRingStroke = clamp(scale(2, dpi), 2, 3);
|
||
const timeRingRadius = Math.max(ringRadius - ringStroke - scale(4, dpi), timeRingStroke);
|
||
|
||
const fonts = fontSizes(canvas);
|
||
const labelH = fonts.small + scale(2, dpi);
|
||
const pctH = fonts.big + scale(2, dpi);
|
||
const gap = Math.max(idiv(fonts.big * 15, 100), scale(2, dpi));
|
||
const total = labelH + gap + pctH;
|
||
const top = idiv(h - total, 2);
|
||
const inset = scale(4, dpi);
|
||
return {
|
||
ringCx: headD / 2,
|
||
ringCy: h / 2,
|
||
ringRadius,
|
||
ringStroke,
|
||
timeRingRadius,
|
||
timeRingStroke,
|
||
labelRect: { l: inset, t: top, r: headD - inset, b: top + labelH },
|
||
pctRect: { l: inset, t: top + labelH + gap, r: headD - inset, b: top + total },
|
||
fonts,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Geometry of the tail: the weekly usage bar and remaining-time bar with
|
||
* their right-aligned texts. When the tail is too narrow, the text column
|
||
* shrinks (possibly to zero) before the bars drop below their minimum.
|
||
* @param {Canvas} canvas Window canvas.
|
||
* @param {MeasureText} measure Regular-weight text measurement.
|
||
* @returns {TailLayout} Tail geometry in physical px.
|
||
*/
|
||
export function tailLayout(canvas, measure) {
|
||
const { canvasW: w, canvasH: h, dpi } = canvas;
|
||
const fonts = fontSizes(canvas);
|
||
const tailLeft = h;
|
||
const contentRight = w - scale(14, dpi);
|
||
const contentLeft = tailLeft + scale(6, dpi);
|
||
const barTextGap = scale(8, dpi);
|
||
|
||
const countdownW = Math.ceil(measure(COUNTDOWN_TEMPLATE, fonts.main));
|
||
const pctReserveW = Math.ceil(measure(PCT_TEMPLATE, fonts.small)) + scale(2, dpi);
|
||
|
||
const usageBarH = clamp(idiv(h * 10, 100), scale(6, dpi), scale(12, dpi));
|
||
const timeBarH = clamp(idiv(h * 4, 100), scale(3, dpi), scale(6, dpi));
|
||
const laneGap = scale(6, dpi);
|
||
const usageBarTop = idiv(h - (usageBarH + laneGap + timeBarH), 2);
|
||
const timeBarTop = usageBarTop + usageBarH + laneGap;
|
||
const timeTextH = fonts.main + scale(2, dpi);
|
||
const usagePctH = fonts.small + scale(2, dpi);
|
||
|
||
const contentW = Math.max(contentRight - contentLeft, 0);
|
||
const barMin = scale(8, dpi);
|
||
const desiredTextW = Math.max(countdownW, pctReserveW);
|
||
const textW =
|
||
contentW >= desiredTextW + barTextGap + barMin
|
||
? desiredTextW
|
||
: Math.max(contentW - barTextGap - barMin, 0);
|
||
const textLeft = contentRight - textW;
|
||
const barLeft = contentLeft;
|
||
const barRight = Math.max(textLeft - barTextGap, barLeft + barMin);
|
||
|
||
const usagePctTop = usageBarTop + idiv(usageBarH - usagePctH, 2);
|
||
const timeTextTop = timeBarTop + idiv(timeBarH - timeTextH, 2);
|
||
return {
|
||
usagePctRect: { l: textLeft, t: usagePctTop, r: contentRight, b: usagePctTop + usagePctH },
|
||
usageBarRect: { l: barLeft, t: usageBarTop, r: barRight, b: usageBarTop + usageBarH },
|
||
timeTextRect: { l: textLeft, t: timeTextTop, r: contentRight, b: timeTextTop + timeTextH },
|
||
timeBarRect: { l: barLeft, t: timeBarTop, r: barRight, b: timeBarTop + timeBarH },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Dash pattern for an arc on an SVG circle rotated -90° (so the path
|
||
* starts at 12 o'clock and runs clockwise). The usage arc starts at
|
||
* 12 o'clock; the remaining-time arc ends there, so the consumed wedge
|
||
* grows clockwise from 12 like a clock hand.
|
||
* @param {number} radius Circle radius.
|
||
* @param {number} fraction Share of the full turn, clamped to [0, 1].
|
||
* @param {'from-top' | 'to-top'} anchor Whether the arc starts or ends at 12 o'clock.
|
||
* @returns {RingDash | null} The dash values, or null when nothing is drawn.
|
||
*/
|
||
export function ringDash(radius, fraction, anchor) {
|
||
const f = clamp(fraction, 0, 1);
|
||
if (!(f > 0)) {
|
||
return null;
|
||
}
|
||
const circumference = 2 * Math.PI * radius;
|
||
const length = f * circumference;
|
||
return {
|
||
dashArray: `${length} ${circumference}`,
|
||
dashOffset: anchor === 'to-top' ? -(circumference - length) : 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Share of a usage window still left before it resets.
|
||
* @param {number | null} resetsAtMs Reset time in Unix ms, or null.
|
||
* @param {number} durationSecs Window length in seconds.
|
||
* @param {number} nowMs Current time in Unix ms.
|
||
* @returns {number | null} Fraction in [0, 1], or null without a reset time.
|
||
*/
|
||
export function remainingFraction(resetsAtMs, durationSecs, nowMs) {
|
||
if (resetsAtMs === null || resetsAtMs === undefined || !(durationSecs > 0)) {
|
||
return null;
|
||
}
|
||
const remainingSecs = Math.max(resetsAtMs - nowMs, 0) / 1000;
|
||
return clamp(remainingSecs / durationSecs, 0, 1);
|
||
}
|
||
|
||
/**
|
||
* Pulse triangle wave: 0 at phase 0, 1 at phase 12, period 24.
|
||
* @param {number} phase Tick counter (a non-negative integer).
|
||
* @returns {number} Brightening amount in [0, 1].
|
||
*/
|
||
export function pulseT(phase) {
|
||
return 1 - Math.abs((phase % PULSE_PERIOD) - PULSE_PERIOD / 2) / (PULSE_PERIOD / 2);
|
||
}
|
||
|
||
/**
|
||
* Whether either percent is in the pulsing alarm band.
|
||
* @param {number | null} sessionPct Session percent.
|
||
* @param {number | null} weeklyPct Weekly percent.
|
||
* @returns {boolean} True while the pulse timer should run.
|
||
*/
|
||
export function pulseActive(sessionPct, weeklyPct) {
|
||
return (
|
||
(sessionPct !== null && sessionPct >= PULSE_THRESHOLD_PCT) ||
|
||
(weeklyPct !== null && weeklyPct >= PULSE_THRESHOLD_PCT)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Move a `#RRGGBB` colour toward white by up to 30%.
|
||
* @param {string} hex Colour as `#RRGGBB`.
|
||
* @param {number} t Pulse amount in [0, 1].
|
||
* @returns {string} The brightened colour as upper-case `#RRGGBB`; the input
|
||
* unchanged when it is not a `#RRGGBB` colour.
|
||
*/
|
||
export function brighten(hex, t) {
|
||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||
if (!match) {
|
||
return hex;
|
||
}
|
||
const k = clamp(t, 0, 1) * PULSE_STRENGTH;
|
||
const value = parseInt(match[1], 16);
|
||
const channels = [value >> 16, (value >> 8) & 0xff, value & 0xff].map((c) =>
|
||
Math.round(c + (255 - c) * k),
|
||
);
|
||
return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
||
}
|
||
|
||
/**
|
||
* Width of the weekly usage fill. A small non-zero value is floored at one
|
||
* bar height, so 1% still shows as a dot.
|
||
* @param {number} barW Bar width.
|
||
* @param {number} barH Bar height.
|
||
* @param {number | null} pct Weekly percent, or null.
|
||
* @returns {number} Fill width; 0 draws nothing.
|
||
*/
|
||
export function usageFillWidth(barW, barH, pct) {
|
||
if (pct === null || !(barW > 0) || !(barH > 0)) {
|
||
return 0;
|
||
}
|
||
const fill = barW * (clamp(pct, 0, 100) / 100);
|
||
if (!(fill > 0)) {
|
||
return 0;
|
||
}
|
||
const clipped = Math.min(fill, barW);
|
||
return clipped < barH ? Math.min(barH, barW) : clipped;
|
||
}
|
||
|
||
/**
|
||
* Width of the remaining-time fill, anchored on the bar's right edge.
|
||
* @param {number} barW Bar width.
|
||
* @param {number | null} fraction Remaining fraction, or null.
|
||
* @returns {number} Fill width; 0 draws nothing.
|
||
*/
|
||
export function timeFillWidth(barW, fraction) {
|
||
if (fraction === null || !(barW > 0)) {
|
||
return 0;
|
||
}
|
||
return Math.max(Math.min(barW * fraction, barW), 0);
|
||
}
|
||
|
||
/**
|
||
* The head label to draw: the countdown, or "5H" when it is empty or wider
|
||
* than its rect.
|
||
* @param {string} label Countdown from the payload.
|
||
* @param {number} labelW Its measured width in physical px.
|
||
* @param {number} rectW Width of the head label rect.
|
||
* @returns {string} Text to draw.
|
||
*/
|
||
export function headLabelText(label, labelW, rectW) {
|
||
return label === '' || labelW > rectW ? HEAD_LABEL_FALLBACK : label;
|
||
}
|