diff --git a/tests/js/panel-view.test.js b/tests/js/panel-view.test.js new file mode 100644 index 0000000..6379fa2 --- /dev/null +++ b/tests/js/panel-view.test.js @@ -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); + }); +}); diff --git a/ui/panel-view.js b/ui/panel-view.js new file mode 100644 index 0000000..a4dba72 --- /dev/null +++ b/ui/panel-view.js @@ -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()); +} diff --git a/ui/panel.css b/ui/panel.css new file mode 100644 index 0000000..0cedde0 --- /dev/null +++ b/ui/panel.css @@ -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; +} diff --git a/ui/panel.html b/ui/panel.html new file mode 100644 index 0000000..7ecdad5 --- /dev/null +++ b/ui/panel.html @@ -0,0 +1,23 @@ + + + + + Usage panel + + + + +
+ +
+
+
+
+
+
+
+
+
+
+ + diff --git a/ui/panel.js b/ui/panel.js new file mode 100644 index 0000000..edd8240 --- /dev/null +++ b/ui/panel.js @@ -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'));