Skip to content

Commit 8aeeb34

Browse files
committed
Persist per-monitor camera window positions
1 parent dd00f27 commit 8aeeb34

5 files changed

Lines changed: 433 additions & 45 deletions

File tree

apps/desktop/src-tauri/src/general_settings.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use crate::window_exclusion::WindowExclusion;
2+
use scap_targets::DisplayId;
23
use serde::{Deserialize, Serialize};
34
use serde_json::json;
45
use specta::Type;
6+
use std::collections::BTreeMap;
57
use tauri::{AppHandle, Wry};
68
use tauri_plugin_store::StoreExt;
79
use tracing::{error, instrument};
@@ -70,11 +72,13 @@ pub fn default_excluded_windows() -> Vec<WindowExclusion> {
7072
// When adding fields here, #[serde(default)] defines the value to use for existing configurations,
7173
// and `Default::default` defines the value to use for new configurations.
7274
// Things that affect the user experience should only be enabled by default for new configurations.
73-
#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy)]
75+
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
7476
#[serde(rename_all = "camelCase")]
7577
pub struct WindowPosition {
7678
pub x: f64,
7779
pub y: f64,
80+
#[serde(default)]
81+
pub display_id: Option<DisplayId>,
7882
}
7983

8084
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
@@ -139,6 +143,8 @@ pub struct GeneralSettingsStore {
139143
pub main_window_position: Option<WindowPosition>,
140144
#[serde(default)]
141145
pub camera_window_position: Option<WindowPosition>,
146+
#[serde(default)]
147+
pub camera_window_positions_by_monitor_name: BTreeMap<String, WindowPosition>,
142148
}
143149

144150
fn default_enable_native_camera_preview() -> bool {
@@ -207,6 +213,7 @@ impl Default for GeneralSettingsStore {
207213
editor_preview_quality: EditorPreviewQuality::Half,
208214
main_window_position: None,
209215
camera_window_position: None,
216+
camera_window_positions_by_monitor_name: BTreeMap::new(),
210217
}
211218
}
212219
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ use std::{
8585
Arc,
8686
atomic::{AtomicBool, AtomicU64, Ordering},
8787
},
88-
time::Duration,
88+
time::{Duration, SystemTime, UNIX_EPOCH},
8989
};
9090
use tauri::{AppHandle, Manager, State, Window, WindowEvent, ipc::Channel};
9191
use tauri_plugin_deep_link::DeepLinkExt;
@@ -136,6 +136,39 @@ impl CameraWindowCloseGate {
136136
}
137137
}
138138

139+
fn now_millis() -> u64 {
140+
SystemTime::now()
141+
.duration_since(UNIX_EPOCH)
142+
.map(|d| d.as_millis() as u64)
143+
.unwrap_or(0)
144+
}
145+
146+
#[derive(Debug)]
147+
pub struct CameraWindowPositionGuard {
148+
ignore_until_ms: AtomicU64,
149+
}
150+
151+
impl Default for CameraWindowPositionGuard {
152+
fn default() -> Self {
153+
Self {
154+
ignore_until_ms: AtomicU64::new(0),
155+
}
156+
}
157+
}
158+
159+
impl CameraWindowPositionGuard {
160+
pub fn ignore_for(&self, duration_ms: u64) {
161+
let now = now_millis();
162+
let until = now.saturating_add(duration_ms);
163+
self.ignore_until_ms.store(until, Ordering::Release);
164+
}
165+
166+
pub fn should_ignore(&self) -> bool {
167+
let now = now_millis();
168+
now < self.ignore_until_ms.load(Ordering::Acquire)
169+
}
170+
}
171+
139172
pub type CameraWindowOperationLock = Mutex<()>;
140173

