Skip to content

Commit f56e491

Browse files
HduSyclaude
andcommitted
feat: save a full panel screenshot to the Desktop
Add a camera button beside the theme toggle that rasterizes the entire panel — full scrollable content, including the charts and heatmap — to a 2x PNG and writes it to ~/Desktop via a new save_screenshot command. DOM rasterization (modern-screenshot) avoids the macOS Screen Recording permission entirely, and the capture button itself is filtered out of the shot. A top-of-panel toast confirms "Saved to Desktop" / "Screenshot failed". Bump version to 0.1.19. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0b2c7d5 commit f56e491

8 files changed

Lines changed: 139 additions & 4 deletions

File tree

‎index.html‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515
*:focus-visible {
1616
outline: none !important;
1717
}
18+
/* Spinner shown on the screenshot button while a capture is in flight. */
19+
@keyframes om-spin {
20+
to { transform: rotate(360deg); }
21+
}
22+
.om-spin {
23+
animation: om-spin 0.7s linear infinite;
24+
transform-origin: 50% 50%;
25+
}
26+
/* Screenshot save/fail toast — fades in at the top of the panel. */
27+
@keyframes om-toast-in {
28+
from { opacity: 0; }
29+
to { opacity: 1; }
30+
}
31+
.om-toast {
32+
animation: om-toast-in 0.18s ease-out;
33+
}
1834
</style>
1935
</head>
2036
<body>

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@tauri-apps/api": "^2.1.1",
14+
"modern-screenshot": "^4.7.0",
1415
"react": "^18.3.1",
1516
"react-dom": "^18.3.1"
1617
},

‎pnpm-lock.yaml‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src-tauri/Cargo.lock‎

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src-tauri/Cargo.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tokenscope"
3-
version = "0.1.18"
3+
version = "0.1.19"
44
description = "Claude CLI token usage dashboard"
55
authors = ["you"]
66
edition = "2021"
@@ -14,6 +14,8 @@ tauri-build = { version = "2", features = [] }
1414

1515
[dependencies]
1616
tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
17+
# decode the base64 PNG body of a screenshot data URL (see save_screenshot)
18+
base64 = "0.22"
1719
tauri-plugin-shell = "2"
1820
tauri-plugin-autostart = "2"
1921
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }

‎src-tauri/src/lib.rs‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,29 @@ fn get_dashboard(app: tauri::AppHandle) -> Dashboard {
415415
dash
416416
}
417417

