Files
claude-code-usage-bubble/ui/layout.js
T
tiennm99 094b6bdcb0 feat: draw the bubble from the payload in HTML, CSS and SVG
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.
2026-10-21 14:40:18 +07:00

344 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}