mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 08:16:18 +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.
494 lines
15 KiB
JavaScript
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'));
|