feat: draw the details panel from the payload and suppress webview shortcuts

This commit is contained in:
tiennm99 committed 2026-10-21 15:29:26 +07:00
1 parent 768fb1e4ab
commit 2180efaf85
5 files changed
+355

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
// Details panel helpers: bar fill widths as the GDI panel computed them and
// the browser shortcuts the panel suppresses.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { isBrowserAccelerator, panelFillWidth } from '../../ui/panel-view.js';
/**
* A key event with no modifiers unless given.
* @param {string} key Key value.
* @param {{ctrlKey?: boolean, metaKey?: boolean}} [mods] Modifiers.
* @returns {import('../../ui/panel-view.js').KeyLike} Key event stand-in.
*/
const key = (key, mods = {}) => ({ key, ctrlKey: false, metaKey: false, ...mods });
describe('panelFillWidth', () => {
it('rounds the clamped percent of the 120 px bar', () => {
assert.equal(panelFillWidth(0), 0);
assert.equal(panelFillWidth(50), 60);
assert.equal(panelFillWidth(100), 120);
assert.equal(panelFillWidth(72.5), 87);
assert.equal(panelFillWidth(0.4), 0);
assert.equal(panelFillWidth(0.5), 1);
});
it('clamps out-of-range and non-finite values', () => {
assert.equal(panelFillWidth(-5), 0);
assert.equal(panelFillWidth(150), 120);
assert.equal(panelFillWidth(Number.NaN), 0);
assert.equal(panelFillWidth(Number.POSITIVE_INFINITY), 0);
});
});
describe('isBrowserAccelerator', () => {
it('catches the function keys on their own', () => {
for (const k of ['F5', 'F7', 'F12']) {
assert.equal(isBrowserAccelerator(key(k)), true, k);
}
assert.equal(isBrowserAccelerator(key('F1')), false);
});
it('catches Ctrl or Cmd with a browser command key, in either case', () => {
for (const k of ['r', 'R', 'p', 'f', 'g', 's', 'u', 'o', '+', '=', '-', '0']) {
assert.equal(isBrowserAccelerator(key(k, { ctrlKey: true })), true, `Ctrl+${k}`);
assert.equal(isBrowserAccelerator(key(k, { metaKey: true })), true, `Cmd+${k}`);
}
});
it('lets other keys through', () => {
assert.equal(isBrowserAccelerator(key('r')), false);
assert.equal(isBrowserAccelerator(key('a', { ctrlKey: true })), false);
assert.equal(isBrowserAccelerator(key('c', { ctrlKey: true })), false);
assert.equal(isBrowserAccelerator(key('Escape')), false);
});
});
+44
View File
@@ -0,0 +1,44 @@
// Pure helpers for the details panel page, kept free of `window.__TAURI__`
// so they run under `node --test`.
/** Width of a panel usage bar in CSS px. */
export const PANEL_BAR_WIDTH = 120;
/**
* Width of a panel bar fill: the clamped percent of the bar, rounded to
* whole px, as the GDI panel drew it. The bar is square, so 1% stays 1 px.
* @param {number} pct Usage percent; anything not finite counts as 0.
* @returns {number} Fill width in CSS px, 0..120.
*/
export function panelFillWidth(pct) {
const value = Number.isFinite(pct) ? Math.min(Math.max(pct, 0), 100) : 0;
return Math.round((value / 100) * PANEL_BAR_WIDTH);
}
/** Keys that act as browser commands with Ctrl (Cmd on macOS) held. */
const CTRL_ACCELERATORS = new Set(['r', 'p', 'f', 'g', 's', 'u', 'o', '+', '=', '-', '_', '0']);
/** Keys that act as browser commands on their own. */
const PLAIN_ACCELERATORS = new Set(['F5', 'F7', 'F12']);
/**
* The parts of a `KeyboardEvent` the accelerator check reads.
* @typedef {object} KeyLike
* @property {string} key Key value.
* @property {boolean} ctrlKey Ctrl held.
* @property {boolean} metaKey Cmd (Meta) held.
*/
/**
* Whether a key press would run a browser command (reload, print, find,
* save, view source, open, zoom, caret browsing, dev tools). The panel
* ignores all input, so these are suppressed.
* @param {KeyLike} event Key event.
* @returns {boolean} True for a browser accelerator.
*/
export function isBrowserAccelerator(event) {
if (PLAIN_ACCELERATORS.has(event.key)) {
return true;
}
return (event.ctrlKey || event.metaKey) && CTRL_ACCELERATORS.has(event.key.toLowerCase());
}
+100
View File
@@ -0,0 +1,100 @@
/*
* Details panel, 280x120 logical px, laid out on the GDI panel's exact
* coordinates. The webview zoom scales it to the monitor DPI. Accent and
* fill colours come from the payload. No transitions, no pulse.
*/
:root[data-theme='dark'] {
--bg: #1f1f1f;
--text: #eaeaea;
--track: #3a3a3a;
}
:root[data-theme='light'] {
--bg: #fafafa;
--text: #1f1f1f;
--track: #d6d6d6;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.stripe {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
}
/* One line, vertically centred, clipped at the box edge without an ellipsis. */
.text {
position: absolute;
height: 18px;
overflow: hidden;
font-size: 11px;
font-weight: 400;
line-height: 18px;
white-space: pre;
text-overflow: clip;
}
.header {
left: 14px;
top: 14px;
width: 252px;
font-size: 13px;
font-weight: 600;
}
.row {
position: absolute;
left: 0;
width: 100%;
height: 14px;
}
.row.session {
top: 38px;
}
.row.weekly {
top: 68px;
}
.row-label {
left: 14px;
top: -2px;
width: 28px;
}
.bar {
position: absolute;
left: 46px;
top: 0;
width: 120px;
height: 14px;
overflow: hidden;
background: var(--track);
}
.fill {
width: 0;
height: 100%;
}
.row-text {
left: 174px;
top: -2px;
width: 96px;
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<title>Usage panel</title>
<link rel="stylesheet" href="panel.css">
<script type="module" src="panel.js"></script>
</head>
<body>
<div class="stripe" id="stripe"></div>
<div class="text header" id="header"></div>
<div class="row session">
<div class="text row-label" id="session-label"></div>
<div class="bar"><div class="fill" id="session-fill"></div></div>
<div class="text row-text" id="session-text"></div>
</div>
<div class="row weekly">
<div class="text row-label" id="weekly-label"></div>
<div class="bar"><div class="fill" id="weekly-fill"></div></div>
<div class="text row-text" id="weekly-text"></div>
</div>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
// The details panel page: draws the payload Rust sends. Rust formats every
// displayed string and picks every colour; this file only places them.
// The panel takes no input, so the webview's own shortcuts are suppressed.
import { isBrowserAccelerator, panelFillWidth } from './panel-view.js';
import { forwardErrors, invoke, listen, logFailure } from './tauri-bridge.js';
/**
* The `panel:update` payload and `get_panel_state` result.
* @typedef {object} PanelPayload
* @property {string} provider Provider slug.
* @property {string} label Header text.
* @property {boolean} isDark Dark palette.
* @property {string} accent Stripe colour.
* @property {number} sessionPct Session usage percent.
* @property {number} weeklyPct Weekly usage percent.
* @property {string} sessionText Session row text.
* @property {string} weeklyText Weekly row text.
* @property {string} sessionFill Session bar colour.
* @property {string} weeklyFill Weekly bar colour.
*/
/**
* The strings this page reads from `get_strings`.
* @typedef {object} PanelStrings
* @property {string} sessionWindow Session row label.
* @property {string} weeklyWindow Weekly row label.
*/
/**
* Look up a required element.
* @param {string} id Element id.
* @returns {HTMLElement} The element.
*/
function byId(id) {
const element = document.getElementById(id);
if (!element) {
throw new Error(`panel.html is missing #${id}`);
}
return element;
}
const stripe = byId('stripe');
const header = byId('header');
const sessionLabel = byId('session-label');
const sessionFill = byId('session-fill');
const sessionText = byId('session-text');
const weeklyLabel = byId('weekly-label');
const weeklyFill = byId('weekly-fill');
const weeklyText = byId('weekly-text');
/** Set once a pushed payload arrived, so a late initial state cannot win. */
let updated = false;
/**
* Switch the palette.
* @param {boolean} isDark Dark palette.
* @returns {void}
*/
function setTheme(isDark) {
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
}
/**
* Draw one bar.
* @param {HTMLElement} fill Fill element.
* @param {number} pct Usage percent.
* @param {string} color Fill colour.
* @returns {void}
*/
function drawBar(fill, pct, color) {
fill.style.width = `${panelFillWidth(pct)}px`;
fill.style.background = color;
}
/**
* Draw a payload.
* @param {PanelPayload} payload Panel state.
* @returns {void}
*/
function render(payload) {
setTheme(payload.isDark);
stripe.style.background = payload.accent;
header.textContent = payload.label;
drawBar(sessionFill, payload.sessionPct, payload.sessionFill);
drawBar(weeklyFill, payload.weeklyPct, payload.weeklyFill);
sessionText.textContent = payload.sessionText;
weeklyText.textContent = payload.weeklyText;
}
forwardErrors();
window.addEventListener('contextmenu', (event) => event.preventDefault());
window.addEventListener(
'wheel',
(event) => {
if (event.ctrlKey) {
event.preventDefault();
}
},
{ passive: false },
);
window.addEventListener('keydown', (event) => {
if (isBrowserAccelerator(event)) {
event.preventDefault();
}
});
listen('panel:update', (/** @type {PanelPayload} */ payload) => {
updated = true;
render(payload);
}).catch(logFailure('listen panel:update'));
listen('theme', (/** @type {{isDark: boolean}} */ payload) => {
setTheme(payload.isDark);
}).catch(logFailure('listen theme'));
invoke('get_strings')
.then((strings) => {
const { sessionWindow, weeklyWindow } = /** @type {PanelStrings} */ (strings);
sessionLabel.textContent = sessionWindow;
weeklyLabel.textContent = weeklyWindow;
})
.catch(logFailure('get_strings'));
invoke('get_panel_state')
.then((state) => {
if (state && !updated) {
render(/** @type {PanelPayload} */ (state));
}
})
.catch(logFailure('get_panel_state'));