141174
impl FinalizingRecordings {
@@ -637,6 +670,43 @@ async fn set_camera_input(
637670
Ok(())
638671
}
639672

673+
fn display_for_position(pos_x: f64, pos_y: f64) -> Option<Display> {
674+
Display::list().into_iter().find_map(|display| {
675+
let bounds = display.raw_handle().logical_bounds()?;
676+
let x = bounds.position().x();
677+
let y = bounds.position().y();
678+
let width = bounds.size().width();
679+
let height = bounds.size().height();
680+
if pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height {
681+
Some(display)
682+
} else {
683+
None
684+
}
685+
})
686+
}
687+
688+
fn display_id_for_position(pos_x: f64, pos_y: f64) -> Option<DisplayId> {
689+
display_for_position(pos_x, pos_y).map(|display| display.id())
690+
}
691+
692+
fn monitor_name_for_position(pos_x: f64, pos_y: f64) -> Option<String> {
693+
display_for_position(pos_x, pos_y)
694+
.and_then(|display| display.name())
695+
.filter(|name| !name.trim().is_empty())
696+
}
697+
698+
fn update_camera_window_position_settings(settings: &mut GeneralSettingsStore, x: f64, y: f64) {
699+
let display_id = display_id_for_position(x, y);
700+
let monitor_name = monitor_name_for_position(x, y);
701+
let position = general_settings::WindowPosition { x, y, display_id };
702+
settings.camera_window_position = Some(position.clone());
703+
if let Some(monitor_name) = monitor_name {
704+
settings
705+
.camera_window_positions_by_monitor_name
706+
.insert(monitor_name, position);
707+
}
708+
}
709+
640710
fn spawn_mic_error_handler(app_handle: AppHandle, error_rx: flume::Receiver<StreamError>) {
641711
tokio::spawn(async move {
642712
let state = app_handle.state::<ArcLock<App>>();
@@ -2675,6 +2745,33 @@ async fn set_camera_preview_state(
26752745
Ok(())
26762746
}
26772747

2748+
#[tauri::command]
2749+
#[specta::specta]
2750+
#[instrument(skip(app))]
2751+
fn set_camera_window_position(app: AppHandle, x: f64, y: f64) -> Result<(), String> {
2752+
let guard = app.state::<CameraWindowPositionGuard>();
2753+
if guard.should_ignore() {
2754+
return Ok(());
2755+
}
2756+
2757+
GeneralSettingsStore::update(&app, |settings| {
2758+
update_camera_window_position_settings(settings, x, y);
2759+
})?;
2760+
2761+
Ok(())
2762+
}
2763+
2764+
#[tauri::command]
2765+
#[specta::specta]
2766+
#[instrument]
2767+
fn ignore_camera_window_position(
2768+
guard: State<'_, CameraWindowPositionGuard>,
2769+
duration_ms: u32,
2770+
) -> Result<(), String> {
2771+
guard.ignore_for(duration_ms as u64);
2772+
Ok(())
2773+
}
2774+
26782775
#[tauri::command]
26792776
#[specta::specta]
26802777
#[instrument(skip(app))]
@@ -2837,6 +2934,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
28372934
set_pretty_name,
28382935
set_server_url,
28392936
set_camera_preview_state,
2937+
set_camera_window_position,
2938+
ignore_camera_window_position,
28402939
await_camera_preview_ready,
28412940
captions::create_dir,
28422941
captions::save_model_file,
@@ -3126,6 +3225,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
31263225

31273226
app.manage(camera_session_id_handle);
31283227
app.manage(CameraWindowCloseGate::default());
3228+
app.manage(CameraWindowPositionGuard::default());
31293229
app.manage(CameraWindowOperationLock::default());
31303230

31313231
app.manage(Arc::new(RwLock::new(
@@ -3441,21 +3541,28 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
34413541
let logical_pos = position.to_logical::<f64>(scale_factor);
34423542
match window_id {
34433543
CapWindowId::Main => {
3544+
let display_id =
3545+
display_id_for_position(logical_pos.x, logical_pos.y);
34443546
let _ = GeneralSettingsStore::update(app, |settings| {
34453547
settings.main_window_position =
34463548
Some(general_settings::WindowPosition {
34473549
x: logical_pos.x,
34483550
y: logical_pos.y,
3551+
display_id,
34493552
});
34503553
});
34513554
}
34523555
CapWindowId::Camera => {
3556+
let guard = app.state::<CameraWindowPositionGuard>();
3557+
if guard.should_ignore() {
3558+
return;
3559+
}
34533560
let _ = GeneralSettingsStore::update(app, |settings| {
3454-
settings.camera_window_position =
3455-
Some(general_settings::WindowPosition {
3456-
x: logical_pos.x,
3457-
y: logical_pos.y,
3458-
});
3561+
update_camera_window_position_settings(
3562+
settings,
3563+
logical_pos.x,
3564+
logical_pos.y,
3565+
);
34593566
});
34603567
}
34613568
_ => {}

apps/desktop/src-tauri/src/windows.rs

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ use tracing::{debug, error, instrument, warn};
2525
use crate::panel_manager::{PanelManager, PanelState, PanelWindowType};
2626

2727
use crate::{
28-
App, ArcLock, CameraWindowCloseGate, RequestScreenCapturePrewarm, RequestSetTargetMode,
28+
App, ArcLock, CameraWindowCloseGate, CameraWindowPositionGuard, RequestScreenCapturePrewarm,
29+
RequestSetTargetMode,
2930
editor_window::PendingEditorInstances,
3031
fake_window,
3132
general_settings::{self, AppTheme, GeneralSettingsStore},
@@ -273,9 +274,67 @@ fn center_camera_window(app: &AppHandle, window: &WebviewWindow) {
273274
let (pos_x, pos_y) = monitor_info.center_position(window_width, window_height);
274275

275276
let _ = window.set_size(tauri::LogicalSize::new(window_width, window_height));
277+
app.state::<CameraWindowPositionGuard>().ignore_for(1000);
276278
let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y));
277279
}
278280

281+
fn is_position_on_display(display_id: &DisplayId, pos_x: f64, pos_y: f64) -> bool {
282+
Display::from_id(display_id)
283+
.and_then(|display| display.raw_handle().logical_bounds())
284+
.map(|bounds| {
285+
let (x, y, width, height) = (
286+
bounds.position().x(),
287+
bounds.position().y(),
288+
bounds.size().width(),
289+
bounds.size().height(),
290+
);
291+
292+
pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height
293+
})
294+
.unwrap_or(false)
295+
}
296+
297+
fn display_name_for_position(pos_x: f64, pos_y: f64) -> Option<String> {
298+
Display::list().into_iter().find_map(|display| {
299+
let bounds = display.raw_handle().logical_bounds()?;
300+
let (x, y, width, height) = (
301+
bounds.position().x(),
302+
bounds.position().y(),
303+
bounds.size().width(),
304+
bounds.size().height(),
305+
);
306+
307+
if pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height {
308+
display.name().filter(|name| !name.trim().is_empty())
309+
} else {
310+
None
311+
}
312+
})
313+
}
314+
315+
fn is_position_on_monitor_name(monitor_name: &str, pos_x: f64, pos_y: f64) -> bool {
316+
Display::list().into_iter().any(|display| {
317+
if display.name().as_deref() != Some(monitor_name) {
318+
return false;
319+
}
320+
321+
display
322+
.raw_handle()
323+
.logical_bounds()
324+
.map(|bounds| {
325+
let (x, y, width, height) = (
326+
bounds.position().x(),
327+
bounds.position().y(),
328+
bounds.size().width(),
329+
bounds.size().height(),
330+
);
331+
332+
pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height
333+
})
334+
.unwrap_or(false)
335+
})
336+
}
337+
279338
fn is_position_on_any_screen(pos_x: f64, pos_y: f64) -> bool {
280339
for display in Display::list() {
281340
if let Some(bounds) = display.raw_handle().logical_bounds() {
@@ -1436,11 +1495,43 @@ impl ShowCapWindow {
14361495
.map(|w| CursorMonitorInfo::from_window(&w))
14371496
.unwrap_or(cursor_monitor);
14381497

1439-
let saved_position = GeneralSettingsStore::get(app)
1440-
.ok()
1441-
.flatten()
1442-
.and_then(|s| s.camera_window_position)
1443-
.filter(|pos| is_position_on_any_screen(pos.x, pos.y));
1498+
let preferred_monitor_name = display_name_for_position(
1499+
camera_monitor.x + camera_monitor.width / 2.0,
1500+
camera_monitor.y + camera_monitor.height / 2.0,
1501+
);
1502+
1503+
let saved_position =
1504+
GeneralSettingsStore::get(app)
1505+
.ok()
1506+
.flatten()
1507+
.and_then(|settings| {
1508+
if let Some(monitor_name) = preferred_monitor_name.as_deref() {
1509+
settings
1510+
.camera_window_positions_by_monitor_name
1511+
.get(monitor_name)
1512+
.cloned()
1513+
.filter(|pos| {
1514+
is_position_on_monitor_name(monitor_name, pos.x, pos.y)
1515+
})
1516+
.or_else(|| {
1517+
settings.camera_window_position.filter(|pos| {
1518+
is_position_on_monitor_name(
1519+
monitor_name,
1520+
pos.x,
1521+
pos.y,
1522+
)
1523+
})
1524+
})
1525+
} else {
1526+
settings.camera_window_position.filter(|pos| {
1527+
if let Some(display_id) = &pos.display_id {
1528+
is_position_on_display(display_id, pos.x, pos.y)
1529+
} else {
1530+
is_position_on_any_screen(pos.x, pos.y)
1531+
}
1532+
})
1533+
}
1534+
});
14441535

14451536
let (camera_pos_x, camera_pos_y) = if let Some(pos) = saved_position {
14461537
(pos.x, pos.y)
@@ -1460,6 +1551,7 @@ impl ShowCapWindow {
14601551

14611552
#[cfg(not(target_os = "macos"))]
14621553
{
1554+
app.state::<CameraWindowPositionGuard>().ignore_for(1000);
14631555
let _ = window
14641556
.set_position(tauri::LogicalPosition::new(camera_pos_x, camera_pos_y));
14651557
}
@@ -1531,6 +1623,7 @@ impl ShowCapWindow {
15311623
unsafe { CGWindowLevelForKey(kCGMaximumWindowLevelKey) };
15321624
panel.set_level(max_level);
15331625

1626+
app.state::<CameraWindowPositionGuard>().ignore_for(1000);
15341627
let _ = window.set_position(tauri::LogicalPosition::new(
15351628
camera_pos_x,
15361629
camera_pos_y,

0 commit comments

Comments
 (0)