mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 22:16:30 +00:00
feat: draw the details panel from the payload and suppress webview shortcuts
This commit is contained in:
1 parent
768fb1e4ab
commit
2180efaf85
5 files changed
+355
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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'));
|
||||
Reference in new issue
Block a user