@@ -15,7 +15,7 @@ use crate::opacity;
1515use crate :: ops;
1616use crate :: state:: { authorize_command, AppState } ;
1717use crate :: urls:: { normalize_url, urls_match} ;
18- use crate :: window_state:: persist_window_geometry;
18+ use crate :: window_state:: { persist_window_geometry, MIN_WINDOW_SIZE } ;
1919
2020#[ tauri:: command]
2121pub async fn get_config (
@@ -36,10 +36,16 @@ pub async fn update_config(
3636) -> Result < ( ) , String > {
3737 authorize_command ( & state, & token, "update_config" ) ?;
3838 let config = sanitize_config ( config) ;
39- {
39+ let hotkeys_changed = {
4040 let mut current = state. config . lock ( ) . map_err ( |e| e. to_string ( ) ) ?;
41+ let changed = current. hotkeys != config. hotkeys ;
4142 * current = config. clone ( ) ;
4243 save_config ( & state, & current) ;
44+ changed
45+ } ;
46+
47+ if hotkeys_changed {
48+ crate :: hotkeys:: re_register_hotkeys ( & app) ;
4349 }
4450
4551 app. emit ( "config-changed" , & config)
@@ -143,6 +149,35 @@ pub async fn save_window_geometry(
143149 persist_window_geometry ( & window, & state)
144150}
145151
152+ /// Drop all global shortcut registrations until `resume_global_hotkeys`
153+ /// is called. Used by the settings UI's hotkey-rebind capture so an
154+ /// existing binding doesn't fire while the user is recording a new one.
155+ #[ tauri:: command]
156+ pub async fn pause_global_hotkeys (
157+ app : AppHandle ,
158+ state : tauri:: State < ' _ , AppState > ,
159+ token : String ,
160+ ) -> Result < ( ) , String > {
161+ authorize_command ( & state, & token, "pause_global_hotkeys" ) ?;
162+ use tauri_plugin_global_shortcut:: GlobalShortcutExt ;
163+ let _ = app. global_shortcut ( ) . unregister_all ( ) ;
164+ Ok ( ( ) )
165+ }
166+
167+ /// Re-register global shortcuts from the current config. Pair with
168+ /// `pause_global_hotkeys`; safe to call when nothing is paused (the
169+ /// underlying register is idempotent on overwrite).
170+ #[ tauri:: command]
171+ pub async fn resume_global_hotkeys (
172+ app : AppHandle ,
173+ state : tauri:: State < ' _ , AppState > ,
174+ token : String ,
175+ ) -> Result < ( ) , String > {
176+ authorize_command ( & state, & token, "resume_global_hotkeys" ) ?;
177+ crate :: hotkeys:: register_hotkeys ( & app) ;
178+ Ok ( ( ) )
179+ }
180+
146181#[ tauri:: command]
147182pub async fn snap_window (
148183 window : WebviewWindow ,
@@ -171,19 +206,68 @@ pub async fn snap_window(
171206 let ww = win_size. width as i32 ;
172207 let wh = win_size. height as i32 ;
173208
174- let ( x, y) = match position. as_str ( ) {
175- "top-left" => ( mx + padding, my + padding) ,
176- "top-right" => ( mx + mw - ww - padding, my + padding) ,
177- "bottom-left" => ( mx + padding, my + mh - wh - padding) ,
178- "bottom-right" => ( mx + mw - ww - padding, my + mh - wh - padding) ,
179- "center" => ( mx + ( mw - ww) / 2 , my + ( mh - wh) / 2 ) ,
209+ // Halves and thirds resize as well as position. Corners and center
210+ // keep the user's current size — long-standing behavior.
211+ //
212+ // Padding budget per layout: edges + inter-tile gaps. Halves use
213+ // 3*padding (left edge, gap, right edge), thirds use 4*padding.
214+ let ( x, y, new_size) = match position. as_str ( ) {
215+ "top-left" => ( mx + padding, my + padding, None ) ,
216+ "top-right" => ( mx + mw - ww - padding, my + padding, None ) ,
217+ "bottom-left" => ( mx + padding, my + mh - wh - padding, None ) ,
218+ "bottom-right" => ( mx + mw - ww - padding, my + mh - wh - padding, None ) ,
219+ "center" => ( mx + ( mw - ww) / 2 , my + ( mh - wh) / 2 , None ) ,
220+ "left-half" => {
221+ let w = ( ( mw - 3 * padding) / 2 ) . max ( MIN_WINDOW_SIZE ) ;
222+ let h = ( mh - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
223+ ( mx + padding, my + padding, Some ( ( w, h) ) )
224+ }
225+ "right-half" => {
226+ let w = ( ( mw - 3 * padding) / 2 ) . max ( MIN_WINDOW_SIZE ) ;
227+ let h = ( mh - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
228+ ( mx + mw - padding - w, my + padding, Some ( ( w, h) ) )
229+ }
230+ "top-half" => {
231+ let w = ( mw - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
232+ let h = ( ( mh - 3 * padding) / 2 ) . max ( MIN_WINDOW_SIZE ) ;
233+ ( mx + padding, my + padding, Some ( ( w, h) ) )
234+ }
235+ "bottom-half" => {
236+ let w = ( mw - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
237+ let h = ( ( mh - 3 * padding) / 2 ) . max ( MIN_WINDOW_SIZE ) ;
238+ ( mx + padding, my + mh - padding - h, Some ( ( w, h) ) )
239+ }
240+ "left-third" => {
241+ let w = ( ( mw - 4 * padding) / 3 ) . max ( MIN_WINDOW_SIZE ) ;
242+ let h = ( mh - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
243+ ( mx + padding, my + padding, Some ( ( w, h) ) )
244+ }
245+ "center-third" => {
246+ let w = ( ( mw - 4 * padding) / 3 ) . max ( MIN_WINDOW_SIZE ) ;
247+ let h = ( mh - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
248+ ( mx + ( mw - w) / 2 , my + padding, Some ( ( w, h) ) )
249+ }
250+ "right-third" => {
251+ let w = ( ( mw - 4 * padding) / 3 ) . max ( MIN_WINDOW_SIZE ) ;
252+ let h = ( mh - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
253+ ( mx + mw - padding - w, my + padding, Some ( ( w, h) ) )
254+ }
180255 _ => return Err ( "Invalid snap position" . to_string ( ) ) ,
181256 } ;
182257
183258 if window. is_maximized ( ) . unwrap_or ( false ) {
184259 window. unmaximize ( ) . map_err ( |e| e. to_string ( ) ) ?;
185260 }
186261
262+ if let Some ( ( w, h) ) = new_size {
263+ window
264+ . set_size ( tauri:: Size :: Physical ( tauri:: PhysicalSize {
265+ width : w as u32 ,
266+ height : h as u32 ,
267+ } ) )
268+ . map_err ( |e| e. to_string ( ) ) ?;
269+ }
270+
187271 window
188272 . set_position ( tauri:: Position :: Physical ( tauri:: PhysicalPosition { x, y } ) )
189273 . map_err ( |e| e. to_string ( ) ) ?;
@@ -192,6 +276,124 @@ pub async fn snap_window(
192276 Ok ( ( ) )
193277}
194278
279+ /// Parse an "N:M" aspect ratio string into a `(width, height)` pair.
280+ /// Both components must be non-zero positive integers ≤ 1000 to filter
281+ /// out absurd inputs that could overflow the resize math.
282+ fn parse_aspect_ratio ( s : & str ) -> Option < ( u32 , u32 ) > {
283+ let mut parts = s. splitn ( 2 , ':' ) ;
284+ let w: u32 = parts. next ( ) ?. trim ( ) . parse ( ) . ok ( ) ?;
285+ let h: u32 = parts. next ( ) ?. trim ( ) . parse ( ) . ok ( ) ?;
286+ if w == 0 || h == 0 || w > 1000 || h > 1000 {
287+ return None ;
288+ }
289+ Some ( ( w, h) )
290+ }
291+
292+ /// Compute the new window size for a target aspect ratio. Picks the
293+ /// "shrink the over-sized side" interpretation: if the window is too
294+ /// wide for the target ratio, shrink width and keep height; if too tall,
295+ /// shrink height and keep width. Always shrinks, never grows, so the
296+ /// result never exceeds the original on either axis (apart from a 1px
297+ /// rounding wiggle).
298+ fn aspect_resize ( cur_w : i32 , cur_h : i32 , rw : u32 , rh : u32 ) -> ( i32 , i32 ) {
299+ let rw_i = rw as i64 ;
300+ let rh_i = rh as i64 ;
301+ let cw = cur_w as i64 ;
302+ let ch = cur_h as i64 ;
303+ // cw/ch > rw/rh ⇔ cw*rh > ch*rw (no float division)
304+ if cw * rh_i > ch * rw_i {
305+ let new_w = ( ( ch * rw_i + rh_i / 2 ) / rh_i) as i32 ;
306+ ( new_w, cur_h)
307+ } else {
308+ let new_h = ( ( cw * rh_i + rw_i / 2 ) / rw_i) as i32 ;
309+ ( cur_w, new_h)
310+ }
311+ }
312+
313+ /// Resize the window to honor a target aspect ratio. Picks whichever
314+ /// dimension is over-sized for the ratio and shrinks just that one,
315+ /// keeping the other untouched (so a wide window narrows, a tall window
316+ /// shortens). Result is re-centered on the original window center and
317+ /// clamped to monitor bounds.
318+ #[ tauri:: command]
319+ pub async fn set_aspect_ratio (
320+ window : WebviewWindow ,
321+ state : tauri:: State < ' _ , AppState > ,
322+ ratio : String ,
323+ token : String ,
324+ ) -> Result < ( ) , String > {
325+ authorize_command ( & state, & token, "set_aspect_ratio" ) ?;
326+
327+ let ( rw, rh) = parse_aspect_ratio ( & ratio)
328+ . ok_or_else ( || format ! ( "Invalid aspect ratio: {}" , ratio) ) ?;
329+
330+ let monitor = window
331+ . current_monitor ( )
332+ . map_err ( |e| e. to_string ( ) ) ?
333+ . or ( window. primary_monitor ( ) . map_err ( |e| e. to_string ( ) ) ?)
334+ . ok_or ( "No monitor found" ) ?;
335+
336+ let scale = window. scale_factor ( ) . map_err ( |e| e. to_string ( ) ) ?;
337+ let mon_pos = monitor. position ( ) ;
338+ let mon_size = monitor. size ( ) ;
339+ let cur_size = window. outer_size ( ) . map_err ( |e| e. to_string ( ) ) ?;
340+ let cur_pos = window. outer_position ( ) . map_err ( |e| e. to_string ( ) ) ?;
341+
342+ let padding = ( 16.0 * scale) as i32 ;
343+ let max_w = ( mon_size. width as i32 - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
344+ let max_h = ( mon_size. height as i32 - 2 * padding) . max ( MIN_WINDOW_SIZE ) ;
345+
346+ let ( mut new_w, mut new_h) =
347+ aspect_resize ( cur_size. width as i32 , cur_size. height as i32 , rw, rh) ;
348+
349+ // Defensive: if the window started larger than the monitor, the
350+ // shrink-only result might still overflow. Scale both dims down
351+ // proportionally to fit.
352+ if new_h > max_h {
353+ new_h = max_h;
354+ new_w = ( ( new_h as f64 ) * ( rw as f64 ) / ( rh as f64 ) ) . round ( ) as i32 ;
355+ }
356+ if new_w > max_w {
357+ new_w = max_w;
358+ new_h = ( ( new_w as f64 ) * ( rh as f64 ) / ( rw as f64 ) ) . round ( ) as i32 ;
359+ }
360+ new_w = new_w. max ( MIN_WINDOW_SIZE ) ;
361+ new_h = new_h. max ( MIN_WINDOW_SIZE ) ;
362+
363+ let center_x = cur_pos. x + cur_size. width as i32 / 2 ;
364+ let center_y = cur_pos. y + cur_size. height as i32 / 2 ;
365+ let mut new_x = center_x - new_w / 2 ;
366+ let mut new_y = center_y - new_h / 2 ;
367+
368+ let min_x = mon_pos. x + padding;
369+ let max_x = mon_pos. x + mon_size. width as i32 - new_w - padding;
370+ let min_y = mon_pos. y + padding;
371+ let max_y = mon_pos. y + mon_size. height as i32 - new_h - padding;
372+ if max_x >= min_x {
373+ new_x = new_x. clamp ( min_x, max_x) ;
374+ }
375+ if max_y >= min_y {
376+ new_y = new_y. clamp ( min_y, max_y) ;
377+ }
378+
379+ if window. is_maximized ( ) . unwrap_or ( false ) {
380+ window. unmaximize ( ) . map_err ( |e| e. to_string ( ) ) ?;
381+ }
382+
383+ window
384+ . set_size ( tauri:: Size :: Physical ( tauri:: PhysicalSize {
385+ width : new_w as u32 ,
386+ height : new_h as u32 ,
387+ } ) )
388+ . map_err ( |e| e. to_string ( ) ) ?;
389+ window
390+ . set_position ( tauri:: Position :: Physical ( tauri:: PhysicalPosition { x : new_x, y : new_y } ) )
391+ . map_err ( |e| e. to_string ( ) ) ?;
392+
393+ persist_window_geometry ( & window, & state) ?;
394+ Ok ( ( ) )
395+ }
396+
195397#[ tauri:: command]
196398pub async fn open_settings (
197399 window : WebviewWindow ,
@@ -494,6 +696,65 @@ mod tests {
494696 assert ! ( truncated. ends_with( "..." ) ) ;
495697 }
496698
699+ #[ test]
700+ fn parse_aspect_ratio_accepts_common_ratios ( ) {
701+ assert_eq ! ( parse_aspect_ratio( "16:9" ) , Some ( ( 16 , 9 ) ) ) ;
702+ assert_eq ! ( parse_aspect_ratio( "4:3" ) , Some ( ( 4 , 3 ) ) ) ;
703+ assert_eq ! ( parse_aspect_ratio( " 21 : 9 " ) , Some ( ( 21 , 9 ) ) ) ;
704+ assert_eq ! ( parse_aspect_ratio( "1:1" ) , Some ( ( 1 , 1 ) ) ) ;
705+ }
706+
707+ #[ test]
708+ fn aspect_resize_shrinks_width_when_too_wide ( ) {
709+ // 2000x500 → 16:9 should keep height and pull width to 16/9*500 ≈ 889
710+ let ( w, h) = aspect_resize ( 2000 , 500 , 16 , 9 ) ;
711+ assert_eq ! ( h, 500 , "height must be preserved when window is too wide" ) ;
712+ assert ! ( ( w - 889 ) . abs( ) <= 1 , "width should shrink to ~889, got {}" , w) ;
713+ assert ! ( w < 2000 , "width should shrink, not grow" ) ;
714+ }
715+
716+ #[ test]
717+ fn aspect_resize_shrinks_height_when_too_tall ( ) {
718+ // 400x1200 → 16:9 should keep width and pull height to 9/16*400 = 225
719+ let ( w, h) = aspect_resize ( 400 , 1200 , 16 , 9 ) ;
720+ assert_eq ! ( w, 400 , "width must be preserved when window is too tall" ) ;
721+ assert ! ( ( h - 225 ) . abs( ) <= 1 , "height should shrink to ~225, got {}" , h) ;
722+ assert ! ( h < 1200 , "height should shrink, not grow" ) ;
723+ }
724+
725+ #[ test]
726+ fn aspect_resize_already_at_ratio_is_a_noop ( ) {
727+ let ( w, h) = aspect_resize ( 1600 , 900 , 16 , 9 ) ;
728+ assert_eq ! ( ( w, h) , ( 1600 , 900 ) ) ;
729+ }
730+
731+ #[ test]
732+ fn aspect_resize_handles_square_target ( ) {
733+ // 1600x900 → 1:1 should pick the smaller dim (height) and shrink width
734+ let ( w, h) = aspect_resize ( 1600 , 900 , 1 , 1 ) ;
735+ assert_eq ! ( h, 900 ) ;
736+ assert_eq ! ( w, 900 ) ;
737+ }
738+
739+ #[ test]
740+ fn aspect_resize_handles_tall_target ( ) {
741+ // 1000x800 → 9:16: current ratio (1.25) > target (0.5625), so too wide
742+ // → keep height, shrink width to 800 * 9/16 = 450
743+ let ( w, h) = aspect_resize ( 1000 , 800 , 9 , 16 ) ;
744+ assert_eq ! ( h, 800 ) ;
745+ assert ! ( ( w - 450 ) . abs( ) <= 1 , "got width {}" , w) ;
746+ }
747+
748+ #[ test]
749+ fn parse_aspect_ratio_rejects_garbage ( ) {
750+ assert ! ( parse_aspect_ratio( "16x9" ) . is_none( ) ) ;
751+ assert ! ( parse_aspect_ratio( "0:9" ) . is_none( ) ) ;
752+ assert ! ( parse_aspect_ratio( "16:0" ) . is_none( ) ) ;
753+ assert ! ( parse_aspect_ratio( "9999:1" ) . is_none( ) ) ;
754+ assert ! ( parse_aspect_ratio( "nope" ) . is_none( ) ) ;
755+ assert ! ( parse_aspect_ratio( "" ) . is_none( ) ) ;
756+ }
757+
497758 #[ test]
498759 fn truncate_title_handles_edge_case_all_multibyte ( ) {
499760 let title = "漢" . repeat ( 200 ) ; // 3 bytes * 200 = 600 bytes
0 commit comments