diff --git a/tests/js/layout.test.js b/tests/js/layout.test.js new file mode 100644 index 0000000..0875536 --- /dev/null +++ b/tests/js/layout.test.js @@ -0,0 +1,267 @@ +// 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'); + }); +}); diff --git a/ui/bubble.css b/ui/bubble.css new file mode 100644 index 0000000..3ea87e5 --- /dev/null +++ b/ui/bubble.css @@ -0,0 +1,144 @@ +/* Bubble palette. Accent and fill colours come from the payload. */ +:root[data-theme='dark'] { + --bg: #1f1f1f; + --track: #2c2c2c; + --time-track: #404040; + --time-fill: #b0b0b0; + --text: #eaeaea; + --muted: #a8a8a8; +} + +:root[data-theme='light'] { + --bg: #f3f3f3; + --track: #e2e2e2; + --time-track: #e0e0e0; + --time-fill: #666666; + --text: #1f1f1f; + --muted: #5e5e5e; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: transparent; + user-select: none; + -webkit-user-select: none; + /* Only the pill takes input: the transparent stadium corners are dead. */ + pointer-events: none; +} + +.pill { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: 9999px; + background: var(--bg); + font-family: 'Segoe UI', system-ui, sans-serif; + cursor: move; + pointer-events: auto; +} + +/* Every pointer event targets the pill itself, whose hit area follows its + rounded outline. */ +.pill * { + pointer-events: none; +} + +.rings { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.rings circle { + fill: none; +} + +.ring-track { + stroke: var(--track); +} + +.time-ring-track { + stroke: var(--time-track); +} + +.time-ring-arc { + stroke: var(--time-fill); +} + +.ring-arc, +.time-ring-arc { + stroke-linecap: round; +} + +.text { + position: absolute; + margin: 0; + white-space: nowrap; + color: var(--text); +} + +.head-label { + font-weight: 600; + color: var(--muted); + text-align: center; +} + +.head-pct { + font-weight: 700; + text-align: center; +} + +.tail-pct, +.tail-time { + overflow: hidden; + text-align: right; + text-overflow: ellipsis; +} + +.tail-pct { + font-weight: 600; +} + +.tail-time { + font-weight: 400; + color: var(--muted); +} + +.bar { + position: absolute; + overflow: hidden; + border-radius: 9999px; +} + +.usage-bar { + background: var(--track); +} + +.time-bar { + background: var(--time-track); +} + +.fill { + position: absolute; + top: 0; + height: 100%; + border-radius: 9999px; +} + +.usage-fill { + left: 0; +} + +.time-fill { + right: 0; + background: var(--time-fill); +} + +[hidden] { + display: none !important; +} diff --git a/ui/bubble.html b/ui/bubble.html index da055c4..eb6a672 100644 --- a/ui/bubble.html +++ b/ui/bubble.html @@ -1,27 +1,25 @@ - + Claude Code Usage Bubble - + -
Waiting for the first payload...
+
+ +
+
+
+
+
+
+
diff --git a/ui/bubble.js b/ui/bubble.js index d95de95..19168c2 100644 --- a/ui/bubble.js +++ b/ui/bubble.js @@ -1,55 +1,493 @@ -// Test view: renders the bubble payload as raw JSON until the real bubble -// UI replaces this page. +// 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. -const tauri = window.__TAURI__; -const view = /** @type {HTMLElement} */ (document.getElementById('payload')); -const webview = tauri.webviewWindow.getCurrentWebviewWindow(); -const provider = webview.label.replace(/^bubble-/, ''); +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'; /** - * Forward a message to the diagnostic log. - * @param {'error' | 'warn' | 'info' | 'debug'} level Log level. - * @param {string} message Text to log. + * 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 | null} */ +let head = null; +/** @type {ReturnType | null} */ +let tail = null; + +let pulsePhase = 0; +/** @type {ReturnType | null} */ +let pulseTimer = null; +/** @type {ReturnType | 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 logJs(level, message) { - tauri.core.invoke('log_js', { level, message }).catch(() => {}); +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`; } /** - * Show one payload. - * @param {unknown} payload The bubble payload from Rust. + * 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 render(payload) { - view.textContent = JSON.stringify(payload, null, 2); +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`; } -window.addEventListener('error', (event) => { - logJs('error', `${event.message} at ${event.filename}:${event.lineno}`); +/** + * 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 }; }); -window.addEventListener('unhandledrejection', (event) => { - logJs('error', `unhandled rejection: ${String(event.reason)}`); + +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(); - tauri.core - .invoke('show_context_menu', { label: webview.label }) - .catch((err) => logJs('error', `show_context_menu failed: ${String(err)}`)); + if (event.target !== pill) { + return; + } + invoke('show_context_menu', { label: windowLabel }).catch(logFailure('show_context_menu')); }); -webview - .listen('bubble:update', (event) => render(event.payload)) - .catch((err) => logJs('error', `listen failed: ${String(err)}`)); +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 }, +); -tauri.core - .invoke('get_bubble_state', { provider }) - .then((payload) => { - render(payload); - return tauri.core.invoke('bubble_ready', { label: webview.label }); +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((err) => { - view.textContent = `get_bubble_state failed: ${String(err)}`; - logJs('error', `startup failed: ${String(err)}`); - }); + .catch(logFailure('bubble start-up')); diff --git a/ui/layout.js b/ui/layout.js new file mode 100644 index 0000000..d646e93 --- /dev/null +++ b/ui/layout.js @@ -0,0 +1,343 @@ +// 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; +} diff --git a/ui/tauri-bridge.js b/ui/tauri-bridge.js new file mode 100644 index 0000000..13e1e2b --- /dev/null +++ b/ui/tauri-bridge.js @@ -0,0 +1,95 @@ +// Thin typed wrappers over `window.__TAURI__` (`withGlobalTauri`), shared by +// the bubble and panel pages. + +/** + * The slice of a Tauri webview window this UI uses. + * @typedef {object} TauriWebviewWindow + * @property {string} label Window label. + * @property {(event: string, handler: (e: {payload: unknown}) => void) => Promise<() => void>} listen + * Listen for an event targeted at this window. + * @property {() => Promise} startDragging Start an OS window drag. + */ + +/** + * The slice of `window.__TAURI__` (`@tauri-apps/api`, which this + * bundler-free UI does not import) that this UI uses. + * @typedef {object} TauriGlobal + * @property {{invoke: (command: string, args?: Record) => Promise}} core + * Command IPC. + * @property {{getCurrentWebviewWindow: () => TauriWebviewWindow}} webviewWindow + * Window access. + */ + +/** @type {TauriGlobal} */ +const tauri = window.__TAURI__; + +/** The webview window this page runs in. */ +const webview = tauri.webviewWindow.getCurrentWebviewWindow(); + +/** + * Label of this page's window, e.g. `bubble-claude`. + * @type {string} + */ +export const windowLabel = webview.label; + +/** + * Call a Rust command. + * @template T + * @param {string} command Command name. + * @param {Record} [args] Command arguments (camelCase). + * @returns {Promise} The command's result. + */ +export function invoke(command, args = {}) { + return /** @type {Promise} */ (tauri.core.invoke(command, args)); +} + +/** + * Listen for an event Rust sends to this window. + * @template T + * @param {string} event Event name. + * @param {(payload: T) => void} handler Called with each event's payload. + * @returns {Promise<() => void>} Resolves to a function that stops listening. + */ +export function listen(event, handler) { + return webview.listen(event, (e) => handler(/** @type {T} */ (e.payload))); +} + +/** + * Start an OS window drag. Call it while the primary button is down. + * @returns {Promise} Resolves once the drag was requested. + */ +export function startDragging() { + return webview.startDragging(); +} + +/** + * Forward a message to the diagnostic log (written only with `--diagnose`). + * @param {'error' | 'warn' | 'info' | 'debug'} level Log level. + * @param {string} message Text to log. + * @returns {void} + */ +export function logJs(level, message) { + invoke('log_js', { level, message }).catch(() => {}); +} + +/** + * Turn a rejected promise into an error log line. + * @param {string} what What failed, for the log. + * @returns {(err: unknown) => void} A `catch` handler. + */ +export function logFailure(what) { + return (err) => logJs('error', `${what} failed: ${String(err)}`); +} + +/** + * Forward uncaught errors and unhandled rejections to the diagnostic log. + * @returns {void} + */ +export function forwardErrors() { + window.addEventListener('error', (event) => { + logJs('error', `${event.message} at ${event.filename}:${event.lineno}`); + }); + window.addEventListener('unhandledrejection', (event) => { + logJs('error', `unhandled rejection: ${String(event.reason)}`); + }); +}