11import { useEffect , useLayoutEffect , useRef , useState } from "react" ;
22import { listen } from "@tauri-apps/api/event" ;
33import { getCurrentWindow } from "@tauri-apps/api/window" ;
4+ import { invoke } from "@tauri-apps/api/core" ;
5+ import { domToPng } from "modern-screenshot" ;
46import {
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+
161185function 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