mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-23 16:16:39 +00:00
Merge branch 'worktree-wf_093aad93-ad0-6' into review-fixes
This commit is contained in:
commit
70e121c3c3
4 files changed
+168
-9
No files matched your search
@@ -15,9 +15,13 @@ import {
|
||||
remainingFraction,
|
||||
ringDash,
|
||||
scale,
|
||||
TAIL_PCT_WEIGHT,
|
||||
tailLayout,
|
||||
timeFillWidth,
|
||||
usageFillWidth,
|
||||
WHEEL_IDLE,
|
||||
WHEEL_IDLE_MS,
|
||||
wheelSteps,
|
||||
} from '../../ui/layout.js';
|
||||
|
||||
/**
|
||||
@@ -130,6 +134,21 @@ describe('tailLayout', () => {
|
||||
assert.equal(tail.usageBarRect.r - tail.usageBarRect.l, 8);
|
||||
});
|
||||
|
||||
it('reserves the percent column at the weight it is drawn in', () => {
|
||||
const regular = 40;
|
||||
const semibold = 61.3;
|
||||
/** @type {Array<number | undefined>} */
|
||||
const pctWeights = [];
|
||||
const tail = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, (text, _px, weight) => {
|
||||
if (text === '100%') {
|
||||
pctWeights.push(weight);
|
||||
}
|
||||
return weight === TAIL_PCT_WEIGHT ? semibold : regular;
|
||||
});
|
||||
assert.deepEqual(pctWeights, [600]);
|
||||
assert.ok(tail.usagePctRect.r - tail.usagePctRect.l >= Math.ceil(semibold));
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -265,3 +284,79 @@ describe('headLabelText', () => {
|
||||
assert.equal(headLabelText('23h 59m', 59, 58), '5H');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wheelSteps', () => {
|
||||
const PIXEL = 0;
|
||||
const LINE = 1;
|
||||
const PAGE = 2;
|
||||
|
||||
/**
|
||||
* Feed wheel events through `wheelSteps` and collect the steps.
|
||||
* @param {Array<[number, number, number]>} events `[deltaY, deltaMode, ms]` triples.
|
||||
* @returns {number[]} Steps per event.
|
||||
*/
|
||||
const run = (events) => {
|
||||
let state = WHEEL_IDLE;
|
||||
return events.map(([deltaY, mode, ms]) => {
|
||||
const result = wheelSteps(state, deltaY, mode, ms);
|
||||
state = result.state;
|
||||
return result.steps;
|
||||
});
|
||||
};
|
||||
|
||||
it('gives exactly one step per mouse notch', () => {
|
||||
assert.deepEqual(run([[-100, PIXEL, 0]]), [1]);
|
||||
assert.deepEqual(run([[100, PIXEL, 0]]), [-1]);
|
||||
assert.deepEqual(run([[-100, PIXEL, 0], [-100, PIXEL, 50], [-100, PIXEL, 100]]), [1, 1, 1]);
|
||||
});
|
||||
|
||||
it('waits for 100 px of travel before a step', () => {
|
||||
assert.deepEqual(run([[-40, PIXEL, 0]]), [0]);
|
||||
assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 10], [-40, PIXEL, 20]]), [0, 0, 1]);
|
||||
assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 1000]]), [0, 0]);
|
||||
});
|
||||
|
||||
it('turns a stream of small touchpad deltas into one step per 100 px', () => {
|
||||
const events = /** @type {Array<[number, number, number]>} */ (
|
||||
Array.from({ length: 30 }, (_, i) => [-10, PIXEL, i * 10])
|
||||
);
|
||||
const steps = run(events);
|
||||
assert.equal(steps.reduce((a, b) => a + b, 0), 3);
|
||||
assert.ok(steps.every((s) => s === 0 || s === 1));
|
||||
});
|
||||
|
||||
it('starts a new gesture after an idle pause', () => {
|
||||
assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS + 1]]), [0, 0]);
|
||||
assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS]]), [0, 1]);
|
||||
});
|
||||
|
||||
it('starts a new gesture when the direction flips', () => {
|
||||
assert.deepEqual(run([[-150, PIXEL, 0], [10, PIXEL, 10]]), [1, 0]);
|
||||
assert.deepEqual(run([[-150, PIXEL, 0], [60, PIXEL, 10], [60, PIXEL, 20]]), [1, 0, -1]);
|
||||
});
|
||||
|
||||
it('ignores a few px of reversed jitter inside a gesture', () => {
|
||||
assert.deepEqual(
|
||||
run([[-10, PIXEL, 0], [-10, PIXEL, 10], [2, PIXEL, 20], [-10, PIXEL, 30], [1, PIXEL, 40], [-10, PIXEL, 50]]),
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
);
|
||||
// After a whole notch leaves no travel, a reverse twitch and the next
|
||||
// forward delta still wait for 100 px.
|
||||
assert.deepEqual(run([[-100, PIXEL, 0], [2, PIXEL, 10], [-10, PIXEL, 20]]), [1, 0, 0]);
|
||||
});
|
||||
|
||||
it('sends several steps for one large delta', () => {
|
||||
assert.deepEqual(run([[-250, PIXEL, 0], [-50, PIXEL, 10]]), [2, 1]);
|
||||
});
|
||||
|
||||
it('converts line and page deltas to px', () => {
|
||||
// Three lines are 48 px, so the third event crosses 100 px.
|
||||
const lines = run([[-3, LINE, 0], [-3, LINE, 10], [-3, LINE, 20], [-3, LINE, 30]]);
|
||||
assert.deepEqual(lines, [0, 0, 1, 0]);
|
||||
assert.deepEqual(run([[-1, PAGE, 0]]), [8]);
|
||||
});
|
||||
|
||||
it('ignores zero and non-finite deltas', () => {
|
||||
assert.deepEqual(run([[0, PIXEL, 0], [Number.NaN, PIXEL, 10]]), [0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,7 @@ body {
|
||||
}
|
||||
|
||||
.tail-pct {
|
||||
/* The layout reserves this column measured at TAIL_PCT_WEIGHT in layout.js; change both together. */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
+14
-5
@@ -15,6 +15,8 @@ import {
|
||||
tailLayout,
|
||||
timeFillWidth,
|
||||
usageFillWidth,
|
||||
WHEEL_IDLE,
|
||||
wheelSteps,
|
||||
} from './layout.js';
|
||||
import {
|
||||
forwardErrors,
|
||||
@@ -53,7 +55,7 @@ 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. */
|
||||
/** Weight text is measured at when the caller names none (regular). */
|
||||
const MEASURE_WEIGHT = 400;
|
||||
const HEAD_LABEL_WEIGHT = 600;
|
||||
|
||||
@@ -212,7 +214,7 @@ function relayout(p) {
|
||||
}
|
||||
const canvas = { canvasW: p.canvasW, canvasH: p.canvasH, dpi: p.dpi };
|
||||
head = headMetrics(canvas);
|
||||
tail = tailLayout(canvas, (text, px) => measure(text, px));
|
||||
tail = tailLayout(canvas, (text, px, weight) => measure(text, px, weight));
|
||||
layoutKey = key;
|
||||
|
||||
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
|
||||
@@ -447,6 +449,9 @@ window.addEventListener('contextmenu', (event) => {
|
||||
invoke('show_context_menu', { label: windowLabel }).catch(logFailure('show_context_menu'));
|
||||
});
|
||||
|
||||
/** Ctrl+wheel gesture carried between wheel events. */
|
||||
let wheelState = WHEEL_IDLE;
|
||||
|
||||
window.addEventListener(
|
||||
'wheel',
|
||||
(event) => {
|
||||
@@ -455,11 +460,15 @@ window.addEventListener(
|
||||
}
|
||||
// Ctrl+wheel would otherwise zoom the page.
|
||||
event.preventDefault();
|
||||
const dir = Math.sign(-event.deltaY);
|
||||
if (event.target !== pill || dir === 0) {
|
||||
if (event.target !== pill) {
|
||||
return;
|
||||
}
|
||||
invoke('bubble_resize_step', { dir }).catch(logFailure('bubble_resize_step'));
|
||||
const result = wheelSteps(wheelState, event.deltaY, event.deltaMode, event.timeStamp);
|
||||
wheelState = result.state;
|
||||
const dir = Math.sign(result.steps);
|
||||
for (let i = Math.abs(result.steps); i > 0; i -= 1) {
|
||||
invoke('bubble_resize_step', { dir }).catch(logFailure('bubble_resize_step'));
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
+58
-4
@@ -1,4 +1,4 @@
|
||||
// Pure bubble geometry and colour helpers. Every length is in physical
|
||||
// Pure bubble geometry, colour and input 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.
|
||||
@@ -15,6 +15,18 @@ const COUNTDOWN_TEMPLATE = '999d';
|
||||
/** Widest percent text the tail reserves room for. */
|
||||
const PCT_TEMPLATE = '100%';
|
||||
|
||||
/** Font weight of the tail percent; keep in step with `.tail-pct` in bubble.css. */
|
||||
export const TAIL_PCT_WEIGHT = 600;
|
||||
|
||||
/** Wheel travel, in px, that makes one Ctrl+wheel resize step. */
|
||||
export const WHEEL_STEP_PX = 100;
|
||||
|
||||
/** A pause this long, in ms, ends a Ctrl+wheel gesture. */
|
||||
export const WHEEL_IDLE_MS = 200;
|
||||
|
||||
/** `WheelEvent.deltaMode` values to px: pixel, line, page. */
|
||||
const WHEEL_MODE_PX = [1, 16, 800];
|
||||
|
||||
/** Usage at or above this percent pulses. */
|
||||
export const PULSE_THRESHOLD_PCT = 95;
|
||||
|
||||
@@ -67,13 +79,27 @@ const PULSE_STRENGTH = 0.3;
|
||||
*/
|
||||
|
||||
/**
|
||||
* Measures `text` set at `fontPx` (regular weight) in physical px.
|
||||
* Measures `text` set at `fontPx` and `weight` in physical px.
|
||||
* @callback MeasureText
|
||||
* @param {string} text Text to measure.
|
||||
* @param {number} fontPx Font size in physical px.
|
||||
* @param {number} [weight] CSS font weight; regular (400) when omitted.
|
||||
* @returns {number} Width in physical px.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ctrl+wheel gesture state between wheel events.
|
||||
* @typedef {object} WheelState
|
||||
* @property {number} acc Travel not yet turned into steps, in px; positive grows.
|
||||
* @property {number} lastMs Time of the previous event, in ms; -Infinity before any.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} WheelResult
|
||||
* @property {WheelState} state State to pass with the next event.
|
||||
* @property {number} steps Resize steps to send: positive grows, negative shrinks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} RingDash
|
||||
* @property {string} dashArray Value for `stroke-dasharray`.
|
||||
@@ -174,7 +200,7 @@ export function headMetrics(canvas) {
|
||||
* 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.
|
||||
* @param {MeasureText} measure Text measurement.
|
||||
* @returns {TailLayout} Tail geometry in physical px.
|
||||
*/
|
||||
export function tailLayout(canvas, measure) {
|
||||
@@ -186,7 +212,7 @@ export function tailLayout(canvas, measure) {
|
||||
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 pctReserveW = Math.ceil(measure(PCT_TEMPLATE, fonts.small, TAIL_PCT_WEIGHT)) + 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));
|
||||
@@ -341,3 +367,31 @@ export function timeFillWidth(barW, fraction) {
|
||||
export function headLabelText(label, labelW, rectW) {
|
||||
return label === '' || labelW > rectW ? HEAD_LABEL_FALLBACK : label;
|
||||
}
|
||||
|
||||
/** Wheel state before any Ctrl+wheel event. */
|
||||
export const WHEEL_IDLE = Object.freeze({ acc: 0, lastMs: -Infinity });
|
||||
|
||||
/**
|
||||
* Turn one Ctrl+wheel event into resize steps. Travel accumulates in px, so
|
||||
* a touchpad's stream of small deltas resizes gradually instead of one step
|
||||
* per event: a step is sent each time the gesture's travel reaches
|
||||
* `WHEEL_STEP_PX`. A pause of `WHEEL_IDLE_MS` or a change of direction starts
|
||||
* a new gesture from zero, so a few px of reversed jitter inside a touchpad
|
||||
* stream never resizes the other way.
|
||||
* @param {WheelState} state State from the previous event, or `WHEEL_IDLE`.
|
||||
* @param {number} deltaY `WheelEvent.deltaY`; negative scrolls up and grows.
|
||||
* @param {number} deltaMode `WheelEvent.deltaMode`.
|
||||
* @param {number} nowMs Event time in ms.
|
||||
* @returns {WheelResult} The next state and the steps to send.
|
||||
*/
|
||||
export function wheelSteps(state, deltaY, deltaMode, nowMs) {
|
||||
const px = -deltaY * (WHEEL_MODE_PX[deltaMode] ?? 1);
|
||||
if (!Number.isFinite(px) || px === 0) {
|
||||
return { state, steps: 0 };
|
||||
}
|
||||
const fresh = nowMs - state.lastMs > WHEEL_IDLE_MS || Math.sign(state.acc) === -Math.sign(px);
|
||||
const acc = fresh ? px : state.acc + px;
|
||||
// `|| 0` turns the -0 of a small reverse delta into a plain 0.
|
||||
const steps = Math.trunc(acc / WHEEL_STEP_PX) || 0;
|
||||
return { state: { acc: acc - steps * WHEEL_STEP_PX, lastMs: nowMs }, steps };
|
||||
}
|
||||
Reference in new issue
Block a user