mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(header): resizable toolbar divider that collapses the usage readings as it narrows
This commit is contained in:
1 parent
002de32a08
commit
fa2d75c951
9 files changed
+467
-1
No files matched your search
@@ -3680,7 +3680,9 @@ class CodemanApp {
|
||||
seg('mo', pct(usage?.monthly), false),
|
||||
].filter(Boolean);
|
||||
if (!windows.length) return '';
|
||||
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
|
||||
// data-short is what the narrowest header densities show instead of the name (header-divider.js).
|
||||
const short = { Claude: 'CL', Codex: 'CX', Copilot: 'CP' }[provider] ?? provider.slice(0, 2).toUpperCase();
|
||||
const label = labelled ? `<span data-short="${short}" class="pu-provider">${provider}</span>` : '';
|
||||
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
|
||||
};
|
||||
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @fileoverview A draggable divider on the left edge of the header's right-hand toolbar.
|
||||
*
|
||||
* Dragging it left gives the toolbar (stats, plan usage, buttons) more room; dragging it right narrows the
|
||||
* toolbar, which then wraps onto a second line instead of squeezing the session tabs out. The width is a
|
||||
* device-local convenience kept in localStorage; double-click or Home resets it. The handle is absolutely
|
||||
* positioned inside #headerRight, so it takes no layout room and is simply hidden where the tabs are not in
|
||||
* the header (sidebar, vertical rail, phones).
|
||||
*
|
||||
* @loadorder after app.js; self-contained, no CodemanApp dependency.
|
||||
*/
|
||||
(function () {
|
||||
const STORAGE_KEY = 'codeman-header-right-width';
|
||||
const MIN_WIDTH = 160;
|
||||
const MIN_TABS_ROOM = 240;
|
||||
const KEY_STEP = 16;
|
||||
|
||||
/** Keep a requested toolbar width between the minimum and what leaves the tabs some room. */
|
||||
function clampWidth(width, headerWidth, brandWidth) {
|
||||
const max = Math.max(MIN_WIDTH, headerWidth - brandWidth - MIN_TABS_ROOM);
|
||||
return Math.round(Math.min(max, Math.max(MIN_WIDTH, width)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of
|
||||
* the labels go: window labels first, then provider names (shortened), then the percentages (the rings
|
||||
* stay, and the chip's tooltip keeps the numbers).
|
||||
*/
|
||||
function densityFor(width) {
|
||||
if (width >= 560) return '';
|
||||
if (width >= 400) return 'tight';
|
||||
if (width >= 280) return 'dense';
|
||||
return 'tiny';
|
||||
}
|
||||
|
||||
function readStored() {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(STORAGE_KEY));
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(width) {
|
||||
try {
|
||||
if (width === null) localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, String(width));
|
||||
} catch {
|
||||
/* storage blocked: the width lasts until the page reloads */
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const header = document.querySelector('.header');
|
||||
const right = document.getElementById('headerRight');
|
||||
if (!header || !right || right.querySelector('.header-divider')) return;
|
||||
const brand = header.querySelector('.header-brand');
|
||||
const root = document.documentElement;
|
||||
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'header-divider';
|
||||
handle.setAttribute('role', 'separator');
|
||||
handle.setAttribute('aria-orientation', 'vertical');
|
||||
handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)');
|
||||
handle.tabIndex = 0;
|
||||
right.insertBefore(handle, right.firstChild);
|
||||
|
||||
const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 });
|
||||
const current = () => right.getBoundingClientRect().width;
|
||||
|
||||
/** The narrowest the toolbar can be without a reading poking out past its left edge (the divider). */
|
||||
function contentMinimum() {
|
||||
const style = getComputedStyle(right);
|
||||
const frame = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth);
|
||||
let widest = 0;
|
||||
for (const child of right.children) {
|
||||
if (child === handle || child.offsetParent === null) continue;
|
||||
widest = Math.max(widest, child.getBoundingClientRect().width, child.scrollWidth);
|
||||
}
|
||||
return Math.ceil(widest + frame);
|
||||
}
|
||||
|
||||
function setDensity(width) {
|
||||
const density = densityFor(width);
|
||||
if (density) root.setAttribute('data-header-density', density);
|
||||
else root.removeAttribute('data-header-density');
|
||||
}
|
||||
|
||||
function apply(width, persist) {
|
||||
if (width === null) {
|
||||
root.style.removeProperty('--header-right-width');
|
||||
root.removeAttribute('data-header-right-width');
|
||||
root.removeAttribute('data-header-density');
|
||||
handle.removeAttribute('aria-valuenow');
|
||||
if (persist) writeStored(null);
|
||||
return;
|
||||
}
|
||||
const { header: h, brand: b } = bounds();
|
||||
let clamped = clampWidth(width, h, b);
|
||||
root.setAttribute('data-header-right-width', 'set');
|
||||
// A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum: settle
|
||||
// on a width no reading overflows at the density that width gets.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
root.style.setProperty('--header-right-width', clamped + 'px');
|
||||
setDensity(clamped);
|
||||
const floor = contentMinimum();
|
||||
if (clamped >= floor) break;
|
||||
clamped = floor;
|
||||
}
|
||||
handle.setAttribute('aria-valuenow', String(clamped));
|
||||
if (persist) writeStored(clamped);
|
||||
}
|
||||
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
document.body.classList.add('header-divider-dragging');
|
||||
const onMove = (e) => apply(header.getBoundingClientRect().right - e.clientX - 12, false);
|
||||
const onUp = () => {
|
||||
handle.removeEventListener('pointermove', onMove);
|
||||
handle.removeEventListener('pointerup', onUp);
|
||||
handle.removeEventListener('pointercancel', onUp);
|
||||
document.body.classList.remove('header-divider-dragging');
|
||||
const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width'));
|
||||
if (Number.isFinite(width)) writeStored(width);
|
||||
};
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', onUp);
|
||||
handle.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
|
||||
handle.addEventListener('dblclick', () => apply(null, true));
|
||||
|
||||
handle.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Home' || event.key === 'Escape') {
|
||||
apply(null, true);
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
const step = event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP;
|
||||
apply(current() + step, true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width'));
|
||||
if (Number.isFinite(width)) apply(width, false);
|
||||
});
|
||||
|
||||
const stored = readStored();
|
||||
if (stored !== null) apply(stored, false);
|
||||
}
|
||||
|
||||
window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM };
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
@@ -4345,6 +4345,7 @@
|
||||
<script defer src="tab-layout-browser.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="header-divider.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="terminal-tile.js"></script>
|
||||
<script defer src="terminal-split.js"></script>
|
||||
|
||||
@@ -2249,6 +2249,97 @@ html[data-tab-orientation='vertical'].tab-rail-compact
|
||||
margin-left: auto;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 1px solid var(--glass-border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Draggable edge of the toolbar (header-divider.js). Absolute, so it takes no layout room and the
|
||||
existing border-left stays the visible line. */
|
||||
.header-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -6px;
|
||||
width: 12px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.header-divider:hover,
|
||||
.header-divider:focus-visible {
|
||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-divider:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
/* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */
|
||||
html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar'])
|
||||
body:not(.solo-mode)
|
||||
.header-right {
|
||||
flex: 0 0 var(--header-right-width);
|
||||
width: var(--header-right-width);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-content: center;
|
||||
row-gap: 0.25rem;
|
||||
min-height: var(--header-height);
|
||||
}
|
||||
|
||||
/* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js):
|
||||
tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings.
|
||||
The same rules apply under every header-stats style. */
|
||||
html[data-header-density] .header-system-stats .stat-label,
|
||||
html[data-header-density] .header-plan-usage .pu-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-density] .header-system-stats,
|
||||
html[data-header-density] .header-plan-usage {
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
html[data-header-density='dense'] .header-plan-usage .pu-provider,
|
||||
html[data-header-density='tiny'] .header-plan-usage .pu-provider {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
html[data-header-density='dense'] .header-plan-usage .pu-provider::after,
|
||||
html[data-header-density='tiny'] .header-plan-usage .pu-provider::after {
|
||||
content: attr(data-short);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
html[data-header-density='tiny'] .header-system-stats .stat-value,
|
||||
html[data-header-density='tiny'] .header-plan-usage .pu-val {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.header-divider-dragging,
|
||||
body.header-divider-dragging * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* Only where the tabs share the header with the toolbar. */
|
||||
html[data-tab-orientation='vertical'] .header-divider,
|
||||
html[data-session-list='sidebar'] .header-divider,
|
||||
body.solo-mode .header-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-right-width] .header-right {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Connection Indicator */
|
||||
|
||||
Reference in new issue
Block a user