Merge branch 'worktree-wf_093aad93-ad0-6' into review-fixes

This commit is contained in:
tiennm99 committed 2026-10-23 03:41:26 +07:00
commit 70e121c3c3
4 files changed
+168 -9

No files matched your search

+95
View File
@@ -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]);
});
});
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}