Files
claude-code-usage-bubble/ui/bubble.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

494 lines
15 KiB
JavaScript

// The bubble page: lays out the payload Rust sends and forwards pointer
// input. Rust formats every displayed string and picks every fill colour;
// this file only places pixels.
import {
BASE_DPI,
brighten,
cssPx,
headLabelText,
headMetrics,
pulseActive,
pulseT,
remainingFraction,
ringDash,
tailLayout,
timeFillWidth,
usageFillWidth,
} from './layout.js';
import {
forwardErrors,
invoke,
listen,
logFailure,
startDragging,
windowLabel,
} from './tauri-bridge.js';
/**
* The `bubble:update` payload and `get_bubble_state` result.
* @typedef {object} BubblePayload
* @property {string} provider Provider slug.
* @property {number | null} sessionPct Session usage percent.
* @property {number | null} weeklyPct Weekly usage percent.
* @property {string} sessionPctText Head percent text.
* @property {string | null} weeklyPctText Tail percent text; null hides it.
* @property {string} headLabel Session countdown; empty or too wide shows "5H".
* @property {string} weeklyCountdown Weekly countdown; empty hides it.
* @property {number | null} sessionResetsAtMs Session reset, Unix ms.
* @property {number | null} weeklyResetsAtMs Weekly reset, Unix ms.
* @property {number} primaryDurSecs Session window length.
* @property {number} secondaryDurSecs Weekly window length.
* @property {string} sessionFill Ring arc colour.
* @property {string} weeklyFill Weekly bar colour.
* @property {string} accent Provider accent colour.
* @property {boolean} isDark Dark palette.
* @property {number} canvasW Window width in physical px.
* @property {number} canvasH Window height in physical px.
* @property {number} dpi Window DPI.
*/
/** Pointer travel, in physical px, that turns a press into a drag. */
const DRAG_THRESHOLD_PX = 3;
const PULSE_INTERVAL_MS = 80;
const TIME_PROGRESS_INTERVAL_MS = 60_000;
const FONT_STACK = '"Segoe UI", system-ui, sans-serif';
/** Weight the layout's reserved text widths are measured at. */
const MEASURE_WEIGHT = 400;
const HEAD_LABEL_WEIGHT = 600;
const slug = windowLabel.replace(/^bubble-/, '');
/**
* Look up a required element.
* @template {Element} T
* @param {string} id Element id.
* @returns {T} The element.
*/
function byId(id) {
const element = document.getElementById(id);
if (!element) {
throw new Error(`bubble.html is missing #${id}`);
}
return /** @type {T} */ (/** @type {unknown} */ (element));
}
/** @type {HTMLElement} */
const pill = byId('pill');
/** @type {SVGSVGElement} */
const rings = byId('rings');
/** @type {SVGCircleElement} */
const ringTrack = byId('ring-track');
/** @type {SVGCircleElement} */
const ringArc = byId('ring-arc');
/** @type {SVGCircleElement} */
const timeRingTrack = byId('time-ring-track');
/** @type {SVGCircleElement} */
const timeRingArc = byId('time-ring-arc');
/** @type {HTMLElement} */
const headLabel = byId('head-label');
/** @type {HTMLElement} */
const headPct = byId('head-pct');
/** @type {HTMLElement} */
const usageBar = byId('usage-bar');
/** @type {HTMLElement} */
const usageFill = byId('usage-fill');
/** @type {HTMLElement} */
const timeBar = byId('time-bar');
/** @type {HTMLElement} */
const timeFill = byId('time-fill');
/** @type {HTMLElement} */
const tailPct = byId('tail-pct');
/** @type {HTMLElement} */
const tailTime = byId('tail-time');
const measureContext = document.createElement('canvas').getContext('2d');
/**
* Width of `text` in physical px. The measuring canvas is unscaled, so a
* font size in physical px yields a width in physical px.
* @param {string} text Text to measure.
* @param {number} fontPx Font size in physical px.
* @param {number} [weight] Font weight.
* @returns {number} Width in physical px; 0 without a 2D context.
*/
function measure(text, fontPx, weight = MEASURE_WEIGHT) {
if (!measureContext) {
return 0;
}
measureContext.font = `${weight} ${fontPx}px ${FONT_STACK}`;
return measureContext.measureText(text).width;
}
/** @type {BubblePayload | null} */
let payload = null;
/** @type {string} */
let layoutKey = '';
/** @type {ReturnType<typeof headMetrics> | null} */
let head = null;
/** @type {ReturnType<typeof tailLayout> | null} */
let tail = null;
let pulsePhase = 0;
/** @type {ReturnType<typeof setInterval> | null} */
let pulseTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */
let timeProgressTimer = null;
/**
* Place an element on a physical-px rect.
* @param {HTMLElement} element Absolutely positioned element.
* @param {import('./layout.js').Rect} rect Rect in physical px.
* @param {number} dpi Window DPI.
* @returns {void}
*/
function place(element, rect, dpi) {
element.style.left = `${cssPx(rect.l, dpi)}px`;
element.style.top = `${cssPx(rect.t, dpi)}px`;
element.style.width = `${cssPx(Math.max(rect.r - rect.l, 0), dpi)}px`;
element.style.height = `${cssPx(Math.max(rect.b - rect.t, 0), dpi)}px`;
}
/**
* Place a text box: one line, vertically centred in its rect.
* @param {HTMLElement} element Text element.
* @param {import('./layout.js').Rect} rect Rect in physical px.
* @param {number} fontPx Font size in physical px.
* @param {number} dpi Window DPI.
* @returns {void}
*/
function placeText(element, rect, fontPx, dpi) {
place(element, rect, dpi);
element.style.fontSize = `${cssPx(fontPx, dpi)}px`;
element.style.lineHeight = `${cssPx(Math.max(rect.b - rect.t, 0), dpi)}px`;
}
/**
* Set a ring circle's geometry.
* @param {SVGCircleElement} circle Circle element.
* @param {number} cx Centre x.
* @param {number} cy Centre y.
* @param {number} r Radius.
* @param {number} stroke Stroke width.
* @returns {void}
*/
function placeCircle(circle, cx, cy, r, stroke) {
circle.setAttribute('cx', String(cx));
circle.setAttribute('cy', String(cy));
circle.setAttribute('r', String(r));
circle.setAttribute('stroke-width', String(stroke));
// Paths of a circle start at 3 o'clock; rotate so arcs start at 12.
circle.setAttribute('transform', `rotate(-90 ${cx} ${cy})`);
}
/**
* Show an arc for `fraction` of a ring, or hide it.
* @param {SVGCircleElement} circle Arc circle.
* @param {number} radius Ring radius.
* @param {number | null} fraction Share of the ring, or null.
* @param {'from-top' | 'to-top'} anchor Where the arc is pinned.
* @returns {void}
*/
function setArc(circle, radius, fraction, anchor) {
const dash = fraction === null ? null : ringDash(radius, fraction, anchor);
if (!dash) {
circle.setAttribute('visibility', 'hidden');
return;
}
circle.setAttribute('stroke-dasharray', dash.dashArray);
circle.setAttribute('stroke-dashoffset', String(dash.dashOffset));
circle.removeAttribute('visibility');
}
/**
* Recompute the geometry for the payload's canvas, when it changed.
* @param {BubblePayload} p Current payload.
* @returns {void}
*/
function relayout(p) {
const key = `${p.canvasW}x${p.canvasH}@${p.dpi}`;
if (key === layoutKey && head && tail) {
return;
}
const canvas = { canvasW: p.canvasW, canvasH: p.canvasH, dpi: p.dpi };
head = headMetrics(canvas);
tail = tailLayout(canvas, (text, px) => measure(text, px));
layoutKey = key;
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
placeCircle(ringTrack, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(ringArc, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(timeRingTrack, head.ringCx, head.ringCy, head.timeRingRadius, head.timeRingStroke);
placeCircle(timeRingArc, head.ringCx, head.ringCy, head.timeRingRadius, head.timeRingStroke);
placeText(headLabel, head.labelRect, head.fonts.small, p.dpi);
placeText(headPct, head.pctRect, head.fonts.big, p.dpi);
placeText(tailPct, tail.usagePctRect, head.fonts.small, p.dpi);
placeText(tailTime, tail.timeTextRect, head.fonts.main, p.dpi);
place(usageBar, tail.usageBarRect, p.dpi);
place(timeBar, tail.timeBarRect, p.dpi);
}
/**
* Write the texts and the usage values that only change with a payload.
* @param {BubblePayload} p Current payload.
* @returns {void}
*/
function renderValues(p) {
if (!head || !tail) {
return;
}
const labelRectW = head.labelRect.r - head.labelRect.l;
const labelW = measure(p.headLabel, head.fonts.small, HEAD_LABEL_WEIGHT);
headLabel.textContent = headLabelText(p.headLabel, labelW, labelRectW);
headPct.textContent = p.sessionPctText;
const pctRectW = tail.usagePctRect.r - tail.usagePctRect.l;
tailPct.textContent = p.weeklyPctText ?? '';
tailPct.hidden = p.weeklyPctText === null || pctRectW <= 0;
const timeRectW = tail.timeTextRect.r - tail.timeTextRect.l;
tailTime.textContent = p.weeklyCountdown;
tailTime.hidden = p.weeklyCountdown === '' || timeRectW <= 0;
setArc(
ringArc,
head.ringRadius,
p.sessionPct === null ? null : p.sessionPct / 100,
'from-top',
);
const barW = tail.usageBarRect.r - tail.usageBarRect.l;
const barH = tail.usageBarRect.b - tail.usageBarRect.t;
const fillW = usageFillWidth(barW, barH, p.weeklyPct);
usageFill.hidden = fillW <= 0;
usageFill.style.width = `${cssPx(fillW, p.dpi)}px`;
}
/**
* Redraw the remaining-time ring and bar for the current time.
* @returns {void}
*/
function renderTime() {
if (!payload || !head || !tail) {
return;
}
const now = Date.now();
setArc(
timeRingArc,
head.timeRingRadius,
remainingFraction(payload.sessionResetsAtMs, payload.primaryDurSecs, now),
'to-top',
);
const barW = tail.timeBarRect.r - tail.timeBarRect.l;
const fillW = timeFillWidth(
barW,
remainingFraction(payload.weeklyResetsAtMs, payload.secondaryDurSecs, now),
);
timeFill.hidden = fillW <= 0;
timeFill.style.width = `${cssPx(fillW, payload.dpi)}px`;
}
/**
* Paint the colours that pulse: the ring arc while the session is at 95%
* or more, the weekly bar fill and percent text while the week is.
* @returns {void}
*/
function paintPulse() {
if (!payload) {
return;
}
const t = pulseT(pulsePhase);
const sessionAlarm = pulseActive(payload.sessionPct, null);
const weeklyAlarm = pulseActive(null, payload.weeklyPct);
ringArc.style.stroke = sessionAlarm ? brighten(payload.sessionFill, t) : payload.sessionFill;
usageFill.style.background = weeklyAlarm ? brighten(payload.weeklyFill, t) : payload.weeklyFill;
if (weeklyAlarm) {
const text = getComputedStyle(document.documentElement).getPropertyValue('--text');
tailPct.style.color = brighten(text, t);
} else {
tailPct.style.color = '';
}
}
/**
* Run the 80 ms pulse only while a value is at 95% or more; reset the
* phase whenever it stops.
* @returns {void}
*/
function syncPulseTimer() {
const active = payload !== null && pulseActive(payload.sessionPct, payload.weeklyPct);
if (active && pulseTimer === null) {
pulseTimer = setInterval(() => {
pulsePhase += 1;
paintPulse();
}, PULSE_INTERVAL_MS);
} else if (!active && pulseTimer !== null) {
clearInterval(pulseTimer);
pulseTimer = null;
pulsePhase = 0;
}
}
/**
* Advance the time progress every minute while a reset time is known.
* @returns {void}
*/
function syncTimeProgressTimer() {
const active =
payload !== null && (payload.sessionResetsAtMs !== null || payload.weeklyResetsAtMs !== null);
if (active && timeProgressTimer === null) {
timeProgressTimer = setInterval(renderTime, TIME_PROGRESS_INTERVAL_MS);
} else if (!active && timeProgressTimer !== null) {
clearInterval(timeProgressTimer);
timeProgressTimer = null;
}
}
/**
* Apply the theme palette.
* @param {boolean} isDark Dark palette.
* @returns {void}
*/
function applyTheme(isDark) {
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
}
/**
* Render a payload.
* @param {BubblePayload} p Payload from Rust.
* @returns {void}
*/
function applyPayload(p) {
payload = p;
applyTheme(p.isDark);
relayout(p);
renderValues(p);
renderTime();
syncPulseTimer();
syncTimeProgressTimer();
paintPulse();
}
// ---------- Input ----------
/** @type {{pointerId: number, x: number, y: number} | null} */
let pressed = null;
/**
* Physical px per CSS px, from the payload DPI.
* @returns {number} Scale factor.
*/
function physicalScale() {
return payload ? payload.dpi / BASE_DPI : window.devicePixelRatio || 1;
}
/** @returns {void} */
function clearPressed() {
pressed = null;
}
pill.addEventListener('pointerdown', (event) => {
if (event.button !== 0) {
return;
}
try {
pill.setPointerCapture(event.pointerId);
} catch (err) {
// Without capture a release outside the bubble is missed, which only
// costs that one click.
logFailure('setPointerCapture')(err);
}
pressed ={ pointerId: event.pointerId, x: event.screenX, y: event.screenY };
});
pill.addEventListener('pointermove', (event) => {
if (!pressed || event.pointerId !== pressed.pointerId) {
return;
}
if ((event.buttons & 1) !== 1) {
clearPressed();
return;
}
const scale = physicalScale();
const dx = Math.abs(event.screenX - pressed.x) * scale;
const dy = Math.abs(event.screenY - pressed.y) * scale;
if (Math.max(dx, dy) < DRAG_THRESHOLD_PX) {
return;
}
// The OS move loop swallows the release, so the drag ends in Rust; the
// pressed state is cleared now so a later hover cannot start another.
clearPressed();
if (pill.hasPointerCapture(event.pointerId)) {
pill.releasePointerCapture(event.pointerId);
}
// Arm the move-end handling before the window starts moving.
invoke('bubble_drag_start', { slug })
.catch(logFailure('bubble_drag_start'))
.then(() => startDragging())
.catch(logFailure('startDragging'));
});
pill.addEventListener('pointerup', (event) => {
if (!pressed || event.pointerId !== pressed.pointerId || event.button !== 0) {
return;
}
clearPressed();
invoke('bubble_click', { slug }).catch(logFailure('bubble_click'));
});
pill.addEventListener('pointercancel', clearPressed);
pill.addEventListener('lostpointercapture', clearPressed);
window.addEventListener('blur', clearPressed);
window.addEventListener('contextmenu', (event) => {
event.preventDefault();
if (event.target !== pill) {
return;
}
invoke('show_context_menu', { label: windowLabel }).catch(logFailure('show_context_menu'));
});
window.addEventListener(
'wheel',
(event) => {
if (!event.ctrlKey) {
return;
}
// Ctrl+wheel would otherwise zoom the page.
event.preventDefault();
const dir = Math.sign(-event.deltaY);
if (event.target !== pill || dir === 0) {
return;
}
invoke('bubble_resize_step', { dir }).catch(logFailure('bubble_resize_step'));
},
{ passive: false },
);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
renderTime();
}
});
// ---------- Start-up ----------
forwardErrors();
listen('bubble:update', (/** @type {BubblePayload} */ p) => applyPayload(p)).catch(
logFailure('listen bubble:update'),
);
listen('theme', (/** @type {{isDark: boolean}} */ p) => {
applyTheme(p.isDark);
paintPulse();
}).catch(logFailure('listen theme'));
invoke('get_bubble_state', { provider: slug })
.then((/** @type {BubblePayload} */ p) => {
// A push that arrived first is at least as new as this answer.
if (payload === null) {
applyPayload(p);
}
return invoke('bubble_ready', { label: windowLabel });
})
.catch(logFailure('bubble start-up'));