Evan's Blog
EvanZhu0721 / 公开仓库

SBMS

镜像已同步

SBMS complete the calculation rules for the default logical desktop topology relationship and the physical dimensions of the displays in the Windows multi-screen collaboration system.

上游 GitHub
READ-ONLY MIRROR

README、源码与 Release 已保存到本站;源码视图固定到 commit a37d6c4b。

main/ root / src
a37d6c4b· 固定源码快照
src/ui.rs68.5 KB
1use std::error::Error;
2use std::process::Command;
3use std::rc::Rc;
4use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use crate::config::{
9 AppConfig, ConfigProfileStore, ConfigProfiles, DisplayOverrideStore, DisplayOverrides,
10 MAX_CONFIG_PROFILES, ProfileRevision, ReferenceSource,
11};
12use crate::control::{TrayInstance, listen_for_config_reload, listen_for_shutdown};
13use crate::controller::{Controller, ControllerEvent, DisplayOption};
14use crate::diagnostics::{self, Level};
15use crate::geometry::{
16 AspectRatio, DisplayGeometry, PhysicalMeasurement, PixelSize, Rotation, SizingRequest,
17 SizingResult, SizingStrategy,
18};
19use crate::limits::{
20 MAX_OUTPUTS, MAX_VIRTUAL_DIMENSION, MILLIMETERS_PER_INCH, valid_physical_millimeters,
21};
22use crate::mapping::MappingPlan;
23use crate::network::preferred_lan_ipv4;
24use crate::session_gate::VirtualMode;
25use crate::win32_flyout;
26use crate::win32_tray::{NativeTray, TrayAction, TrayHandle};
27use slint::winit_030::winit::event::WindowEvent;
28use slint::winit_030::winit::platform::windows::{CornerPreference, WindowAttributesExtWindows};
29use slint::winit_030::{EventResult as WinitEventResult, WinitWindowAccessor};
30use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
31slint::include_modules!();
32
33const STREAM_ONLY_ID: &str = "__stream_only__";
34const DISPLAY_SETTINGS_URI: &str = "ms-settings:display";
35const PROJECT_HOME_URL: &str = "https://github.com/EvanZhu0721/SBMS";
36static INTRO_GENERATION: AtomicU64 = AtomicU64::new(0);
37static PROFILE_SAVE_GENERATION: AtomicU64 = AtomicU64::new(0);
38
39mod diagnostic_view;
40mod geometry_view;
41mod handlers;
42mod projection;
43mod state;
44
45#[cfg(test)]
46use diagnostic_view::format_recent_diagnostics;
47use diagnostic_view::{open_log_folder, populate_diagnostics};
48use geometry_view::*;
49use handlers::{
50 HandlerState, register_diagnostics_handlers, register_geometry_handlers,
51 register_group_handlers, register_lifecycle_handlers, register_profile_handlers,
52 register_screen_size_handlers, register_stream_handlers,
53};
54use projection::{hydrate_group, project_group_telemetry, snapshot_group, update_tab_projection};
55use state::{
56 GroupDraft, LoadedGroups, SunshineState, ValidatedGroupDraft, ValidatedStreamFields,
57 aspect_ratio_from_index, aspect_ratio_option_index, parse_screen_diagonal,
58 parse_stream_refresh_millihz, parse_u32, rotation_from_index, rotation_index, rotation_label,
59};
60
61type ConfigStateRefs<'a> = (
62 &'a Arc<Mutex<Vec<GroupDraft>>>,
63 &'a Arc<Mutex<usize>>,
64 &'a Arc<Mutex<ProfileRevision>>,
65 &'a Arc<AtomicBool>,
66 &'a Arc<AtomicBool>,
67);
68
69pub fn run_host(_instance: TrayInstance, open_on_start: bool) -> Result<(), Box<dyn Error>> {
70 slint::BackendSelector::new()
71 .backend_name("winit".into())
72 .renderer_name("software".into())
73 .with_winit_window_attributes_hook(|attributes| {
74 attributes
75 .with_transparent(false)
76 .with_skip_taskbar(true)
77 .with_corner_preference(CornerPreference::Round)
78 })
79 .select()?;
80
81 listen_for_shutdown(|| {
82 diagnostics::log(
83 Level::Info,
84 "tray",
85 "external-shutdown",
86 None,
87 "shutdown signal received",
88 );
89 let _ = slint::invoke_from_event_loop(|| {
90 let _ = slint::quit_event_loop();
91 });
92 })?;
93 let flyout = QuickAccess::new()?;
94 let intro_disabled = Arc::new(AtomicBool::new(
95 ConfigProfileStore::default_store()
96 .and_then(|store| store.list())
97 .map(|profiles| profiles.launch_intro_disabled)
98 .unwrap_or(false),
99 ));
100 let dismiss_flyout = flyout.as_weak();
101 flyout.window().on_winit_window_event(move |_, event| {
102 if matches!(event, WindowEvent::Focused(false)) {
103 let flyout = dismiss_flyout.clone();
104 slint::Timer::single_shot(Duration::from_millis(50), move || {
105 if let Some(flyout) = flyout.upgrade()
106 && flyout.window().is_visible()
107 && flyout
108 .window()
109 .with_winit_window(|window| !window.has_focus())
110 .unwrap_or(false)
111 {
112 diagnostics::log(
113 Level::Debug,
114 "ui",
115 "flyout-dismiss",
116 None,
117 "window lost focus",
118 );
119 let _ = flyout.hide();
120 }
121 });
122 }
123 WinitEventResult::Propagate
124 });
125 flyout.set_max_group_count(MAX_OUTPUTS as i32);
126 let loaded = load_group_config(&flyout);
127 let override_store = DisplayOverrideStore::default_store()?;
128 let override_outcome = override_store.load()?;
129 let override_save_blocked = override_outcome.warning.is_some();
130 if let Some(warning) = &override_outcome.warning {
131 flyout.set_screen_size_error(warning.as_str().into());
132 }
133 flyout.set_screen_size_save_blocked(override_save_blocked);
134 let display_overrides = Arc::new(Mutex::new(override_outcome.overrides));
135 let displays = Arc::new(Mutex::new(Vec::<DisplayOption>::new()));
136 let groups = Arc::new(Mutex::new(loaded.groups));
137 let active_group = Arc::new(Mutex::new(loaded.active));
138 let profile_revision = Arc::new(Mutex::new(loaded.profile));
139 update_tab_projection(
140 &flyout,
141 &groups.lock().expect("group drafts poisoned"),
142 loaded.active,
143 );
144
145 let flyout_weak = flyout.as_weak();
146 let tray_intro_disabled = intro_disabled.clone();
147 let tray = NativeTray::new(move |action| {
148 let flyout_weak = flyout_weak.clone();
149 let intro_disabled = tray_intro_disabled.clone();
150 if let Err(error) = slint::invoke_from_event_loop(move || match action {
151 TrayAction::Toggle => {
152 if let Some(flyout) = flyout_weak.upgrade() {
153 if flyout.window().is_visible() {
154 let _ = flyout.hide();
155 } else {
156 let play_intro = !intro_disabled.load(Ordering::Acquire);
157 show_flyout(&flyout, play_intro);
158 }
159 }
160 }
161 TrayAction::Open => {
162 if let Some(flyout) = flyout_weak.upgrade() {
163 let play_intro = !intro_disabled.load(Ordering::Acquire);
164 show_flyout(&flyout, play_intro);
165 }
166 }
167 TrayAction::Quit => {
168 diagnostics::log(Level::Info, "tray", "action", None, "quit");
169 let _ = slint::quit_event_loop();
170 }
171 }) {
172 diagnostics::log(
173 Level::Warn,
174 "tray",
175 "action-dispatch",
176 None,
177 error.to_string(),
178 );
179 }
180 })?;
181 let tray_handle = tray.handle();
182
183 let ui = flyout.as_weak();
184 let error_revision = Arc::new(AtomicU64::new(0));
185 let pending_config_reload = Arc::new(AtomicBool::new(false));
186 let pending_profile_restart = Arc::new(AtomicBool::new(false));
187 {
188 let ui = flyout.as_weak();
189 let displays = displays.clone();
190 let groups = groups.clone();
191 let active_group = active_group.clone();
192 let profile_revision = profile_revision.clone();
193 let pending = pending_config_reload.clone();
194 let error_revision = error_revision.clone();
195 listen_for_config_reload(move || {
196 let ui = ui.clone();
197 let displays = displays.clone();
198 let groups = groups.clone();
199 let active_group = active_group.clone();
200 let profile_revision = profile_revision.clone();
201 let pending = pending.clone();
202 let error_revision = error_revision.clone();
203 let _ = slint::invoke_from_event_loop(move || {
204 let Some(ui) = ui.upgrade() else {
205 return;
206 };
207 if ui.get_running() || ui.get_busy() {
208 pending.store(true, Ordering::Release);
209 diagnostics::log(
210 Level::Info,
211 "config",
212 "reload-deferred",
213 None,
214 "config reload deferred until the active mapping stops",
215 );
216 return;
217 }
218 apply_config_reload(
219 &ui,
220 &displays,
221 &groups,
222 &active_group,
223 &profile_revision,
224 &error_revision,
225 );
226 });
227 })?;
228 }
229 apply_config_reload(
230 &flyout,
231 &displays,
232 &groups,
233 &active_group,
234 &profile_revision,
235 &error_revision,
236 );
237 let event_error_revision = error_revision.clone();
238 let event_displays = displays.clone();
239 let event_groups = groups.clone();
240 let event_active_group = active_group.clone();
241 let event_display_overrides = display_overrides.clone();
242 let event_profile_revision = profile_revision.clone();
243 let event_pending_config_reload = pending_config_reload.clone();
244 let event_pending_profile_restart = pending_profile_restart.clone();
245 let controller = Controller::spawn(move |event| {
246 let ui = ui.clone();
247 let tray = tray_handle.clone();
248 let error_revision = event_error_revision.clone();
249 let displays = event_displays.clone();
250 let groups = event_groups.clone();
251 let active_group = event_active_group.clone();
252 let display_overrides = event_display_overrides.clone();
253 let profile_revision = event_profile_revision.clone();
254 let pending_config_reload = event_pending_config_reload.clone();
255 let pending_profile_restart = event_pending_profile_restart.clone();
256 let _ = slint::invoke_from_event_loop(move || {
257 if let Some(ui) = ui.upgrade() {
258 apply_event(
259 &ui,
260 tray,
261 &error_revision,
262 (&displays, &display_overrides),
263 (
264 &groups,
265 &active_group,
266 &profile_revision,
267 &pending_config_reload,
268 &pending_profile_restart,
269 ),
270 event,
271 );
272 }
273 });
274 });
275 let sender = controller.sender();
276
277 let handler_state = HandlerState::new(
278 displays.clone(),
279 groups.clone(),
280 active_group.clone(),
281 display_overrides.clone(),
282 profile_revision.clone(),
283 );
284 register_lifecycle_handlers(&flyout, &sender, &handler_state, &error_revision);
285 register_geometry_handlers(&flyout, &handler_state);
286 register_stream_handlers(&flyout, &handler_state);
287 register_screen_size_handlers(&flyout, &sender, &handler_state, &override_store);
288 register_group_handlers(&flyout, &handler_state);
289 register_diagnostics_handlers(&flyout);
290 register_profile_handlers(
291 &flyout,
292 &sender,
293 &handler_state,
294 &error_revision,
295 &pending_config_reload,
296 &pending_profile_restart,
297 );
298 {
299 let flyout_weak = flyout.as_weak();
300 let intro_disabled = intro_disabled.clone();
301 flyout.on_disable_launch_intro(move || {
302 INTRO_GENERATION.fetch_add(1, Ordering::AcqRel);
303 intro_disabled.store(true, Ordering::Release);
304 let Some(ui) = flyout_weak.upgrade() else {
305 return;
306 };
307 ui.set_intro_active(false);
308 if let Err(error) = ConfigProfileStore::default_store()
309 .and_then(|store| store.set_launch_intro_disabled(true))
310 {
311 diagnostics::log(
312 Level::Warn,
313 "ui",
314 "disable-launch-intro",
315 None,
316 error.to_string(),
317 );
318 ui.set_error_text(
319 format!("Couldn’t save the launch logo preference: {error}").into(),
320 );
321 }
322 });
323 }
324 {
325 let flyout_weak = flyout.as_weak();
326 flyout.on_open_project_home(move || {
327 if let Err(error) = open_project_home() {
328 diagnostics::log(Level::Warn, "ui", "open-project-home", None, error.as_str());
329 if let Some(ui) = flyout_weak.upgrade() {
330 ui.set_error_text(error.into());
331 }
332 }
333 });
334 }
335 {
336 let flyout_weak = flyout.as_weak();
337 flyout.on_open_profile_directory(move || {
338 if let Err(error) = open_profile_directory() {
339 diagnostics::log(
340 Level::Warn,
341 "ui",
342 "open-profile-directory",
343 None,
344 error.as_str(),
345 );
346 if let Some(ui) = flyout_weak.upgrade() {
347 ui.set_error_text(error.into());
348 }
349 }
350 });
351 }
352
353 sender.refresh();
354 if open_on_start {
355 let play_intro = !intro_disabled.load(Ordering::Acquire);
356 show_flyout(&flyout, play_intro);
357 }
358 diagnostics::log(
359 Level::Info,
360 "tray",
361 "event-loop-start",
362 None,
363 "Slint event loop started",
364 );
365 // The tray host is a native Win32 window, so Slint cannot count it when deciding
366 // whether the app is still alive. Only the explicit tray Exit action should quit.
367 slint::run_event_loop_until_quit()?;
368 diagnostics::log(
369 Level::Info,
370 "tray",
371 "event-loop-stop",
372 None,
373 "Slint event loop stopped",
374 );
375 {
376 let displays = displays.lock().expect("display metadata poisoned");
377 let mut groups = groups.lock().expect("group drafts poisoned");
378 let active = *active_group.lock().expect("active group poisoned");
379 snapshot_group(&flyout, &displays, &mut groups, active);
380 persist_groups(&flyout, &groups, active, &profile_revision);
381 }
382 controller.shutdown();
383 Ok(())
384}
385
386fn active_after_removal(current: usize, removed: usize, remaining: usize) -> usize {
387 debug_assert!(remaining > 0);
388 if removed < current {
389 current - 1
390 } else if removed == current {
391 current.min(remaining - 1)
392 } else {
393 current
394 }
395}
396
397fn apply_event(
398 ui: &QuickAccess,
399 tray: TrayHandle,
400 error_revision: &Arc<AtomicU64>,
401 display_state: (
402 &Arc<Mutex<Vec<DisplayOption>>>,
403 &Arc<Mutex<DisplayOverrides>>,
404 ),
405 config_state: ConfigStateRefs<'_>,
406 event: ControllerEvent,
407) {
408 let (display_metadata, display_overrides) = display_state;
409 let (
410 group_state,
411 active_group,
412 profile_revision,
413 pending_config_reload,
414 pending_profile_restart,
415 ) = config_state;
416 match event {
417 ControllerEvent::Displays(mut displays) => {
418 apply_display_overrides(
419 &mut displays,
420 &display_overrides
421 .lock()
422 .expect("display overrides poisoned"),
423 );
424 let mut groups = group_state.lock().expect("group drafts poisoned");
425 let active = *active_group.lock().expect("active group poisoned");
426 if displays.is_empty()
427 && let Some(group) = groups.get_mut(active)
428 && group.target_id.is_none()
429 {
430 group.stream_only = true;
431 }
432 refresh_group_sizing(&mut groups, &displays);
433 refresh_group_tab_details(&mut groups, &displays);
434 hydrate_group(ui, &displays, &groups, active);
435 if !ui.get_restart_available() {
436 ui.set_error_text("".into());
437 if !ui.get_running() && !ui.get_busy() {
438 ui.set_state("Stopped".into());
439 ui.set_state_detail(
440 if displays.is_empty() {
441 "Configure a stream-only screen to start"
442 } else {
443 "Choose a target screen to start"
444 }
445 .into(),
446 );
447 }
448 }
449 if ui.get_page() == QuickAccessPage::ScreenSize {
450 populate_screen_size_page(ui, &displays);
451 }
452 *display_metadata.lock().expect("display metadata poisoned") = displays;
453 }
454 ControllerEvent::GroupReady(info) => {
455 let mut groups = group_state.lock().expect("group drafts poisoned");
456 if let Some(group) = groups.iter_mut().find(|group| group.id == info.id) {
457 group.telemetry.ready = true;
458 group.telemetry.sunshine_id = info.sunshine_id;
459 group.telemetry.sunshine_state =
460 if group.telemetry.sunshine_id.as_deref().is_some_and(is_guid) {
461 SunshineState::Starting
462 } else {
463 SunshineState::Failed
464 };
465 }
466 let active = *active_group.lock().expect("active group poisoned");
467 project_group_telemetry(ui, &groups, active);
468 }
469 ControllerEvent::Fps { id, fps } => {
470 let mut groups = group_state.lock().expect("group drafts poisoned");
471 if let Some(group) = groups.iter_mut().find(|group| group.id == id) {
472 group.telemetry.fps = Some(fps.min(999));
473 }
474 let active = *active_group.lock().expect("active group poisoned");
475 project_group_telemetry(ui, &groups, active);
476 }
477 ControllerEvent::Sunshine {
478 id,
479 display_id,
480 requested_port,
481 port,
482 error,
483 } => {
484 let mut groups = group_state.lock().expect("group drafts poisoned");
485 let mut group_number = id + 1;
486 if let Some(group) = groups.iter_mut().find(|group| {
487 group.id == id
488 && group.telemetry.sunshine_id.as_deref() == Some(display_id.as_str())
489 }) {
490 group_number = group.id + 1;
491 group.telemetry.sunshine_port = Some(port.unwrap_or(requested_port));
492 if error.is_some() {
493 group.telemetry.sunshine_state = SunshineState::Failed;
494 group.telemetry.error = true;
495 } else {
496 group.telemetry.sunshine_state = SunshineState::Ready;
497 group.telemetry.error = false;
498 }
499 }
500 let active = *active_group.lock().expect("active group poisoned");
501 project_group_telemetry(ui, &groups, active);
502 drop(groups);
503
504 if let Some(error) = error {
505 let message = format!("Output {group_number} Sunshine: {error}");
506 let revision = error_revision.fetch_add(1, Ordering::Relaxed) + 1;
507 ui.set_error_text(message.as_str().into());
508 ui.set_diagnostic_summary(message.as_str().into());
509 let ui = ui.as_weak();
510 let error_revision = error_revision.clone();
511 slint::Timer::single_shot(Duration::from_secs(12), move || {
512 if error_revision.load(Ordering::Relaxed) != revision {
513 return;
514 }
515 if let Some(ui) = ui.upgrade() {
516 ui.set_error_text("".into());
517 }
518 });
519 }
520 }
521 ControllerEvent::State {
522 state,
523 detail,
524 running,
525 busy,
526 error,
527 restart_available,
528 } => {
529 let revision = error_revision.fetch_add(1, Ordering::Relaxed) + 1;
530 tray.set_status(state);
531 ui.set_state(state.into());
532 ui.set_state_detail(detail.into());
533 ui.set_running(running);
534 ui.set_busy(busy);
535 ui.set_restart_available(restart_available);
536 ui.set_error_text(error.as_str().into());
537 if state == "Reconnecting" {
538 show_flyout(ui, false);
539 }
540 if !running && !busy {
541 let mut groups = group_state.lock().expect("group drafts poisoned");
542 for group in groups.iter_mut() {
543 group.reset_telemetry();
544 }
545 }
546 if !error.is_empty() {
547 let mut groups = group_state.lock().expect("group drafts poisoned");
548 for group in groups.iter_mut() {
549 group.telemetry.error = true;
550 }
551 ui.set_diagnostic_summary(error.as_str().into());
552 ui.set_mapping_fps_error(true);
553 ui.set_mapping_fps_valid(false);
554 let ui = ui.as_weak();
555 let error_revision = error_revision.clone();
556 slint::Timer::single_shot(Duration::from_secs(12), move || {
557 if error_revision.load(Ordering::Relaxed) != revision {
558 return;
559 }
560 if let Some(ui) = ui.upgrade() {
561 ui.set_error_text("".into());
562 if !ui.get_running() && !ui.get_busy() && !ui.get_restart_available() {
563 if ui.get_selected_display() >= 0 {
564 ui.set_state("Stopped".into());
565 ui.set_state_detail("Choose a display to start".into());
566 } else {
567 ui.set_state("No displays".into());
568 ui.set_state_detail("Connect or enable a physical display".into());
569 }
570 }
571 }
572 });
573 } else {
574 let mut groups = group_state.lock().expect("group drafts poisoned");
575 if !running {
576 for group in groups.iter_mut() {
577 group.telemetry.error = false;
578 }
579 }
580 let active = *active_group.lock().expect("active group poisoned");
581 project_group_telemetry(ui, &groups, active);
582 }
583 if !running && !busy && pending_config_reload.swap(false, Ordering::AcqRel) {
584 let restart = pending_profile_restart.swap(false, Ordering::AcqRel);
585 let reloaded = apply_config_reload(
586 ui,
587 display_metadata,
588 group_state,
589 active_group,
590 profile_revision,
591 error_revision,
592 );
593 if reloaded && restart {
594 ui.invoke_start();
595 }
596 }
597 }
598 }
599}
600
601fn sunshine_web_url(base_port: u16) -> Result<String, String> {
602 let web_port = base_port
603 .checked_add(1)
604 .ok_or_else(|| "The Sunshine Web port is outside the valid range".to_string())?;
605 Ok(format!("https://localhost:{web_port}/"))
606}
607
608fn open_sunshine_panel(base_port: u16) -> Result<(), String> {
609 let url = sunshine_web_url(base_port)?;
610 Command::new("explorer.exe")
611 .arg(url)
612 .spawn()
613 .map(|_| ())
614 .map_err(|error| format!("Couldn’t open the Sunshine Web panel: {error}"))
615}
616
617fn open_display_settings() -> Result<(), String> {
618 Command::new("explorer.exe")
619 .arg(DISPLAY_SETTINGS_URI)
620 .spawn()
621 .map(|_| ())
622 .map_err(|error| format!("Couldn’t open Windows display settings: {error}"))
623}
624
625fn open_project_home() -> Result<(), String> {
626 Command::new("explorer.exe")
627 .arg(PROJECT_HOME_URL)
628 .spawn()
629 .map(|_| ())
630 .map_err(|error| format!("Couldn’t open the SBMS repository: {error}"))
631}
632
633fn open_profile_directory() -> Result<(), String> {
634 let profile_path = ConfigProfileStore::default_path()
635 .map_err(|error| format!("Couldn’t locate the profile folder: {error}"))?;
636 let directory = profile_path
637 .parent()
638 .ok_or_else(|| "Couldn’t locate the profile folder".to_string())?;
639
640 let mut explorer = Command::new("explorer.exe");
641 if profile_path.is_file() {
642 explorer.arg(format!("/select,{}", profile_path.display()));
643 } else {
644 explorer.arg(directory);
645 }
646
647 explorer
648 .spawn()
649 .map(|_| ())
650 .map_err(|error| format!("Couldn’t open the profile folder: {error}"))
651}
652
653fn refresh_lan_ip(ui: &QuickAccess) {
654 match preferred_lan_ipv4() {
655 Ok(Some(address)) => ui.set_sunshine_lan_ip(address.to_string().into()),
656 Ok(None) => ui.set_sunshine_lan_ip("--".into()),
657 Err(error) => {
658 diagnostics::log(Level::Warn, "ui", "lan-ip", None, error);
659 ui.set_sunshine_lan_ip("--".into());
660 }
661 }
662}
663
664fn surface_transient_error(ui: &QuickAccess, error_revision: &Arc<AtomicU64>, message: &str) {
665 let revision = error_revision.fetch_add(1, Ordering::Relaxed) + 1;
666 ui.set_error_text(message.into());
667 let ui = ui.as_weak();
668 let error_revision = error_revision.clone();
669 slint::Timer::single_shot(Duration::from_secs(12), move || {
670 if error_revision.load(Ordering::Relaxed) != revision {
671 return;
672 }
673 if let Some(ui) = ui.upgrade() {
674 ui.set_error_text("".into());
675 }
676 });
677}
678
679fn is_guid(value: &str) -> bool {
680 let bytes = value.as_bytes();
681 bytes.len() == 38
682 && bytes[0] == b'{'
683 && bytes[37] == b'}'
684 && [9, 14, 19, 24].iter().all(|index| bytes[*index] == b'-')
685 && bytes[1..37]
686 .iter()
687 .enumerate()
688 .all(|(index, byte)| [8, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit())
689}
690
691fn apply_display_overrides(displays: &mut [DisplayOption], overrides: &DisplayOverrides) {
692 for display in displays {
693 display.physical_width_mm = display.detected_physical_width_mm;
694 display.physical_height_mm = display.detected_physical_height_mm;
695 let display_override = overrides.override_for(&display.id);
696 display.physical_override_inches = display_override.map(|entry| entry.diagonal_inches);
697 display.physical_override_aspect_ratio =
698 display_override.and_then(|entry| entry.aspect_ratio);
699 if let Some(diagonal_inches) = display.physical_override_inches
700 && let Some((width_mm, height_mm)) = dimensions_from_diagonal(
701 display,
702 diagonal_inches,
703 display.physical_override_aspect_ratio,
704 )
705 {
706 display.physical_width_mm = Some(width_mm);
707 display.physical_height_mm = Some(height_mm);
708 }
709 }
710}
711
712fn refresh_group_tab_details(groups: &mut [GroupDraft], displays: &[DisplayOption]) {
713 for group in groups {
714 group.tab_detail = group_tab_detail(group, displays);
715 }
716}
717
718fn group_tab_detail(group: &GroupDraft, displays: &[DisplayOption]) -> String {
719 if group.stream_only {
720 return parse_screen_diagonal(&group.stream_diagonal)
721 .map(|diagonal| format!("{diagonal:.0}″"))
722 .unwrap_or_else(|_| "--".into());
723 }
724 group
725 .target_id
726 .as_deref()
727 .and_then(|id| displays.iter().find(|display| display.id == id))
728 .and_then(display_diagonal_inches)
729 .map(|diagonal| format!("{diagonal:.0}″"))
730 .unwrap_or_else(|| "—".into())
731}
732
733fn display_diagonal_inches(display: &DisplayOption) -> Option<f64> {
734 Some(
735 display
736 .physical_width_mm?
737 .hypot(display.physical_height_mm?)
738 / MILLIMETERS_PER_INCH,
739 )
740}
741
742fn dimensions_from_diagonal(
743 display: &DisplayOption,
744 diagonal_inches: f64,
745 aspect_ratio: Option<AspectRatio>,
746) -> Option<(f64, f64)> {
747 let (width, height) = aspect_ratio
748 .map(|ratio| (f64::from(ratio.width), f64::from(ratio.height)))
749 .unwrap_or((
750 f64::from(display.native_width),
751 f64::from(display.native_height),
752 ));
753 let ratio_diagonal = width.hypot(height);
754 (ratio_diagonal > 0.0).then(|| {
755 let diagonal_mm = diagonal_inches * MILLIMETERS_PER_INCH;
756 (
757 diagonal_mm * width / ratio_diagonal,
758 diagonal_mm * height / ratio_diagonal,
759 )
760 })
761}
762
763fn refresh_group_sizing(groups: &mut [GroupDraft], displays: &[DisplayOption]) {
764 for group in groups {
765 let Some(mut sizing) = group.sizing else {
766 continue;
767 };
768 let target_geometry = if group.stream_only {
769 match stream_target_geometry(
770 &group.stream_width,
771 &group.stream_height,
772 &group.stream_diagonal,
773 match aspect_ratio_from_index(group.stream_aspect_ratio_index) {
774 Ok(aspect_ratio) => aspect_ratio,
775 Err(_) => {
776 group.sizing = None;
777 continue;
778 }
779 },
780 match rotation_from_index(group.stream_rotation_index) {
781 Ok(rotation) => rotation,
782 Err(_) => {
783 group.sizing = None;
784 continue;
785 }
786 },
787 ) {
788 Ok(target) => target,
789 Err(_) => {
790 group.sizing = None;
791 continue;
792 }
793 }
794 } else {
795 let Some(target_id) = group.target_id.as_deref() else {
796 group.sizing = None;
797 continue;
798 };
799 let Some(target) = displays.iter().find(|display| display.id == target_id) else {
800 continue;
801 };
802 let Ok(target) = display_geometry(target, "Target") else {
803 group.sizing = None;
804 continue;
805 };
806 target
807 };
808 sizing.target = target_geometry;
809 if let Some(ReferenceSource::Display(reference_id)) = &group.reference_source {
810 let Some(reference) = displays.iter().find(|display| display.id == *reference_id)
811 else {
812 continue;
813 };
814 let Ok(reference_geometry) = display_geometry(reference, "Reference") else {
815 group.sizing = None;
816 continue;
817 };
818 sizing.reference = reference_geometry;
819 }
820 group.sizing = Some(sizing);
821 }
822}
823
824fn populate_screen_size_page(ui: &QuickAccess, displays: &[DisplayOption]) {
825 let Some(display) = selected_option(ui, displays, ui.get_selected_display()) else {
826 ui.set_screen_size_target("Display unavailable".into());
827 ui.set_screen_size_detected_mm("Not reported".into());
828 ui.set_screen_size_detected_inches("Physical size unavailable".into());
829 ui.set_screen_size_error("Target screen is no longer available".into());
830 return;
831 };
832 ui.set_screen_size_target(display.name.as_str().into());
833 ui.set_screen_size_native_mode(
834 format!("{} × {}", display.native_width, display.native_height).into(),
835 );
836 if let (Some(width), Some(height)) = (
837 display.detected_physical_width_mm,
838 display.detected_physical_height_mm,
839 ) {
840 ui.set_screen_size_detected_mm(format!("{width:.0} × {height:.0} mm").into());
841 ui.set_screen_size_detected_inches(
842 format!(
843 "{:.1} in diagonal",
844 width.hypot(height) / MILLIMETERS_PER_INCH
845 )
846 .into(),
847 );
848 } else {
849 ui.set_screen_size_detected_mm("Not reported".into());
850 ui.set_screen_size_detected_inches("No physical size in EDID".into());
851 }
852 if ui.get_page() != QuickAccessPage::ScreenSize {
853 ui.set_screen_size_manual_inches(
854 display
855 .physical_override_inches
856 .map(|value| format!("{value:.1}"))
857 .unwrap_or_default()
858 .into(),
859 );
860 ui.set_screen_size_aspect_ratio(
861 display
862 .physical_override_aspect_ratio
863 .map(|ratio| aspect_ratio_option_index(ratio) + 1)
864 .unwrap_or(0),
865 );
866 }
867 update_screen_size_preview(ui, displays);
868}
869
870fn update_screen_size_preview(ui: &QuickAccess, displays: &[DisplayOption]) {
871 if ui.get_screen_size_save_blocked() {
872 return;
873 }
874 let Some(display) = selected_option(ui, displays, ui.get_selected_display()) else {
875 ui.set_screen_size_error("Target screen is no longer available".into());
876 ui.set_screen_size_valid(false);
877 return;
878 };
879 let manual = ui.get_screen_size_manual_inches();
880 let effective = if manual.trim().is_empty() {
881 ui.set_screen_size_source("EDID".into());
882 match (
883 display.detected_physical_width_mm,
884 display.detected_physical_height_mm,
885 ) {
886 (Some(width), Some(height)) => {
887 Some((width, height, width.hypot(height) / MILLIMETERS_PER_INCH))
888 }
889 _ => None,
890 }
891 } else {
892 match parse_screen_diagonal(manual.as_str())
893 .ok()
894 .and_then(|diagonal| {
895 let aspect_ratio =
896 screen_override_aspect_ratio(ui.get_screen_size_aspect_ratio()).ok()?;
897 dimensions_from_diagonal(display, diagonal, aspect_ratio)
898 .map(|(width, height)| (width, height, diagonal))
899 }) {
900 Some(value) => {
901 ui.set_screen_size_source("Manual override".into());
902 Some(value)
903 }
904 None => {
905 ui.set_screen_size_error("Enter a valid diagonal in inches".into());
906 ui.set_screen_size_valid(false);
907 return;
908 }
909 }
910 };
911 match effective {
912 Some((width, height, diagonal)) => {
913 ui.set_screen_size_effective_mm(format!("{width:.0} × {height:.0} mm").into());
914 ui.set_screen_size_effective_inches(format!("{diagonal:.1} in diagonal").into());
915 ui.set_screen_size_error("".into());
916 ui.set_screen_size_valid(true);
917 }
918 None => {
919 ui.set_screen_size_effective_mm("Physical size unavailable".into());
920 ui.set_screen_size_effective_inches("Enter a manual diagonal".into());
921 ui.set_screen_size_error("".into());
922 ui.set_screen_size_valid(true);
923 }
924 }
925}
926
927fn switch_group(
928 ui: &QuickAccess,
929 next: i32,
930 groups: &Arc<Mutex<Vec<GroupDraft>>>,
931 active_group: &Arc<Mutex<usize>>,
932 displays: &Arc<Mutex<Vec<DisplayOption>>>,
933 profile_revision: &Arc<Mutex<ProfileRevision>>,
934) {
935 if next < 0 {
936 return;
937 }
938 let displays = displays.lock().expect("display metadata poisoned");
939 let mut groups = groups.lock().expect("group drafts poisoned");
940 if next as usize >= groups.len() {
941 return;
942 }
943 let current = *active_group.lock().expect("active group poisoned");
944 snapshot_group(ui, &displays, &mut groups, current);
945 let next = next as usize;
946 *active_group.lock().expect("active group poisoned") = next;
947 hydrate_group(ui, &displays, &groups, next);
948 persist_groups(ui, &groups, next, profile_revision);
949}
950
951fn stream_mode(ui: &QuickAccess, displays: &[DisplayOption]) -> Result<VirtualMode, String> {
952 let request = geometry_request(ui, displays)?
953 .ok_or_else(|| "Enter the reference display measurements".to_string())?;
954 let result = request.calculate().map_err(|error| error.to_string())?;
955 let refresh_millihz = parse_stream_refresh_millihz(ui.get_stream_refresh().as_str())?;
956 VirtualMode::from_millihz(
957 result.virtual_mode.width,
958 result.virtual_mode.height,
959 refresh_millihz,
960 )
961 .map_err(|error| error.to_string())
962}
963
964fn validate_stream_fields(ui: &QuickAccess, displays: &[DisplayOption]) -> Option<VirtualMode> {
965 match stream_mode(ui, displays) {
966 Ok(mode) => {
967 ui.set_stream_error("".into());
968 let mode_text = format!("{} × {}", mode.width, mode.height);
969 let mode_ratio = stream_aspect_ratio(&mode.width.to_string(), &mode.height.to_string())
970 .unwrap_or_default();
971 let direction = rotation_label(ui.get_stream_rotation()).unwrap_or("Unknown direction");
972 let detail = format!(
973 "{} Hz · {} · {}",
974 ui.get_stream_refresh(),
975 mode_ratio,
976 direction
977 );
978 ui.set_stream_result(mode_text.as_str().into());
979 ui.set_stream_result_detail(detail.into());
980 ui.set_geometry_configured(true);
981 ui.set_geometry_summary(mode_text.into());
982 ui.set_geometry_summary_detail(
983 format!(
984 "Stream only · {} · {} Hz",
985 direction,
986 ui.get_stream_refresh()
987 )
988 .into(),
989 );
990 Some(mode)
991 }
992 Err(error) => {
993 ui.set_stream_error(error.into());
994 None
995 }
996 }
997}
998
999fn update_stream_suggestions(ui: &QuickAccess) {
1000 let width = ui.get_stream_width().to_string();
1001 let height = ui.get_stream_height().to_string();
1002 let aspect_ratio =
1003 aspect_ratio_from_index(ui.get_stream_aspect_ratio_index()).unwrap_or(AspectRatio {
1004 width: DEFAULT_STREAM_ASPECT_WIDTH as u32,
1005 height: DEFAULT_STREAM_ASPECT_HEIGHT as u32,
1006 });
1007
1008 set_stream_dimension_suggestion(
1009 &width,
1010 &height,
1011 COMMON_WIDTHS,
1012 u64::from(aspect_ratio.width),
1013 u64::from(aspect_ratio.height),
1014 |value| ui.set_stream_width_suggestion(value),
1015 |value| ui.set_stream_width_suffix(value),
1016 );
1017 set_stream_dimension_suggestion(
1018 &height,
1019 &width,
1020 COMMON_HEIGHTS,
1021 u64::from(aspect_ratio.height),
1022 u64::from(aspect_ratio.width),
1023 |value| ui.set_stream_height_suggestion(value),
1024 |value| ui.set_stream_height_suffix(value),
1025 );
1026 ui.set_stream_aspect_ratio(
1027 stream_aspect_ratio(&width, &height)
1028 .unwrap_or_default()
1029 .into(),
1030 );
1031}
1032
1033fn validate_group_drafts(groups: &[GroupDraft]) -> Result<Vec<ValidatedGroupDraft>, String> {
1034 groups.iter().map(GroupDraft::validate).collect()
1035}
1036
1037fn build_mapping_plan(groups: &[ValidatedGroupDraft]) -> Result<MappingPlan, String> {
1038 let requests = groups
1039 .iter()
1040 .map(ValidatedGroupDraft::to_mapping_request)
1041 .collect::<Result<Vec<_>, String>>()?;
1042 MappingPlan::new(requests).map_err(|error| error.to_string())
1043}
1044
1045fn selected_display_id(ui: &QuickAccess, index: i32) -> Option<String> {
1046 (index >= 0)
1047 .then(|| ui.get_display_ids().row_data(index as usize))
1048 .flatten()
1049 .map(|id| id.to_string())
1050}
1051
1052fn selected_reference_source(ui: &QuickAccess) -> Option<ReferenceSource> {
1053 let index = ui.get_selected_reference();
1054 let display_count = ui.get_reference_ids().row_count() as i32;
1055 if index == display_count && ui.get_reference_labels().row_count() == display_count as usize + 1
1056 {
1057 return Some(ReferenceSource::Manual);
1058 }
1059 (index >= 0)
1060 .then(|| ui.get_reference_ids().row_data(index as usize))
1061 .flatten()
1062 .map(|id| ReferenceSource::Display(id.to_string()))
1063}
1064
1065fn rebuild_reference_options(
1066 ui: &QuickAccess,
1067 displays: &[DisplayOption],
1068 desired_source: Option<ReferenceSource>,
1069) {
1070 let target_id = selected_display_id(ui, ui.get_selected_display());
1071 let candidate_indices = reference_candidate_indices(displays, target_id.as_deref());
1072 let candidates: Vec<_> = candidate_indices
1073 .iter()
1074 .map(|index| &displays[*index])
1075 .collect();
1076 let mut names: Vec<_> = candidates
1077 .iter()
1078 .map(|display| SharedString::from(display.name.as_str()))
1079 .collect();
1080 let mut labels: Vec<_> = candidates
1081 .iter()
1082 .map(|display| {
1083 SharedString::from(
1084 if display.physical_width_mm.is_some() && display.physical_height_mm.is_some() {
1085 display.label.clone()
1086 } else {
1087 format!("{} · Size unavailable", display.label)
1088 },
1089 )
1090 })
1091 .collect();
1092 let ids: Vec<_> = candidates
1093 .iter()
1094 .map(|display| SharedString::from(display.id.as_str()))
1095 .collect();
1096 let selected_index =
1097 reference_selection_index(displays, &candidate_indices, desired_source.as_ref());
1098 names.push("Manual".into());
1099 labels.push("Manual · Enter display geometry".into());
1100 ui.set_reference_names(ModelRc::new(Rc::new(VecModel::from(names))));
1101 ui.set_reference_labels(ModelRc::new(Rc::new(VecModel::from(labels))));
1102 ui.set_reference_ids(ModelRc::new(Rc::new(VecModel::from(ids))));
1103 ui.set_selected_reference(selected_index);
1104}
1105
1106fn reference_candidate_indices(displays: &[DisplayOption], target_id: Option<&str>) -> Vec<usize> {
1107 displays
1108 .iter()
1109 .enumerate()
1110 .filter_map(|(index, display)| (target_id != Some(display.id.as_str())).then_some(index))
1111 .collect()
1112}
1113
1114fn reference_selection_index(
1115 displays: &[DisplayOption],
1116 candidates: &[usize],
1117 desired_source: Option<&ReferenceSource>,
1118) -> i32 {
1119 let manual_index = candidates.len() as i32;
1120 match desired_source {
1121 Some(ReferenceSource::Manual) => manual_index,
1122 Some(ReferenceSource::Display(id)) => candidates
1123 .iter()
1124 .position(|index| displays[*index].id == *id)
1125 .map(|index| index as i32)
1126 .unwrap_or(if candidates.is_empty() {
1127 manual_index
1128 } else {
1129 0
1130 }),
1131 None => {
1132 if candidates.is_empty() {
1133 manual_index
1134 } else {
1135 0
1136 }
1137 }
1138 }
1139}
1140
1141fn load_group_config(ui: &QuickAccess) -> LoadedGroups {
1142 let snapshot = match ConfigProfileStore::default_store().and_then(|store| store.load_active()) {
1143 Ok(snapshot) => snapshot,
1144 Err(error) => {
1145 set_geometry_unconfigured(ui);
1146 ui.set_geometry_save_blocked(true);
1147 ui.set_geometry_error(error.to_string().into());
1148 return default_loaded_groups();
1149 }
1150 };
1151 ui.set_geometry_save_blocked(false);
1152 loaded_groups(snapshot)
1153}
1154
1155fn loaded_groups(snapshot: crate::config::ProfileSnapshot) -> LoadedGroups {
1156 let groups = snapshot
1157 .config
1158 .groups
1159 .iter()
1160 .map(GroupDraft::from_config)
1161 .collect::<Vec<_>>();
1162 let active = snapshot
1163 .config
1164 .selected_group_id
1165 .and_then(|id| groups.iter().position(|group| group.id == id))
1166 .unwrap_or(0);
1167 LoadedGroups {
1168 groups,
1169 active,
1170 profile: snapshot.profile,
1171 }
1172}
1173
1174fn default_loaded_groups() -> LoadedGroups {
1175 LoadedGroups {
1176 groups: vec![GroupDraft::new(0)],
1177 active: 0,
1178 profile: ProfileRevision {
1179 id: "default".into(),
1180 revision: 0,
1181 },
1182 }
1183}
1184
1185fn apply_config_reload(
1186 ui: &QuickAccess,
1187 displays: &Arc<Mutex<Vec<DisplayOption>>>,
1188 groups: &Arc<Mutex<Vec<GroupDraft>>>,
1189 active_group: &Arc<Mutex<usize>>,
1190 profile_revision: &Arc<Mutex<ProfileRevision>>,
1191 error_revision: &Arc<AtomicU64>,
1192) -> bool {
1193 let snapshot = match ConfigProfileStore::default_store().and_then(|store| store.load_active()) {
1194 Ok(snapshot) => snapshot,
1195 Err(error) => {
1196 diagnostics::log(Level::Error, "config", "reload", None, error.to_string());
1197 surface_transient_error(
1198 ui,
1199 error_revision,
1200 &format!("Couldn’t reload configuration: {error}"),
1201 );
1202 return false;
1203 }
1204 };
1205 let mut loaded = loaded_groups(snapshot);
1206 let displays = displays.lock().expect("display metadata poisoned");
1207 let mut groups = groups.lock().expect("group drafts poisoned");
1208 for group in &mut loaded.groups {
1209 if let Some(previous) = groups.iter().find(|previous| previous.id == group.id) {
1210 group.telemetry = previous.telemetry.clone();
1211 }
1212 }
1213 refresh_group_sizing(&mut loaded.groups, &displays);
1214 refresh_group_tab_details(&mut loaded.groups, &displays);
1215 *groups = loaded.groups;
1216 let mut active = active_group.lock().expect("active group poisoned");
1217 *active = loaded.active;
1218 *profile_revision
1219 .lock()
1220 .expect("config profile revision poisoned") = loaded.profile.clone();
1221 ui.set_geometry_save_blocked(false);
1222 ui.set_geometry_error("".into());
1223 hydrate_group(ui, &displays, &groups, *active);
1224 if let Err(error) = refresh_profile_projection(ui) {
1225 diagnostics::log(Level::Warn, "config", "profile-list", None, &error);
1226 surface_transient_error(ui, error_revision, &error);
1227 }
1228 diagnostics::log(
1229 Level::Info,
1230 "config",
1231 "reload",
1232 None,
1233 format!(
1234 "loaded profile={} revision={}",
1235 loaded.profile.id, loaded.profile.revision
1236 ),
1237 );
1238 true
1239}
1240
1241fn refresh_profile_projection(ui: &QuickAccess) -> Result<(), String> {
1242 let profiles = ConfigProfileStore::default_store()
1243 .and_then(|store| store.list())
1244 .map_err(|error| format!("Couldn’t load configuration profiles: {error}"))?;
1245 project_profiles(ui, &profiles);
1246 Ok(())
1247}
1248
1249fn project_profiles(ui: &QuickAccess, profiles: &ConfigProfiles) {
1250 let names = profiles
1251 .profiles
1252 .iter()
1253 .map(|profile| SharedString::from(profile.id.as_str()))
1254 .collect::<Vec<_>>();
1255 ui.set_profile_names(ModelRc::new(Rc::new(VecModel::from(names))));
1256 ui.set_active_profile_name(profiles.active_profile.as_str().into());
1257 ui.set_max_profile_count(MAX_CONFIG_PROFILES as i32);
1258}
1259
1260fn persist_groups(
1261 ui: &QuickAccess,
1262 groups: &[GroupDraft],
1263 active: usize,
1264 profile_revision: &Arc<Mutex<ProfileRevision>>,
1265) -> bool {
1266 if ui.get_geometry_save_blocked() {
1267 surface_persistence_error(
1268 ui,
1269 "The saved configuration is invalid; reset or repair it before saving",
1270 );
1271 return false;
1272 }
1273 let validated = match validate_group_drafts(groups) {
1274 Ok(validated) => validated,
1275 Err(error) => {
1276 surface_persistence_error(ui, &error);
1277 return false;
1278 }
1279 };
1280 persist_validated_groups(ui, &validated, active, profile_revision)
1281}
1282
1283fn persist_validated_groups(
1284 ui: &QuickAccess,
1285 groups: &[ValidatedGroupDraft],
1286 active: usize,
1287 profile_revision: &Arc<Mutex<ProfileRevision>>,
1288) -> bool {
1289 let persisted = groups.iter().map(ValidatedGroupDraft::to_config).collect();
1290 let Some(selected_group_id) = groups.get(active).map(ValidatedGroupDraft::id) else {
1291 surface_persistence_error(ui, "The selected mapping group no longer exists");
1292 return false;
1293 };
1294 let config = AppConfig {
1295 groups: persisted,
1296 selected_group_id: Some(selected_group_id),
1297 ..AppConfig::default()
1298 };
1299 let expected = profile_revision
1300 .lock()
1301 .expect("config profile revision poisoned")
1302 .clone();
1303 let save_generation = begin_profile_save(ui);
1304 let result = ConfigProfileStore::default_store()
1305 .and_then(|store| store.save_active_if_revision(&expected, &config));
1306 match result {
1307 Ok(snapshot) => {
1308 *profile_revision
1309 .lock()
1310 .expect("config profile revision poisoned") = snapshot.profile;
1311 finish_profile_save(ui.as_weak(), save_generation, true);
1312 true
1313 }
1314 Err(error) => {
1315 finish_profile_save(ui.as_weak(), save_generation, false);
1316 surface_persistence_error(ui, &error.to_string());
1317 false
1318 }
1319 }
1320}
1321
1322fn begin_profile_save(ui: &QuickAccess) -> u64 {
1323 let generation = PROFILE_SAVE_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
1324 ui.set_profile_saving(true);
1325 generation
1326}
1327
1328fn finish_profile_save(ui: slint::Weak<QuickAccess>, generation: u64, succeeded: bool) {
1329 let finish = move || {
1330 if PROFILE_SAVE_GENERATION.load(Ordering::Acquire) != generation {
1331 return;
1332 }
1333 if let Some(ui) = ui.upgrade() {
1334 ui.set_profile_saving(false);
1335 }
1336 };
1337 if succeeded {
1338 // Keep the real write transition visible long enough to be perceived.
1339 // The timer only controls presentation; success still comes from the store.
1340 slint::Timer::single_shot(Duration::from_millis(400), finish);
1341 } else {
1342 finish();
1343 }
1344}
1345
1346fn surface_persistence_error(ui: &QuickAccess, error: &str) {
1347 ui.set_error_text(format!("Couldn’t save mapping groups: {error}").into());
1348 ui.set_state_detail("Mapping group changes were not saved".into());
1349 ui.set_geometry_error(error.into());
1350}
1351
1352fn reposition_after_layout(ui: slint::Weak<QuickAccess>) {
1353 slint::Timer::single_shot(Duration::ZERO, move || {
1354 if let Some(ui) = ui.upgrade() {
1355 win32_flyout::position(ui.window());
1356 ui.window().request_redraw();
1357 }
1358 });
1359}
1360
1361fn show_flyout(ui: &QuickAccess, play_intro: bool) {
1362 let intro_generation = play_intro.then(|| INTRO_GENERATION.fetch_add(1, Ordering::AcqRel) + 1);
1363 if intro_generation.is_some() {
1364 ui.set_intro_stage(0);
1365 ui.set_intro_active(true);
1366 }
1367 refresh_lan_ip(ui);
1368 win32_flyout::position(ui.window());
1369
1370 // Slint 1.17's winit software renderer can retain a reused-buffer cache
1371 // after Windows clears a hidden window during a display-topology change.
1372 // Taking a snapshot temporarily selects a new repaint buffer and clears
1373 // that cache, so the visible frame below is rendered in full.
1374 let _ = ui.window().take_snapshot();
1375 if let Err(error) = ui.show() {
1376 diagnostics::log(Level::Warn, "ui", "flyout-show", None, error.to_string());
1377 return;
1378 }
1379
1380 let activated = win32_flyout::activate(ui.window());
1381 diagnostics::log(
1382 Level::Debug,
1383 "ui",
1384 "flyout-activate",
1385 None,
1386 format!("attempt=immediate foreground={activated}"),
1387 );
1388 ui.window().request_redraw();
1389
1390 if let Some(generation) = intro_generation {
1391 play_launch_intro(ui.as_weak(), generation);
1392 }
1393}
1394
1395fn play_launch_intro(ui: slint::Weak<QuickAccess>, generation: u64) {
1396 // Solid tiles -> carve MS -> move the mark -> B/S enter and recoil immediately.
1397 for (delay_ms, stage) in [
1398 (420, 1),
1399 (543, 2),
1400 (665, 3),
1401 (788, 4),
1402 (910, 5),
1403 (1_033, 6),
1404 (1_155, 7),
1405 (1_278, 8),
1406 (1_400, 9),
1407 (1_523, 10),
1408 (1_645, 11),
1409 (1_768, 12),
1410 (1_890, 13),
1411 (2_013, 14),
1412 (2_135, 15),
1413 (2_258, 16),
1414 (2_380, 17),
1415 ] {
1416 let ui = ui.clone();
1417 slint::Timer::single_shot(Duration::from_millis(delay_ms), move || {
1418 if INTRO_GENERATION.load(Ordering::Acquire) == generation
1419 && let Some(ui) = ui.upgrade()
1420 {
1421 ui.set_intro_stage(stage);
1422 }
1423 });
1424 }
1425
1426 slint::Timer::single_shot(Duration::from_millis(2_660), move || {
1427 if INTRO_GENERATION.load(Ordering::Acquire) == generation
1428 && let Some(ui) = ui.upgrade()
1429 {
1430 ui.set_intro_active(false);
1431 }
1432 });
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::{
1438 COMMON_HEIGHTS, COMMON_WIDTHS, DISPLAY_SETTINGS_URI, GroupDraft, active_after_removal,
1439 aspect_ratio_from_index, build_mapping_plan, dimensions_from_diagonal,
1440 format_recent_diagnostics, group_tab_detail, is_guid, reference_candidate_indices,
1441 reference_selection_index, resolution_suggestion, rotation_from_index, rotation_index,
1442 rotation_label, scaled_dimension_suggestion, screen_override_aspect_ratio,
1443 sizing_strategy_from_index, sizing_strategy_index, stream_aspect_ratio,
1444 stream_target_geometry, sunshine_web_url, validate_group_drafts,
1445 };
1446 use crate::config::ReferenceSource;
1447 use crate::controller::DisplayOption;
1448 use crate::diagnostics::{Level, Record};
1449 use crate::geometry::{
1450 AspectRatio, DisplayGeometry, PhysicalMeasurement, PixelSize, Rotation, SizingRequest,
1451 SizingStrategy,
1452 };
1453 use crate::limits::MILLIMETERS_PER_INCH;
1454 use crate::mapping::MappingRoute;
1455
1456 fn display_option(id: &str, name: &str) -> DisplayOption {
1457 DisplayOption {
1458 id: id.into(),
1459 name: name.into(),
1460 label: format!("{name} · 2560×1440"),
1461 native_width: 2560,
1462 native_height: 1440,
1463 detected_physical_width_mm: Some(527.0),
1464 detected_physical_height_mm: Some(296.0),
1465 physical_width_mm: Some(527.0),
1466 physical_height_mm: Some(296.0),
1467 physical_override_inches: None,
1468 physical_override_aspect_ratio: None,
1469 rotation: Rotation::Deg0,
1470 }
1471 }
1472
1473 fn test_geometry(width: u32, height: u32) -> DisplayGeometry {
1474 DisplayGeometry {
1475 native_pixels: PixelSize { width, height },
1476 physical: PhysicalMeasurement::DimensionsMm {
1477 width: f64::from(width),
1478 height: f64::from(height),
1479 },
1480 aspect_ratio: None,
1481 rotation: Rotation::Deg0,
1482 }
1483 }
1484
1485 #[test]
1486 fn tab_detail_uses_effective_diagonal_and_stream_marker() {
1487 let display = display_option("display-a", "Panel");
1488 let mut group = GroupDraft::new(0);
1489 group.target_id = Some(display.id.clone());
1490 assert_eq!(group_tab_detail(&group, &[display]), "24″");
1491 group.stream_only = true;
1492 assert_eq!(group_tab_detail(&group, &[]), "--");
1493 group.stream_diagonal = "24.4".into();
1494 assert_eq!(group_tab_detail(&group, &[]), "24″");
1495 }
1496
1497 #[test]
1498 fn sunshine_web_panel_uses_the_https_port_after_the_base_port() {
1499 assert_eq!(
1500 sunshine_web_url(54_321).unwrap(),
1501 "https://localhost:54322/"
1502 );
1503 assert!(sunshine_web_url(u16::MAX).is_err());
1504 }
1505
1506 #[test]
1507 fn display_settings_uses_the_windows_display_uri() {
1508 assert_eq!(DISPLAY_SETTINGS_URI, "ms-settings:display");
1509 }
1510
1511 #[test]
1512 fn diagonal_override_preserves_native_aspect_ratio() {
1513 let display = display_option("display-a", "Panel");
1514 let (width, height) = dimensions_from_diagonal(
1515 &display,
1516 24.0,
1517 Some(AspectRatio {
1518 width: 16,
1519 height: 9,
1520 }),
1521 )
1522 .unwrap();
1523 assert!((width / height - 16.0 / 9.0).abs() < 0.001);
1524 assert!((width.hypot(height) / MILLIMETERS_PER_INCH - 24.0).abs() < 0.001);
1525 }
1526
1527 #[test]
1528 fn screen_override_supports_native_and_explicit_ratios() {
1529 assert_eq!(screen_override_aspect_ratio(0).unwrap(), None);
1530 assert_eq!(
1531 screen_override_aspect_ratio(1).unwrap(),
1532 Some(AspectRatio {
1533 width: 16,
1534 height: 9
1535 })
1536 );
1537 assert_eq!(
1538 screen_override_aspect_ratio(5).unwrap(),
1539 Some(AspectRatio {
1540 width: 21,
1541 height: 9
1542 })
1543 );
1544 }
1545
1546 #[test]
1547 fn removing_an_inactive_left_tab_keeps_the_same_group_active() {
1548 assert_eq!(active_after_removal(2, 0, 2), 1);
1549 assert_eq!(active_after_removal(0, 1, 2), 0);
1550 assert_eq!(active_after_removal(1, 1, 2), 1);
1551 }
1552
1553 #[test]
1554 fn completes_unique_resolution_prefixes() {
1555 assert_eq!(resolution_suggestion("2", COMMON_WIDTHS), Some("2560"));
1556 assert_eq!(resolution_suggestion("38", COMMON_WIDTHS), Some("3840"));
1557 assert_eq!(resolution_suggestion("21", COMMON_HEIGHTS), Some("2160"));
1558 }
1559
1560 #[test]
1561 fn does_not_guess_ambiguous_or_complete_values() {
1562 assert_eq!(resolution_suggestion("1", COMMON_WIDTHS), None);
1563 assert_eq!(resolution_suggestion("2", COMMON_HEIGHTS), None);
1564 assert_eq!(resolution_suggestion("2560", COMMON_WIDTHS), None);
1565 assert_eq!(resolution_suggestion("", COMMON_WIDTHS), None);
1566 assert_eq!(resolution_suggestion("4K", COMMON_WIDTHS), None);
1567 }
1568
1569 #[test]
1570 fn derives_missing_stream_dimension_from_default_sixteen_by_ten_ratio() {
1571 assert_eq!(
1572 scaled_dimension_suggestion("1920", 10, 16),
1573 Some("1200".into())
1574 );
1575 assert_eq!(
1576 scaled_dimension_suggestion("1080", 16, 10),
1577 Some("1728".into())
1578 );
1579 assert_eq!(
1580 scaled_dimension_suggestion("1921", 10, 16),
1581 Some("1201".into())
1582 );
1583 }
1584
1585 #[test]
1586 fn missing_stream_dimension_suggestion_respects_virtual_mode_bounds() {
1587 assert_eq!(scaled_dimension_suggestion("", 16, 10), None);
1588 assert_eq!(scaled_dimension_suggestion("0", 16, 10), None);
1589 assert_eq!(scaled_dimension_suggestion("4k", 16, 10), None);
1590 assert_eq!(
1591 scaled_dimension_suggestion("10240", 16, 10),
1592 Some("16384".into())
1593 );
1594 assert_eq!(scaled_dimension_suggestion("16384", 16, 10), None);
1595 assert_eq!(
1596 scaled_dimension_suggestion(&u64::MAX.to_string(), 16, 10),
1597 None
1598 );
1599 }
1600
1601 #[test]
1602 fn formats_live_stream_aspect_ratio() {
1603 assert_eq!(stream_aspect_ratio("3840", "2160"), Some("16:9".into()));
1604 assert_eq!(stream_aspect_ratio("2560", "1600"), Some("16:10".into()));
1605 assert_eq!(stream_aspect_ratio("3440", "1440"), Some("43:18".into()));
1606 assert_eq!(stream_aspect_ratio("", "1440"), None);
1607 assert_eq!(stream_aspect_ratio("3840", "0"), None);
1608 assert_eq!(stream_aspect_ratio("4k", "2160"), None);
1609 }
1610
1611 #[test]
1612 fn validates_sunshine_ids_and_stream_rotation_labels() {
1613 assert!(is_guid("{f2b109d3-3184-5c7d-be17-00d066d470a3}"));
1614 assert!(!is_guid("f2b109d3-3184-5c7d-be17-00d066d470a3"));
1615 assert!(!is_guid("{not-a-display-id}"));
1616 assert_eq!(rotation_label(2), Some("Portrait clockwise"));
1617 assert_eq!(rotation_label(4), None);
1618 }
1619
1620 #[test]
1621 fn streaming_target_geometry_uses_selected_physical_ratio() {
1622 let target = stream_target_geometry(
1623 "2560",
1624 "1600",
1625 "24",
1626 AspectRatio {
1627 width: 16,
1628 height: 9,
1629 },
1630 Rotation::Deg90,
1631 )
1632 .unwrap();
1633 assert_eq!(target.native_pixels.width, 2560);
1634 assert_eq!(target.native_pixels.height, 1600);
1635 assert_eq!(
1636 target.aspect_ratio,
1637 Some(AspectRatio {
1638 width: 16,
1639 height: 9
1640 })
1641 );
1642 assert_eq!(target.rotation, Rotation::Deg90);
1643 let PhysicalMeasurement::DiagonalMm(diagonal_mm) = target.physical else {
1644 panic!("stream target should preserve a diagonal measurement");
1645 };
1646 assert!((diagonal_mm - 609.6).abs() < 0.001);
1647 assert!(
1648 stream_target_geometry(
1649 "2560",
1650 "1600",
1651 "",
1652 AspectRatio {
1653 width: 16,
1654 height: 10
1655 },
1656 Rotation::Deg0,
1657 )
1658 .is_err()
1659 );
1660 }
1661
1662 #[test]
1663 fn stream_plan_uses_reference_density_instead_of_raw_target_pixels() {
1664 let mut stream = GroupDraft::new(0);
1665 stream.stream_only = true;
1666 stream.stream_width = "2560".into();
1667 stream.stream_height = "1600".into();
1668 stream.stream_diagonal = "24".into();
1669 stream.stream_refresh = "60".into();
1670 stream.sizing = Some(SizingRequest {
1671 reference: DisplayGeometry {
1672 native_pixels: PixelSize {
1673 width: 1920,
1674 height: 1200,
1675 },
1676 physical: PhysicalMeasurement::DimensionsMm {
1677 width: 960.0,
1678 height: 600.0,
1679 },
1680 aspect_ratio: None,
1681 rotation: Rotation::Deg0,
1682 },
1683 target: test_geometry(2560, 1600),
1684 strategy: SizingStrategy::MatchPhysicalSize,
1685 alignment: 2,
1686 preferred_refresh_millihz: Some(60_000),
1687 });
1688
1689 let validated = validate_group_drafts(&[stream]).unwrap();
1690 let plan = build_mapping_plan(&validated).unwrap();
1691 assert_eq!(plan.groups[0].mode.width, 5120);
1692 assert_eq!(plan.groups[0].mode.height, 3200);
1693 assert_eq!(plan.groups[0].route, MappingRoute::StreamOnly);
1694 }
1695
1696 #[test]
1697 fn orientation_labels_map_to_explicit_rotations() {
1698 let expected = [
1699 Rotation::Deg0,
1700 Rotation::Deg180,
1701 Rotation::Deg90,
1702 Rotation::Deg270,
1703 ];
1704 for (index, rotation) in expected.into_iter().enumerate() {
1705 assert_eq!(rotation_from_index(index as i32), Ok(rotation));
1706 assert_eq!(rotation_index(rotation), index as i32);
1707 }
1708 }
1709
1710 #[test]
1711 fn aspect_ratio_options_are_explicit() {
1712 assert_eq!(
1713 aspect_ratio_from_index(0),
1714 Ok(AspectRatio {
1715 width: 16,
1716 height: 9,
1717 })
1718 );
1719 assert_eq!(
1720 aspect_ratio_from_index(5),
1721 Ok(AspectRatio {
1722 width: 32,
1723 height: 9,
1724 })
1725 );
1726 assert!(aspect_ratio_from_index(6).is_err());
1727 }
1728
1729 #[test]
1730 fn sizing_strategy_options_round_trip() {
1731 for (index, strategy) in [
1732 SizingStrategy::MatchPhysicalSize,
1733 SizingStrategy::RoundedScale,
1734 SizingStrategy::IntegerScale,
1735 ]
1736 .into_iter()
1737 .enumerate()
1738 {
1739 assert_eq!(sizing_strategy_from_index(index as i32), Ok(strategy));
1740 assert_eq!(sizing_strategy_index(strategy), index as i32);
1741 }
1742 assert!(sizing_strategy_from_index(3).is_err());
1743 }
1744
1745 #[test]
1746 fn reference_options_exclude_the_selected_physical_display() {
1747 let displays = vec![
1748 display_option("a", "Alpha"),
1749 display_option("b", "Beta"),
1750 display_option("c", "Gamma"),
1751 ];
1752 let candidates = reference_candidate_indices(&displays, Some("a"));
1753 assert_eq!(candidates, vec![1, 2]);
1754 assert_eq!(
1755 reference_selection_index(
1756 &displays,
1757 &candidates,
1758 Some(&ReferenceSource::Display("b".into()))
1759 ),
1760 0
1761 );
1762
1763 let candidates = reference_candidate_indices(&displays, Some("b"));
1764 assert_eq!(candidates, vec![0, 2]);
1765 assert_eq!(
1766 reference_selection_index(
1767 &displays,
1768 &candidates,
1769 Some(&ReferenceSource::Display("b".into()))
1770 ),
1771 0
1772 );
1773 }
1774
1775 #[test]
1776 fn a_single_physical_display_leaves_only_manual_reference() {
1777 let displays = vec![display_option("only", "Only")];
1778 let candidates = reference_candidate_indices(&displays, Some("only"));
1779 assert!(candidates.is_empty());
1780 assert_eq!(
1781 reference_selection_index(
1782 &displays,
1783 &candidates,
1784 Some(&ReferenceSource::Display("only".into()))
1785 ),
1786 0
1787 );
1788 assert_eq!(
1789 reference_selection_index(&displays, &candidates, Some(&ReferenceSource::Manual)),
1790 0
1791 );
1792 }
1793
1794 #[test]
1795 fn diagnostic_preview_is_bounded_and_keeps_error_context() {
1796 let records = (0..45)
1797 .map(|index| Record {
1798 time: index * 1_000,
1799 local_time: format!("time-{index:02}"),
1800 level: if index == 44 {
1801 Level::Error
1802 } else {
1803 Level::Info
1804 },
1805 module: "controller".into(),
1806 stage: "start".into(),
1807 mapping_session_id: "42-1".into(),
1808 message: if index == 44 {
1809 "record-44\ncleanup context".into()
1810 } else {
1811 format!("record-{index}")
1812 },
1813 })
1814 .collect::<Vec<_>>();
1815
1816 let preview = format_recent_diagnostics(&records);
1817 assert!(!preview.contains("record-4\n"));
1818 assert!(preview.contains("record-5"));
1819 assert!(preview.contains("ERROR · controller/start · 42-1"));
1820 assert!(preview.starts_with("[time-44] ERROR"));
1821 assert!(preview.ends_with("record-5"));
1822 assert!(!preview.contains("record-44\ncleanup"));
1823 }
1824
1825 #[test]
1826 fn mixed_gui_groups_build_a_real_mapping_plan() {
1827 let mut mirror = GroupDraft::new(0);
1828 mirror.target_id = Some("physical-a".into());
1829 let mut stream = GroupDraft::new(3);
1830 stream.stream_only = true;
1831 stream.stream_width = "5120".into();
1832 stream.stream_height = "2880".into();
1833 stream.stream_diagonal = "48.8".into();
1834 stream.stream_refresh = "60".into();
1835 stream.sizing = Some(SizingRequest {
1836 reference: test_geometry(2560, 1440),
1837 target: test_geometry(5120, 2880),
1838 strategy: SizingStrategy::MatchPhysicalSize,
1839 alignment: 2,
1840 preferred_refresh_millihz: Some(60_000),
1841 });
1842
1843 let validated = validate_group_drafts(&[mirror, stream]).unwrap();
1844 let plan = build_mapping_plan(&validated).expect("mixed plan should be valid");
1845 assert_eq!(plan.groups[0].id, 0);
1846 assert_eq!(
1847 plan.groups[0].route,
1848 MappingRoute::Mirror {
1849 target: "physical-a".into()
1850 }
1851 );
1852 assert_eq!(plan.groups[1].id, 3);
1853 assert_eq!(plan.groups[1].route, MappingRoute::StreamOnly);
1854 assert_eq!(plan.groups[1].mode.width, 5120);
1855 assert_eq!(plan.groups[1].mode.height, 2880);
1856 assert_eq!(plan.groups[1].mode.refresh_numerator, 60);
1857 assert_eq!(plan.groups[1].mode.refresh_denominator, 1);
1858 }
1859
1860 #[test]
1861 fn persisted_stream_group_round_trips_every_user_setting() {
1862 let mut original = GroupDraft::new(5);
1863 original.stream_only = true;
1864 original.reference_source = Some(ReferenceSource::Manual);
1865 original.stream_width = "4640".into();
1866 original.stream_height = "2610".into();
1867 original.stream_diagonal = "31.5".into();
1868 original.stream_refresh = "119.88".into();
1869 original.stream_aspect_ratio_index = 4;
1870 original.stream_rotation_index = 2;
1871 original.sizing = Some(SizingRequest {
1872 reference: test_geometry(3840, 2160),
1873 target: DisplayGeometry {
1874 native_pixels: PixelSize {
1875 width: 4640,
1876 height: 2610,
1877 },
1878 physical: PhysicalMeasurement::DiagonalMm(31.5 * MILLIMETERS_PER_INCH),
1879 aspect_ratio: Some(AspectRatio {
1880 width: 21,
1881 height: 9,
1882 }),
1883 rotation: Rotation::Deg90,
1884 },
1885 strategy: SizingStrategy::RoundedScale,
1886 alignment: 2,
1887 preferred_refresh_millihz: Some(119_880),
1888 });
1889
1890 let config = original.validate().unwrap().to_config();
1891 let restored = GroupDraft::from_config(&config);
1892 assert_eq!(restored.id, 5);
1893 assert!(restored.stream_only);
1894 assert_eq!(restored.reference_source, Some(ReferenceSource::Manual));
1895 assert_eq!(restored.sizing, original.sizing);
1896 assert_eq!(restored.stream_width, "4640");
1897 assert_eq!(restored.stream_height, "2610");
1898 assert_eq!(restored.stream_diagonal, "31.5");
1899 assert_eq!(restored.stream_refresh, "119.88");
1900 assert_eq!(restored.stream_aspect_ratio_index, 4);
1901 assert_eq!(restored.stream_rotation_index, 2);
1902 }
1903
1904 #[test]
1905 fn mirror_rotation_reaches_the_mapping_plan() {
1906 let mut mirror = GroupDraft::new(0);
1907 mirror.target_id = Some("physical-a".into());
1908 mirror.sizing = Some(SizingRequest {
1909 reference: test_geometry(3840, 2160),
1910 target: DisplayGeometry {
1911 rotation: Rotation::Deg90,
1912 ..test_geometry(2560, 1440)
1913 },
1914 strategy: SizingStrategy::MatchPhysicalSize,
1915 alignment: 2,
1916 preferred_refresh_millihz: Some(240_000),
1917 });
1918
1919 let validated = validate_group_drafts(&[mirror]).unwrap();
1920 let plan = build_mapping_plan(&validated).unwrap();
1921 assert_eq!(plan.groups[0].rotation, Rotation::Deg90);
1922 }
1923
1924 #[test]
1925 fn invalid_stream_screen_is_rejected_before_controller_start() {
1926 let mut stream = GroupDraft::new(0);
1927 stream.stream_only = true;
1928 stream.stream_width = "4k".into();
1929 assert!(validate_group_drafts(&[stream]).is_err());
1930 }
1931}
1932