33
44use std:: fs;
55use std:: path:: PathBuf ;
6+ use tauri:: Emitter ;
67use tauri:: Manager ;
8+ use tauri_plugin_global_shortcut:: { Code , Modifiers , Shortcut } ;
9+
10+ // ── Voice overlay dimensions ─────────────────────────────────────────────
11+ const OVERLAY_INITIAL_WIDTH : f64 = 520.0 ;
12+ const OVERLAY_INITIAL_HEIGHT : f64 = 100.0 ;
13+ const OVERLAY_BOTTOM_MARGIN : f64 = 40.0 ;
714
815/// Resolve the path to the connection settings file in the app data directory.
916fn settings_path ( app : & tauri:: AppHandle ) -> Result < PathBuf , String > {
@@ -43,6 +50,146 @@ fn set_server_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
4350 Ok ( ( ) )
4451}
4552
53+ /// Toggle the voice overlay window visibility.
54+ #[ tauri:: command]
55+ fn toggle_voice_overlay ( app : tauri:: AppHandle ) -> Result < ( ) , String > {
56+ toggle_overlay ( & app) ;
57+ Ok ( ( ) )
58+ }
59+
60+ /// Resize a named overlay window to the given logical dimensions.
61+ /// Repositions so the window stays horizontally centred and bottom-pinned.
62+ /// The frontend owns the layout — it measures its own content and tells us
63+ /// the exact size it needs.
64+ #[ tauri:: command]
65+ fn resize_overlay_window (
66+ app : tauri:: AppHandle ,
67+ label : String ,
68+ width : f64 ,
69+ height : f64 ,
70+ ) -> Result < ( ) , String > {
71+ let Some ( window) = app. get_webview_window ( & label) else {
72+ return Ok ( ( ) ) ;
73+ } ;
74+
75+ let monitor = app. primary_monitor ( ) . ok ( ) . flatten ( ) ;
76+ let screen_width = monitor
77+ . as_ref ( )
78+ . map ( |m| m. size ( ) . width as f64 / m. scale_factor ( ) )
79+ . unwrap_or ( 1920.0 ) ;
80+ let screen_height = monitor
81+ . as_ref ( )
82+ . map ( |m| m. size ( ) . height as f64 / m. scale_factor ( ) )
83+ . unwrap_or ( 1080.0 ) ;
84+
85+ let x = ( screen_width - width) / 2.0 ;
86+ let y = screen_height - height - OVERLAY_BOTTOM_MARGIN ;
87+
88+ use tauri:: LogicalPosition ;
89+ use tauri:: LogicalSize ;
90+ let _ = window. set_size ( LogicalSize :: new ( width, height) ) ;
91+ let _ = window. set_position ( LogicalPosition :: new ( x, y) ) ;
92+
93+ Ok ( ( ) )
94+ }
95+
96+ fn activate_voice_overlay ( app : & tauri:: AppHandle ) {
97+ if app. get_webview_window ( "voice-overlay" ) . is_none ( ) {
98+ create_overlay_window ( app) ;
99+ } else if let Some ( overlay) = app. get_webview_window ( "voice-overlay" ) {
100+ if !overlay. is_visible ( ) . unwrap_or ( false ) {
101+ apply_overlay_window_chrome ( & overlay) ;
102+ let _ = overlay. show ( ) ;
103+ let _ = overlay. set_focus ( ) ;
104+ }
105+ }
106+ }
107+
108+ fn toggle_overlay ( app : & tauri:: AppHandle ) {
109+ if let Some ( overlay) = app. get_webview_window ( "voice-overlay" ) {
110+ // Toggle visibility
111+ if overlay. is_visible ( ) . unwrap_or ( false ) {
112+ let _ = overlay. hide ( ) ;
113+ } else {
114+ apply_overlay_window_chrome ( & overlay) ;
115+ let _ = overlay. show ( ) ;
116+ let _ = overlay. set_focus ( ) ;
117+ }
118+ } else {
119+ // Create the overlay window on first toggle
120+ create_overlay_window ( app) ;
121+ }
122+ }
123+
124+ fn create_overlay_window ( app : & tauri:: AppHandle ) {
125+ use tauri:: window:: Color ;
126+ use tauri:: WebviewWindowBuilder ;
127+
128+ // Get the primary monitor to position at bottom center
129+ let monitor = app. primary_monitor ( ) . ok ( ) . flatten ( ) ;
130+
131+ let screen_width = monitor
132+ . as_ref ( )
133+ . map ( |m| m. size ( ) . width as f64 / m. scale_factor ( ) )
134+ . unwrap_or ( 1920.0 ) ;
135+ let screen_height = monitor
136+ . as_ref ( )
137+ . map ( |m| m. size ( ) . height as f64 / m. scale_factor ( ) )
138+ . unwrap_or ( 1080.0 ) ;
139+
140+ // Start collapsed (pill-only). The frontend measures its own content
141+ // and calls resize_overlay_window when the layout changes.
142+ let x = ( screen_width - OVERLAY_INITIAL_WIDTH ) / 2.0 ;
143+ let y = screen_height - OVERLAY_INITIAL_HEIGHT - OVERLAY_BOTTOM_MARGIN ;
144+
145+ match WebviewWindowBuilder :: new (
146+ app,
147+ "voice-overlay" ,
148+ tauri:: WebviewUrl :: App ( "/overlay" . into ( ) ) ,
149+ )
150+ . title ( "Voice" )
151+ . inner_size ( OVERLAY_INITIAL_WIDTH , OVERLAY_INITIAL_HEIGHT )
152+ . position ( x, y)
153+ . decorations ( false )
154+ . shadow ( false )
155+ . transparent ( true )
156+ . background_color ( Color ( 0 , 0 , 0 , 0 ) )
157+ . always_on_top ( true )
158+ . visible ( true )
159+ . resizable ( false )
160+ . skip_taskbar ( true )
161+ . focused ( true )
162+ . maximizable ( false )
163+ . minimizable ( false )
164+ . closable ( false )
165+ . build ( )
166+ {
167+ Ok ( window) => {
168+ apply_overlay_window_chrome ( & window) ;
169+ tracing:: info!( "voice overlay window created" ) ;
170+ // Apply dark theme on macOS
171+ #[ cfg( target_os = "macos" ) ]
172+ {
173+ if let Ok ( ns_window) = window. ns_window ( ) {
174+ unsafe {
175+ sb_desktop_macos:: lock_app_theme ( 1 ) ;
176+ }
177+ let _ = ns_window;
178+ }
179+ }
180+ }
181+ Err ( error) => {
182+ tracing:: error!( %error, "failed to create voice overlay window" ) ;
183+ }
184+ }
185+ }
186+
187+ fn apply_overlay_window_chrome ( window : & tauri:: WebviewWindow ) {
188+ let _ = window. set_decorations ( false ) ;
189+ let _ = window. set_shadow ( false ) ;
190+ let _ = window. set_always_on_top ( true ) ;
191+ }
192+
46193fn main ( ) {
47194 tracing_subscriber:: fmt ( )
48195 . with_env_filter (
@@ -51,9 +198,47 @@ fn main() {
51198 )
52199 . init ( ) ;
53200
201+ // Option+Space toggles the overlay. Option+Shift+Space is hold-to-talk.
202+ let toggle_shortcut = Shortcut :: new ( Some ( Modifiers :: ALT ) , Code :: Space ) ;
203+ let voice_shortcut = Shortcut :: new ( Some ( Modifiers :: ALT | Modifiers :: SHIFT ) , Code :: Space ) ;
204+
54205 tauri:: Builder :: default ( )
55206 . plugin ( tauri_plugin_shell:: init ( ) )
56- . invoke_handler ( tauri:: generate_handler![ get_server_url, set_server_url] )
207+ . plugin (
208+ tauri_plugin_global_shortcut:: Builder :: new ( )
209+ . with_shortcut ( toggle_shortcut. clone ( ) )
210+ . unwrap ( )
211+ . with_shortcut ( voice_shortcut. clone ( ) )
212+ . unwrap ( )
213+ . with_handler (
214+ move |app, _shortcut, event| match ( _shortcut, event. state ) {
215+ ( shortcut, tauri_plugin_global_shortcut:: ShortcutState :: Pressed )
216+ if shortcut == & toggle_shortcut =>
217+ {
218+ toggle_overlay ( app) ;
219+ }
220+ ( shortcut, tauri_plugin_global_shortcut:: ShortcutState :: Pressed )
221+ if shortcut == & voice_shortcut =>
222+ {
223+ activate_voice_overlay ( app) ;
224+ let _ = app. emit ( "voice-overlay:start-recording" , ( ) ) ;
225+ }
226+ ( shortcut, tauri_plugin_global_shortcut:: ShortcutState :: Released )
227+ if shortcut == & voice_shortcut =>
228+ {
229+ let _ = app. emit ( "voice-overlay:stop-recording" , ( ) ) ;
230+ }
231+ _ => { }
232+ } ,
233+ )
234+ . build ( ) ,
235+ )
236+ . invoke_handler ( tauri:: generate_handler![
237+ get_server_url,
238+ set_server_url,
239+ toggle_voice_overlay,
240+ resize_overlay_window,
241+ ] )
57242 . setup ( |app| {
58243 // Apply macOS titlebar style (invisible toolbar for traffic light padding)
59244 #[ cfg( target_os = "macos" ) ]
0 commit comments