Skip to content

Commit 4bf0bfa

Browse files
davidtorciviaclaude
andcommitted
feat: v1.4.0 — hotkey rebinding, smart aspect snap, halves/thirds, polish pass
- snap_window: halves (l/r/t/b) and thirds (l/c/r) — resize + position - set_aspect_ratio command: shrinks the over-sized dimension to honor 16:9 / 4:3 / 21:9 / 1:1 / 9:16, keeping the closer side untouched - hotkey rebinding UI in settings: click-to-capture, per-row reset that fades in on default→custom transition, reset-all, OS hotkeys paused during capture so existing bindings don't fire - gear button now toggles settings open/closed - design-token-based polish pass: micro-lift buttons, amber active glow, URL focus ring, slider halo, toggle warmth, modal backdrop blur ramp, snap popup stagger reveal, recent/context menu accent slide, refresh spin, bookmark star pop Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f6bec6 commit 4bf0bfa

10 files changed

Lines changed: 1005 additions & 126 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ The drag bar uses `-webkit-app-region: drag` for native WebView2 drag handling.
120120
| `update_config` | Update config fields | `commands.rs` |
121121
| `set_url` | Set the last_url and recent list | `commands.rs` |
122122
| `save_window_geometry` | Persist current geometry | `commands.rs` |
123-
| `snap_window` | Snap window to corner/center | `commands.rs` |
123+
| `snap_window` | Snap window to corner/center, half, or third (resizes for halves/thirds) | `commands.rs` |
124+
| `set_aspect_ratio` | Resize window to a `"W:H"` aspect ratio around its current center | `commands.rs` |
125+
| `pause_global_hotkeys` | Drop all global shortcut registrations (used during hotkey rebind) | `commands.rs` |
126+
| `resume_global_hotkeys` | Re-register global shortcuts from current config | `commands.rs` |
124127
| `open_settings` | Emit open-settings event | `commands.rs` |
125128
| `close_window` | Close window | `commands.rs` |
126129
| `maximize_toggle` | Maximize/unmaximize window | `commands.rs` |
@@ -295,3 +298,5 @@ Run unit tests in `src-tauri/` (via `cargo test`). Test manually:
295298
15. Test tray quit preserves geometry
296299
16. Test media hotkeys target the most recently interacted player
297300
17. Test error-page redirect only fires on actual browser errors
301+
18. Test snap dropdown sub-sections: position corners, halves, thirds, and aspect ratios (16:9 / 4:3 / 21:9 / 1:1 / 9:16) — each section's buttons should fire correctly and the popup should stay inside the viewport
302+
19. Test hotkey rebinding from settings: click a binding, press a new combo, confirm it persists and the new combo fires immediately. Test Esc-cancel, "Modifier required" guard for unmodified non-F-keys, and Reset to defaults. While capturing, confirm pressing the *current* binding does not fire its action

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "floatview",
3-
"version": "1.3.0",
3+
"version": "1.4.0",
44
"description": "A minimal floating browser window for streaming media on a secondary monitor",
55
"scripts": {
66
"tauri": "tauri",

src-tauri/Cargo.lock

Lines changed: 1 addition & 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "floatview"
3-
version = "1.3.0"
3+
version = "1.4.0"
44
description = "A minimal floating browser window for streaming media"
55
authors = ["David Torcivia"]
66
license = "MIT"

src-tauri/src/commands.rs

Lines changed: 269 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::opacity;
1515
use crate::ops;
1616
use crate::state::{authorize_command, AppState};
1717
use 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]
2121
pub 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]
147182
pub 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]
196398
pub 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

src-tauri/src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ impl Default for WindowConfig {
4545
}
4646
}
4747

48-
#[derive(Debug, Clone, Serialize, Deserialize)]
48+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4949
pub struct HotkeyConfig {
5050
pub toggle_on_top: String,
5151
pub toggle_locked: String,

src-tauri/src/hotkeys.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,18 @@ pub fn register_hotkeys(app: &AppHandle) {
165165
});
166166
}
167167

168+
/// Drop every currently registered global shortcut and re-register from
169+
/// the latest config. Called from `update_config` after the user rebinds
170+
/// a hotkey via the settings UI. Errors during unregister are logged but
171+
/// not surfaced — the subsequent `register_hotkeys` will silently
172+
/// overwrite any stragglers that survived.
173+
pub fn re_register_hotkeys(app: &AppHandle) {
174+
if let Err(e) = app.global_shortcut().unregister_all() {
175+
warn!("Failed to unregister hotkeys before re-register: {}", e);
176+
}
177+
register_hotkeys(app);
178+
}
179+
168180
/// Internal: parse + register one hotkey binding, logging on failure.
169181
///
170182
/// `action` takes no arguments — captures are up to the caller. This

0 commit comments

Comments
 (0)