mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-01 12:01:05 +00:00
feat(windows): Capacity Dock, an edge-docked quota rail
A slim always-on-top rail on the right screen edge showing one capacity ring per signed-in provider, the Windows twin of the macOS Capacity Dock. The window is created on demand and closed when the dock is switched off, so a disabled dock leaves no webview resident. Hovering widens the same window to reveal a detail panel with each quota window, its used percent, plan and reset countdown; the panel unmounts on collapse rather than lingering hidden. Data comes from `codeburn quota --format json` through the existing cli.rs spawn plumbing. A CLI without that subcommand exits with `unknown command`, which the rail reports as a quiet update-needed state instead of an error. Metrics are the mac dock's whole-point values at its default 0.6 scale.
This commit is contained in:
parent
bbc0e222c8
commit
672196bb8f
7 changed files with 573 additions and 5 deletions
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the CodeBurn tray app window",
|
||||
"windows": ["popover"],
|
||||
"windows": ["popover", "dock"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const MAX_PAYLOAD_BYTES: usize = 20 * 1024 * 1024;
|
|||
const MAX_STDERR_BYTES: usize = 256 * 1024;
|
||||
const FETCH_TIMEOUT_SECS: u64 = 60;
|
||||
const VERSION_TIMEOUT_SECS: u64 = 20;
|
||||
const QUOTA_TIMEOUT_SECS: u64 = 45;
|
||||
|
||||
/// Oldest CLI this app can talk to. 0.9.9 is the first release whose
|
||||
/// `status --format menubar-json` accepts `--no-optimize`, which every quiet background
|
||||
|
|
@ -62,6 +63,15 @@ pub struct CliStatus {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// What the Capacity Dock renders: the provider array, or the reason there isn't one.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum DockQuota {
|
||||
Ready { providers: Value },
|
||||
CliOutdated,
|
||||
Unavailable { message: String },
|
||||
}
|
||||
|
||||
impl CodeburnCli {
|
||||
/// Honours `CODEBURN_BIN` only when every whitespace-delimited token passes the
|
||||
/// allowlist. Otherwise resolves `codeburn` from PATH and the usual npm locations.
|
||||
|
|
@ -163,6 +173,38 @@ impl CodeburnCli {
|
|||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Spawns `codeburn quota --format json` for the Capacity Dock. A CLI without the
|
||||
/// subcommand exits 1 with `unknown command`, which the dock reports as a quiet
|
||||
/// "CLI update needed" state rather than an error.
|
||||
pub async fn fetch_quota(&self) -> DockQuota {
|
||||
let stdout = match self
|
||||
.run_capture(&["quota", "--format", "json"], QUOTA_TIMEOUT_SECS)
|
||||
.await
|
||||
{
|
||||
Ok(stdout) => stdout,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
return if message.contains("unknown command") {
|
||||
DockQuota::CliOutdated
|
||||
} else {
|
||||
DockQuota::Unavailable { message }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str::<Value>(&stdout) {
|
||||
Ok(payload) => DockQuota::Ready {
|
||||
providers: payload
|
||||
.get("providers")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::Array(vec![])),
|
||||
},
|
||||
Err(err) => DockQuota::Unavailable {
|
||||
message: format!("CLI returned invalid JSON: {err}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_capture(&self, args: &[&str], timeout_secs: u64) -> Result<String> {
|
||||
let mut full_args = self.extra_args.clone();
|
||||
full_args.extend(args.iter().map(|s| s.to_string()));
|
||||
|
|
|
|||
111
windows/src-tauri/src/dock.rs
Normal file
111
windows/src-tauri/src/dock.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! The Capacity Dock: a slim quota rail docked to the right screen edge, the Windows twin of
|
||||
//! the macOS Capacity Dock. The window is created on demand and closed again when the dock is
|
||||
//! switched off, so a disabled dock leaves no webview resident.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
pub const DOCK_LABEL: &str = "dock";
|
||||
|
||||
/// Whole-point metrics at the macOS dock's default 0.6 scale: its 88pt rail lands on 53, which
|
||||
/// `layout.activityBarWidth` in tokens.json already rounds to 56 for this frontend. Fractional
|
||||
/// sizes cost the mac app 5-7% idle CPU by making the hosting view re-lay itself out forever,
|
||||
/// so every value here stays an integer.
|
||||
const RAIL_WIDTH: f64 = 56.0;
|
||||
const ROW_HEIGHT: f64 = 50.0;
|
||||
const ROW_SPACING: f64 = 7.0;
|
||||
const RAIL_PAD: f64 = 12.0;
|
||||
const DETAIL_WIDTH: f64 = 300.0;
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config/codeburn"))
|
||||
.unwrap_or_else(|| PathBuf::from(".codeburn"))
|
||||
.join("windows-dock.json")
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
fs::read(state_path())
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||
.and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn set_enabled(enabled: bool) -> Result<()> {
|
||||
let path = state_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let body = serde_json::to_vec_pretty(&serde_json::json!({ "enabled": enabled }))?;
|
||||
fs::write(&path, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn logical_height(rows: u32) -> f64 {
|
||||
let rows = rows.max(1) as f64;
|
||||
RAIL_PAD * 2.0 + rows * ROW_HEIGHT + (rows - 1.0) * ROW_SPACING
|
||||
}
|
||||
|
||||
/// Right edge, vertically centred, sized to the provider count. Expanding keeps the right edge
|
||||
/// flush and grows leftwards, so the rail never moves under the pointer.
|
||||
pub fn apply_layout(window: &tauri::WebviewWindow, rows: u32, expanded: bool) {
|
||||
let Ok(Some(monitor)) = window.primary_monitor() else {
|
||||
return;
|
||||
};
|
||||
let scale = monitor.scale_factor();
|
||||
let width = if expanded {
|
||||
RAIL_WIDTH + DETAIL_WIDTH
|
||||
} else {
|
||||
RAIL_WIDTH
|
||||
};
|
||||
let w = (width * scale).round() as i32;
|
||||
let h = (logical_height(rows) * scale).round() as i32;
|
||||
|
||||
let area = monitor.work_area();
|
||||
let x = area.position.x + area.size.width as i32 - w;
|
||||
let y = area.position.y + ((area.size.height as i32 - h) / 2).max(0);
|
||||
|
||||
let _ = window.set_size(tauri::PhysicalSize::new(w.max(1) as u32, h.max(1) as u32));
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
|
||||
}
|
||||
|
||||
pub fn show(app: &AppHandle) -> tauri::Result<()> {
|
||||
if let Some(existing) = app.get_webview_window(DOCK_LABEL) {
|
||||
existing.show()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let builder = WebviewWindowBuilder::new(app, DOCK_LABEL, WebviewUrl::default())
|
||||
.title("CodeBurn Capacity Dock")
|
||||
.inner_size(RAIL_WIDTH, logical_height(1))
|
||||
.decorations(false)
|
||||
.resizable(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.focused(false)
|
||||
.shadow(false)
|
||||
.visible(false);
|
||||
|
||||
// The page draws its own rounded card, so the window behind it has to be see-through.
|
||||
// `transparent` is only compiled in off macOS, where it needs the private-API feature.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let builder = builder.transparent(true);
|
||||
|
||||
let window = builder.build()?;
|
||||
|
||||
apply_layout(&window, 1, false);
|
||||
window.show()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closing rather than hiding is deliberate: a hidden webview keeps rendering, which is what
|
||||
/// cost the macOS popover 6% idle CPU.
|
||||
pub fn hide(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(DOCK_LABEL) {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod autostart;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod dock;
|
||||
mod fx;
|
||||
mod plan;
|
||||
/// The spend-in-the-tray badge is a second tray icon, which only the Tauri tray backend
|
||||
|
|
@ -22,10 +23,13 @@ use tauri::Listener;
|
|||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
static DOCK_MENU_ITEM: std::sync::OnceLock<CheckMenuItem<tauri::Wry>> = std::sync::OnceLock::new();
|
||||
|
||||
use crate::cli::CodeburnCli;
|
||||
use crate::config::CurrencyConfig;
|
||||
use crate::fx::FxCache;
|
||||
|
|
@ -78,9 +82,18 @@ pub fn run() {
|
|||
round_window_corners(&window);
|
||||
}
|
||||
|
||||
if dock::is_enabled() {
|
||||
let _ = dock::show(app.handle());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// The dock is a persistent rail: it owns its own lifecycle and must survive both
|
||||
// the hide-on-blur and the hide-on-close that keep the popover alive.
|
||||
if window.label() == dock::DOCK_LABEL {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
|
|
@ -108,6 +121,8 @@ pub fn run() {
|
|||
commands::plan_usage,
|
||||
commands::launch_at_login,
|
||||
commands::set_launch_at_login,
|
||||
commands::dock_quota,
|
||||
commands::dock_set_layout,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
|
|
@ -132,6 +147,14 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
|
|||
let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?;
|
||||
let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?;
|
||||
let theme = MenuItem::with_id(app, "toggle_theme", "Toggle Dark/Light", true, None::<&str>)?;
|
||||
let capacity_dock = CheckMenuItem::with_id(
|
||||
app,
|
||||
"toggle_dock",
|
||||
"Show Capacity Dock",
|
||||
true,
|
||||
dock::is_enabled(),
|
||||
None::<&str>,
|
||||
)?;
|
||||
let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(
|
||||
|
|
@ -140,11 +163,15 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
|
|||
&open,
|
||||
&refresh,
|
||||
&theme,
|
||||
&capacity_dock,
|
||||
&report,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
// Both tray icons share one menu, so the handler needs the item itself to keep the
|
||||
// checkmark in step with the persisted state.
|
||||
let _ = DOCK_MENU_ITEM.set(capacity_dock);
|
||||
|
||||
tray.set_menu(Some(menu.clone()))?;
|
||||
tray.set_show_menu_on_left_click(false)?;
|
||||
|
|
@ -190,6 +217,7 @@ fn on_tray_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
|||
let _ = window.emit("codeburn://toggle-theme", ());
|
||||
}
|
||||
}
|
||||
"toggle_dock" => set_dock_enabled(app, !dock::is_enabled()),
|
||||
"report" => {
|
||||
let _ = cli::spawn_in_terminal(app, &["report"]);
|
||||
}
|
||||
|
|
@ -290,6 +318,24 @@ fn mark_hidden(app: &AppHandle) {
|
|||
let _ = app.emit("codeburn://hidden", ());
|
||||
}
|
||||
|
||||
/// Persists the dock preference, then brings the window in line with it. The checkmark is only
|
||||
/// moved once the state is stored, so a failed write cannot leave the menu lying.
|
||||
fn set_dock_enabled(app: &AppHandle, enabled: bool) {
|
||||
if let Err(err) = dock::set_enabled(enabled) {
|
||||
eprintln!("codeburn: failed to persist the Capacity Dock setting: {err}");
|
||||
return;
|
||||
}
|
||||
if enabled {
|
||||
let _ = dock::show(app);
|
||||
} else {
|
||||
dock::hide(app);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if let Some(item) = DOCK_MENU_ITEM.get() {
|
||||
let _ = item.set_checked(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
|
||||
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
|
||||
return;
|
||||
|
|
@ -526,4 +572,19 @@ mod commands {
|
|||
pub async fn plan_usage(state: State<'_, AppState>) -> Result<crate::plan::PlanUsage, String> {
|
||||
state.plan.fetch().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dock_quota(state: State<'_, AppState>) -> Result<crate::cli::DockQuota, String> {
|
||||
let cli = state.cli.lock().map_err(|e| e.to_string())?.clone();
|
||||
Ok(cli.fetch_quota().await)
|
||||
}
|
||||
|
||||
/// The rail only knows its own provider count and hover state; the geometry that turns
|
||||
/// those into a window lives in `dock`.
|
||||
#[tauri::command]
|
||||
pub fn dock_set_layout(app: AppHandle, rows: u32, expanded: bool) {
|
||||
if let Some(window) = app.get_webview_window(crate::dock::DOCK_LABEL) {
|
||||
crate::dock::apply_layout(&window, rows, expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
181
windows/src/Dock.tsx
Normal file
181
windows/src/Dock.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import './dock.css'
|
||||
|
||||
const REFRESH_MS = 5 * 60 * 1000
|
||||
const HOVER_CLOSE_MS = 180
|
||||
|
||||
type QuotaWindow = {
|
||||
label: string
|
||||
usedPct: number
|
||||
resetsAt?: string
|
||||
}
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
name: string
|
||||
available: boolean
|
||||
plan?: string
|
||||
windows: QuotaWindow[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
type DockQuota =
|
||||
| { state: 'ready'; providers: Provider[] }
|
||||
| { state: 'cliOutdated' }
|
||||
| { state: 'unavailable'; message: string }
|
||||
|
||||
const RING_SIZE = 32
|
||||
const RING_STROKE = 3
|
||||
const RADIUS = (RING_SIZE - RING_STROKE) / 2
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
|
||||
function headline(provider: Provider): QuotaWindow | null {
|
||||
if (provider.windows.length === 0) return null
|
||||
return provider.windows.reduce((worst, row) => (row.usedPct > worst.usedPct ? row : worst))
|
||||
}
|
||||
|
||||
// Whole percentage points, matching the mac dock's rounded metrics.
|
||||
function pct(value: number): number {
|
||||
return Math.min(100, Math.max(0, Math.round(value)))
|
||||
}
|
||||
|
||||
function resetsIn(iso?: string): string {
|
||||
if (!iso) return ''
|
||||
const seconds = (new Date(iso).getTime() - Date.now()) / 1000
|
||||
if (Number.isNaN(seconds)) return ''
|
||||
if (seconds < 60) return 'now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days > 0) return `${days}d ${hours % 24}h`
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
function Ring({ provider }: { provider: Provider }) {
|
||||
const top = headline(provider)
|
||||
const used = top ? pct(top.usedPct) : 0
|
||||
return (
|
||||
<div className="dock-row" data-provider={provider.id} title={provider.name}>
|
||||
<svg className="dock-ring" width={RING_SIZE} height={RING_SIZE} viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}>
|
||||
<circle
|
||||
className="dock-ring-track"
|
||||
cx={RING_SIZE / 2}
|
||||
cy={RING_SIZE / 2}
|
||||
r={RADIUS}
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
<circle
|
||||
className="dock-ring-arc"
|
||||
cx={RING_SIZE / 2}
|
||||
cy={RING_SIZE / 2}
|
||||
r={RADIUS}
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={CIRCUMFERENCE * (1 - used / 100)}
|
||||
/>
|
||||
</svg>
|
||||
<span className={`dock-pct${used >= 90 ? ' is-spent' : ''}`}>{top ? `${used}%` : '--'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function notice(quota: DockQuota | null, providers: Provider[]): string | null {
|
||||
if (quota?.state === 'cliOutdated') return 'CLI update needed for live quota. Run npm install -g codeburn.'
|
||||
if (quota?.state === 'unavailable') return 'Quota is unavailable right now.'
|
||||
if (quota?.state === 'ready' && providers.length === 0) return 'No signed in providers to show.'
|
||||
return null
|
||||
}
|
||||
|
||||
function Detail({ providers, message }: { providers: Provider[]; message: string | null }) {
|
||||
return (
|
||||
<div className="dock-detail">
|
||||
{message ? <p className="dock-notice">{message}</p> : null}
|
||||
{providers.map((provider) => (
|
||||
<section className="dock-detail-card" key={provider.id} data-provider={provider.id}>
|
||||
<header className="dock-detail-head">
|
||||
<span className="dock-detail-name">{provider.name}</span>
|
||||
{provider.plan ? <span className="dock-detail-plan">{provider.plan}</span> : null}
|
||||
</header>
|
||||
{provider.windows.map((row) => {
|
||||
const used = pct(row.usedPct)
|
||||
const resets = resetsIn(row.resetsAt)
|
||||
return (
|
||||
<div className="dock-window" key={`${provider.id}-${row.label}`}>
|
||||
<div className="dock-window-top">
|
||||
<span className="dock-window-label">{row.label}</span>
|
||||
<span className="dock-window-pct">{used}%</span>
|
||||
</div>
|
||||
<div className="dock-track">
|
||||
<div className="dock-fill" style={{ width: `${used}%` }} />
|
||||
</div>
|
||||
{resets ? <span className="dock-window-resets">Resets in {resets}</span> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Dock() {
|
||||
const [quota, setQuota] = useState<DockQuota | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const closeTimer = useRef<number | undefined>(undefined)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setQuota(await invoke<DockQuota>('dock_quota'))
|
||||
} catch (err) {
|
||||
setQuota({ state: 'unavailable', message: String(err) })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const timer = window.setInterval(() => void load(), REFRESH_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [load])
|
||||
|
||||
const providers =
|
||||
quota?.state === 'ready' ? quota.providers.filter((provider) => provider.available) : []
|
||||
const rows = Math.max(providers.length, 1)
|
||||
|
||||
useEffect(() => {
|
||||
void invoke('dock_set_layout', { rows, expanded })
|
||||
}, [rows, expanded])
|
||||
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), [])
|
||||
|
||||
const message = notice(quota, providers)
|
||||
|
||||
const open = () => {
|
||||
window.clearTimeout(closeTimer.current)
|
||||
if (providers.length > 0 || message) setExpanded(true)
|
||||
}
|
||||
|
||||
// A short grace period keeps the panel from flickering shut as the pointer crosses the
|
||||
// seam between the rail and the panel.
|
||||
const close = () => {
|
||||
window.clearTimeout(closeTimer.current)
|
||||
closeTimer.current = window.setTimeout(() => setExpanded(false), HOVER_CLOSE_MS)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`dock${expanded ? ' is-expanded' : ''}`} onMouseEnter={open} onMouseLeave={close}>
|
||||
{expanded ? <Detail providers={providers} message={message} /> : null}
|
||||
<div className="dock-rail" onDoubleClick={() => void load()}>
|
||||
{providers.map((provider) => (
|
||||
<Ring provider={provider} key={provider.id} />
|
||||
))}
|
||||
{providers.length === 0 ? (
|
||||
<div className="dock-row">
|
||||
<span className="dock-quiet">{quota?.state === 'cliOutdated' ? 'CLI' : '--'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
windows/src/dock.css
Normal file
170
windows/src/dock.css
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/* Capacity Dock rail. Metrics are the macOS dock's whole-point values at its default 0.6
|
||||
scale (mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift); the window that holds
|
||||
them is sized in src-tauri/src/dock.rs and the two must stay in step. */
|
||||
|
||||
:root {
|
||||
--dock-rail-w: 56px;
|
||||
--dock-row-h: 50px;
|
||||
--dock-row-gap: 7px;
|
||||
--dock-pad: 12px;
|
||||
--dock-detail-w: 300px;
|
||||
--dock-radius: 10px;
|
||||
|
||||
/* claude, codex and copilot are tokens.json color.categorical; the remaining three keep
|
||||
the ring colours the macOS Capacity Dock already gives them. */
|
||||
--dock-claude: #C9521D;
|
||||
--dock-codex: #5C7CA3;
|
||||
--dock-copilot: #B8944C;
|
||||
--dock-gemini: #478CFA;
|
||||
--dock-kimi: #E6F014;
|
||||
--dock-antigravity: #FF7A45;
|
||||
}
|
||||
|
||||
.dock {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.dock-rail {
|
||||
flex: 0 0 var(--dock-rail-w);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--dock-row-gap);
|
||||
padding: var(--dock-pad) 0;
|
||||
background: var(--surface);
|
||||
border-radius: var(--dock-radius) 0 0 var(--dock-radius);
|
||||
border: 1px solid var(--separator);
|
||||
border-right: none;
|
||||
box-shadow: var(--menu-shadow);
|
||||
}
|
||||
|
||||
.dock-row {
|
||||
height: var(--dock-row-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dock-ring {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.dock-ring-track {
|
||||
fill: none;
|
||||
stroke: var(--fill-12);
|
||||
}
|
||||
|
||||
.dock-ring-arc {
|
||||
fill: none;
|
||||
stroke: var(--dock-accent, var(--label-2));
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 240ms var(--ease-ui);
|
||||
}
|
||||
|
||||
.dock-pct {
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--label-2);
|
||||
}
|
||||
|
||||
.dock-pct.is-spent {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dock-quiet {
|
||||
font-size: 10px;
|
||||
color: var(--label-3);
|
||||
}
|
||||
|
||||
.dock-detail {
|
||||
flex: 0 0 var(--dock-detail-w);
|
||||
overflow-y: auto;
|
||||
padding: var(--dock-pad);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--dock-radius) 0 0 var(--dock-radius);
|
||||
border: 1px solid var(--separator);
|
||||
border-right: none;
|
||||
box-shadow: var(--menu-shadow);
|
||||
}
|
||||
|
||||
.dock-notice {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--label-2);
|
||||
}
|
||||
|
||||
.dock-detail-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dock-detail-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dock-detail-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.dock-detail-plan {
|
||||
font-size: 10px;
|
||||
color: var(--label-2);
|
||||
}
|
||||
|
||||
.dock-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dock-window-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--label-2);
|
||||
}
|
||||
|
||||
.dock-window-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.dock-track {
|
||||
height: 4px;
|
||||
border-radius: var(--radius-bar);
|
||||
background: var(--fill-12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dock-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-bar);
|
||||
background: var(--dock-accent, var(--label-2));
|
||||
}
|
||||
|
||||
.dock-window-resets {
|
||||
font-size: 10px;
|
||||
color: var(--label-3);
|
||||
}
|
||||
|
||||
[data-provider='claude'] { --dock-accent: var(--dock-claude); }
|
||||
[data-provider='codex'] { --dock-accent: var(--dock-codex); }
|
||||
[data-provider='copilot'] { --dock-accent: var(--dock-copilot); }
|
||||
[data-provider='gemini'] { --dock-accent: var(--dock-gemini); }
|
||||
[data-provider='kimi'] { --dock-accent: var(--dock-kimi); }
|
||||
[data-provider='antigravity'] { --dock-accent: var(--dock-antigravity); }
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { App } from './App'
|
||||
import { Dock } from './Dock'
|
||||
import './styles.css'
|
||||
|
||||
// Both windows load the one bundle, so the label decides which surface mounts.
|
||||
const isDock = getCurrentWindow().label === 'dock'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>{isDock ? <Dock /> : <App />}</React.StrictMode>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue