mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 22:16:30 +00:00
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.
This commit is contained in:
1 parent
340464de58
commit
094b6bdcb0
6 files changed
+1337
-52
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
+144
@@ -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;
|
||||
}
|
||||
+16
-18
@@ -1,27 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Claude Code Usage Bubble</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background: #1f1f1f;
|
||||
color: #eaeaea;
|
||||
font: 12px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="bubble.css">
|
||||
<script type="module" src="bubble.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<pre id="payload">Waiting for the first payload...</pre>
|
||||
<div class="pill" id="pill">
|
||||
<svg class="rings" id="rings" aria-hidden="true">
|
||||
<circle class="ring-track" id="ring-track"></circle>
|
||||
<circle class="ring-arc" id="ring-arc"></circle>
|
||||
<circle class="time-ring-track" id="time-ring-track"></circle>
|
||||
<circle class="time-ring-arc" id="time-ring-arc"></circle>
|
||||
</svg>
|
||||
<div class="text head-label" id="head-label"></div>
|
||||
<div class="text head-pct" id="head-pct"></div>
|
||||
<div class="bar usage-bar" id="usage-bar"><div class="fill usage-fill" id="usage-fill"></div></div>
|
||||
<div class="bar time-bar" id="time-bar"><div class="fill time-fill" id="time-fill"></div></div>
|
||||
<div class="text tail-pct" id="tail-pct"></div>
|
||||
<div class="text tail-time" id="tail-time"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+472
-34
@@ -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<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 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'));
|
||||
+343
@@ -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;
|
||||
}
|
||||
@@ -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<void>} 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<string, unknown>) => Promise<unknown>}} 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<string, unknown>} [args] Command arguments (camelCase).
|
||||
* @returns {Promise<T>} The command's result.
|
||||
*/
|
||||
export function invoke(command, args = {}) {
|
||||
return /** @type {Promise<T>} */ (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<void>} 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)}`);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user