418+
/// Save a full-panel screenshot (a `data:image/png;base64,...` URL captured in
419+
/// the webview) to the user's Desktop as `Tokenscope <date> at <time>.png`.
420+
/// DOM rasterization sidesteps macOS Screen Recording permission entirely.
421+
/// Returns the written file path on success.
422+
#[tauri::command]
423+
fn save_screenshot(data_url: String) -> Result<String, String> {
424+
use base64::Engine;
425+
let body = data_url
426+
.strip_prefix("data:image/png;base64,")
427+
.ok_or_else(|| "expected a data:image/png;base64,... URL".to_string())?;
428+
let bytes = base64::engine::general_purpose::STANDARD
429+
.decode(body.trim())
430+
.map_err(|e| format!("invalid base64: {e}"))?;
431+
432+
let dir = dirs::desktop_dir()
433+
.ok_or_else(|| "could not resolve the Desktop directory".to_string())?;
434+
let stamp = chrono::Local::now().format("Tokenscope %Y-%m-%d at %H.%M.%S.png");
435+
let path = dir.join(stamp.to_string());
436+
437+
std::fs::write(&path, &bytes).map_err(|e| format!("failed to write file: {e}"))?;
438+
Ok(path.to_string_lossy().into_owned())
439+
}
440+
418441
/// For CLI/example validation against real logs.
419442
pub fn dashboard_json() -> String {
420443
serde_json::to_string_pretty(&parser::build_dashboard()).unwrap_or_default()
@@ -462,7 +485,7 @@ pub fn run() {
462485
}
463486

464487
builder
465-
.invoke_handler(tauri::generate_handler![get_dashboard])
488+
.invoke_handler(tauri::generate_handler![get_dashboard, save_screenshot])
466489
.setup(move |app| {
467490
// Menu-bar–only app: no Dock icon, runs in the background.
468491
#[cfg(target_os = "macos")]

‎src-tauri/tauri.conf.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Tokenscope",
4-
"version": "0.1.18",
4+
"version": "0.1.19",
55
"identifier": "com.tokenscope.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

‎src/App.tsx‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useEffect, useLayoutEffect, useRef, useState } from "react";
22
import { listen } from "@tauri-apps/api/event";
33
import { getCurrentWindow } from "@tauri-apps/api/window";
4+
import { invoke } from "@tauri-apps/api/core";
5+
import { domToPng } from "modern-screenshot";
46
import {
57
Dashboard, PeriodReport, ModelStat, Theme, TH,
68
fetchDashboard, fmtInt, fmtTokens, pct,
@@ -158,6 +160,28 @@ function ThemeToggle({ dark, theme, onToggle }: { dark: boolean; theme: Theme; o
158160
);
159161
}
160162

163+
function ScreenshotButton({ theme, busy, onClick }: { theme: Theme; busy: boolean; onClick: () => void }) {
164+
const t = theme;
165+
return (
166+
<button onClick={onClick} disabled={busy} title="Save screenshot to Desktop" aria-label="save screenshot" style={{
167+
display: "inline-flex", alignItems: "center", justifyContent: "center",
168+
width: 26, height: 26, borderRadius: 7, cursor: busy ? "default" : "pointer", padding: 0,
169+
background: t.segBg, border: `1px solid ${t.segBorder}`, color: t.dim,
170+
}}>
171+
{busy ? (
172+
<svg className="om-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={t.dim} strokeWidth="2.6" strokeLinecap="round">
173+
<path d="M12 3a9 9 0 1 0 9 9" />
174+
</svg>
175+
) : (
176+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={t.dim} strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
177+
<path d="M3 8.5A2.5 2.5 0 0 1 5.5 6h1.7l1.1-1.6A1.5 1.5 0 0 1 9.5 4h5a1.5 1.5 0 0 1 1.2.4L16.8 6h1.7A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5z" />
178+
<circle cx="12" cy="12.2" r="3.4" />
179+
</svg>
180+
)}
181+
</button>
182+
);
183+
}
184+
161185
function Panel({ dash, dark, onToggleTheme, openGen, active }: { dash: Dashboard; dark: boolean; onToggleTheme: () => void; openGen: number; active: boolean }) {
162186
const t = TH[dark ? "dark" : "light"];
163187
const [period, setPeriod] = useState<"Day" | "Week" | "Month">("Week");
@@ -182,9 +206,56 @@ function Panel({ dash, dark, onToggleTheme, openGen, active }: { dash: Dashboard
182206
const tokenShares = sharePcts(tokenModels.map((m) => m.tokens));
183207
const trendSub = { Day: "today 24h", Week: "this week", Month: "this month" }[period];
184208

209+
// screenshot capture: rasterize the full panel card to a PNG and hand it to
210+
// the Rust `save_screenshot` command (browser preview falls back to a download).
211+
const [shotBusy, setShotBusy] = useState(false);
212+
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
213+
const toastTimer = useRef<number | null>(null);
214+
const showToast = (msg: string, ok: boolean) => {
215+
if (toastTimer.current) window.clearTimeout(toastTimer.current);
216+
setToast({ msg, ok });
217+
toastTimer.current = window.setTimeout(() => setToast(null), 1800);
218+
};
219+
const captureScreenshot = async () => {
220+
if (shotBusy) return;
221+
const el = document.querySelector<HTMLElement>(".om-scroll");
222+
if (!el) { showToast("Nothing to capture", false); return; }
223+
setShotBusy(true);
224+
try {
225+
// explicit width/height = full scrollable content, not just the viewport;
226+
// filter drops the capture button itself (and its in-flight spinner) so
227+
// the saved image is a clean dashboard, not a shot of the button.
228+
const dataUrl = await domToPng(el, {
229+
scale: 2,
230+
backgroundColor: dark ? "#1f2226" : "#ffffff",
231+
width: el.scrollWidth,
232+
height: el.scrollHeight,
233+
filter: (n) => !(n instanceof HTMLElement && n.getAttribute("aria-label") === "save screenshot"),
234+
});
235+
const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
236+
if (inTauri) {
237+
await invoke<string>("save_screenshot", { dataUrl });
238+
showToast("Saved to Desktop", true);
239+
} else {
240+
const a = document.createElement("a");
241+
a.href = dataUrl;
242+
a.download = "tokenscope.png";
243+
document.body.appendChild(a);
244+
a.click();
245+
a.remove();
246+
showToast("Downloaded", true);
247+
}
248+
} catch {
249+
showToast("Screenshot failed", false);
250+
} finally {
251+
setShotBusy(false);
252+
}
253+
};
254+
185255
return (
186256
<div style={{
187257
width: "100%", height: "100vh", overflow: "hidden", boxSizing: "border-box",
258+
position: "relative",
188259
background: "transparent", padding: 0,
189260
fontFamily: t.ui,
190261
}}>
@@ -209,6 +280,7 @@ function Panel({ dash, dark, onToggleTheme, openGen, active }: { dash: Dashboard
209280
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
210281
<Segmented value={period} theme={t} onSelect={(v) => setPeriod(v as any)} />
211282
<ThemeToggle dark={dark} theme={t} onToggle={onToggleTheme} />
283+
<ScreenshotButton theme={t} busy={shotBusy} onClick={captureScreenshot} />
212284
</div>
213285
</div>
214286
{/* scrolling body */}
@@ -301,6 +373,18 @@ function Panel({ dash, dark, onToggleTheme, openGen, active }: { dash: Dashboard
301373
</div>
302374
</div>{/* /scrolling body */}
303375
</div>
376+
{toast && (
377+
<div className="om-toast" style={{
378+
position: "absolute", top: 58, left: "50%", transform: "translateX(-50%)",
379+
zIndex: 20, whiteSpace: "nowrap", pointerEvents: "none",
380+
font: `600 12px ${t.mono}`, color: "#fff",
381+
background: toast.ok ? t.accent : "#e0795f",
382+
padding: "7px 13px", borderRadius: 9,
383+
boxShadow: "0 8px 22px rgba(0,0,0,0.34)",
384+
}}>
385+
{toast.msg}
386+
</div>
387+
)}
304388
</div>
305389
);
306390
}

0 commit comments

Comments
 (0)