From c4ad23236e7034904de73664ead7fcb56bcb0b54 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:25:28 +0700 Subject: [PATCH 1/3] fix(ui): reserve the weekly percent column at its semibold weight The tail percent is drawn at weight 600 but its column was measured at 400, so "100%" was clipped at larger bubble sizes. The layout now asks the measure callback for the weight the text is drawn in. --- tests/js/layout.test.js | 16 ++++++++++++++++ ui/bubble.css | 1 + ui/bubble.js | 4 ++-- ui/layout.js | 11 ++++++++--- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/js/layout.test.js b/tests/js/layout.test.js index 0875536..b8edfeb 100644 --- a/tests/js/layout.test.js +++ b/tests/js/layout.test.js @@ -15,6 +15,7 @@ import { remainingFraction, ringDash, scale, + TAIL_PCT_WEIGHT, tailLayout, timeFillWidth, usageFillWidth, @@ -130,6 +131,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} */ + 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); diff --git a/ui/bubble.css b/ui/bubble.css index 3ea87e5..99ac6f2 100644 --- a/ui/bubble.css +++ b/ui/bubble.css @@ -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; } diff --git a/ui/bubble.js b/ui/bubble.js index 19168c2..8074bf7 100644 --- a/ui/bubble.js +++ b/ui/bubble.js @@ -53,7 +53,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 +212,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}`); diff --git a/ui/layout.js b/ui/layout.js index d646e93..a688377 100644 --- a/ui/layout.js +++ b/ui/layout.js @@ -15,6 +15,9 @@ 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; + /** Usage at or above this percent pulses. */ export const PULSE_THRESHOLD_PCT = 95; @@ -67,10 +70,11 @@ 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. */ @@ -174,7 +178,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 +190,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 +345,4 @@ export function timeFillWidth(barW, fraction) { export function headLabelText(label, labelW, rectW) { return label === '' || labelW > rectW ? HEAD_LABEL_FALLBACK : label; } + From a4f8011b26a0da7c10de3c38912aad43fe16a8c0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:25:37 +0700 Subject: [PATCH 2/3] fix(ui): accumulate Ctrl+wheel travel before resizing the bubble Every wheel event used to send one resize step, so a touchpad's stream of small deltas jumped straight to the smallest or largest size. Wheel travel is now normalised to px and accumulated: the first event of a gesture gives one step, then each further 100 px gives another. A 200 ms pause or a change of direction starts a new gesture, so one mouse notch still resizes by exactly one step. --- tests/js/layout.test.js | 68 +++++++++++++++++++++++++++++++++++++++++ ui/bubble.js | 15 +++++++-- ui/layout.js | 49 ++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/tests/js/layout.test.js b/tests/js/layout.test.js index b8edfeb..c2233d6 100644 --- a/tests/js/layout.test.js +++ b/tests/js/layout.test.js @@ -19,6 +19,9 @@ import { tailLayout, timeFillWidth, usageFillWidth, + WHEEL_IDLE, + WHEEL_IDLE_MS, + wheelSteps, } from '../../ui/layout.js'; /** @@ -281,3 +284,68 @@ 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('gives one step for a single notch reported as few px', () => { + assert.deepEqual(run([[-40, PIXEL, 0]]), [1]); + assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 1000]]), [1, 1]); + }); + + 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); + // The first event starts the gesture with a step; the other 290 px add two. + 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]]), [1, 1]); + assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS]]), [1, 0]); + }); + + it('starts a new gesture when the direction flips', () => { + assert.deepEqual(run([[-150, PIXEL, 0], [10, PIXEL, 10]]), [1, -1]); + }); + + 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: the gesture's first step, then one per 100 px. + const lines = run([[-3, LINE, 0], [-3, LINE, 10], [-3, LINE, 20], [-3, LINE, 30]]); + assert.deepEqual(lines, [1, 0, 0, 1]); + 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]); + }); +}); diff --git a/ui/bubble.js b/ui/bubble.js index 8074bf7..554a6ab 100644 --- a/ui/bubble.js +++ b/ui/bubble.js @@ -15,6 +15,8 @@ import { tailLayout, timeFillWidth, usageFillWidth, + WHEEL_IDLE, + wheelSteps, } from './layout.js'; import { forwardErrors, @@ -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 }, ); diff --git a/ui/layout.js b/ui/layout.js index a688377..46509cc 100644 --- a/ui/layout.js +++ b/ui/layout.js @@ -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. @@ -18,6 +18,15 @@ 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; @@ -78,6 +87,19 @@ const PULSE_STRENGTH = 0.3; * @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`. @@ -346,3 +368,28 @@ 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. The first event of a gesture always gives at least one step, so + * a single mouse notch resizes however few px the platform reports for it. + * A pause of `WHEEL_IDLE_MS` or a change of direction starts a new gesture. + * @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 ? Math.sign(px) * Math.max(Math.abs(px), WHEEL_STEP_PX) : state.acc + px; + const steps = Math.trunc(acc / WHEEL_STEP_PX); + return { state: { acc: acc - steps * WHEEL_STEP_PX, lastMs: nowMs }, steps }; +} From d583e173d23b2acd59c9ed2ec2ef32212a4a47da Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:37:38 +0700 Subject: [PATCH 3/3] fix(ui): resize on Ctrl+wheel only after 100 px of gesture travel A new gesture, including one started by a change of direction, used to send a full step at once, so a few px of reversed jitter in a touchpad pinch shrank and grew the bubble back and forth. A new gesture now starts from its own travel and steps only once that reaches 100 px. --- tests/js/layout.test.js | 29 ++++++++++++++++++++--------- ui/layout.js | 12 +++++++----- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/tests/js/layout.test.js b/tests/js/layout.test.js index c2233d6..32b1d21 100644 --- a/tests/js/layout.test.js +++ b/tests/js/layout.test.js @@ -310,9 +310,10 @@ describe('wheelSteps', () => { assert.deepEqual(run([[-100, PIXEL, 0], [-100, PIXEL, 50], [-100, PIXEL, 100]]), [1, 1, 1]); }); - it('gives one step for a single notch reported as few px', () => { - assert.deepEqual(run([[-40, PIXEL, 0]]), [1]); - assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 1000]]), [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', () => { @@ -320,18 +321,28 @@ describe('wheelSteps', () => { Array.from({ length: 30 }, (_, i) => [-10, PIXEL, i * 10]) ); const steps = run(events); - // The first event starts the gesture with a step; the other 290 px add two. 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]]), [1, 1]); - assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS]]), [1, 0]); + 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, -1]); + 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', () => { @@ -339,9 +350,9 @@ describe('wheelSteps', () => { }); it('converts line and page deltas to px', () => { - // Three lines are 48 px: the gesture's first step, then one per 100 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, [1, 0, 0, 1]); + assert.deepEqual(lines, [0, 0, 1, 0]); assert.deepEqual(run([[-1, PAGE, 0]]), [8]); }); diff --git a/ui/layout.js b/ui/layout.js index 46509cc..d475ae3 100644 --- a/ui/layout.js +++ b/ui/layout.js @@ -374,9 +374,10 @@ 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. The first event of a gesture always gives at least one step, so - * a single mouse notch resizes however few px the platform reports for it. - * A pause of `WHEEL_IDLE_MS` or a change of direction starts a new gesture. + * 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`. @@ -389,7 +390,8 @@ export function wheelSteps(state, deltaY, deltaMode, nowMs) { return { state, steps: 0 }; } const fresh = nowMs - state.lastMs > WHEEL_IDLE_MS || Math.sign(state.acc) === -Math.sign(px); - const acc = fresh ? Math.sign(px) * Math.max(Math.abs(px), WHEEL_STEP_PX) : state.acc + px; - const steps = Math.trunc(acc / WHEEL_STEP_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 }; }