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.
文件
../ bininputuiconfig.rs81.2 KBcontrol.rs4.1 KBcontroller.rs43.6 KBdiagnostics.rs24.2 KBdisplay.rs29.8 KBgeometry.rs21.4 KBgpu_renderer.rs37.4 KBinput.rs79.7 KBlaunch_broker.rs10.3 KBlib.rs350 Blimits.rs714 Bmain.rs24.4 KBmapping.rs46.5 KBnetwork.rs9.2 KBrenderer.rs20.6 KBsession_gate.rs14.9 KBsunshine.rs12.2 KBui.rs68.5 KBvirtual_display.rs7.2 KBwin32_flyout.rs3.4 KBwin32_tray.rs22.3 KBwindow_migration.rs43.8 KBsrc/ui.rs68.5 KB
1
use std::error::Error;2
use std::process::Command;3
use std::rc::Rc;4
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};5
use std::sync::{Arc, Mutex};6
use std::time::Duration;7
8
use crate::config::{9
AppConfig, ConfigProfileStore, ConfigProfiles, DisplayOverrideStore, DisplayOverrides,10
MAX_CONFIG_PROFILES, ProfileRevision, ReferenceSource,11
};12
use crate::control::{TrayInstance, listen_for_config_reload, listen_for_shutdown};13
use crate::controller::{Controller, ControllerEvent, DisplayOption};14
use crate::diagnostics::{self, Level};15
use crate::geometry::{16
AspectRatio, DisplayGeometry, PhysicalMeasurement, PixelSize, Rotation, SizingRequest,17
SizingResult, SizingStrategy,18
};19
use crate::limits::{20
MAX_OUTPUTS, MAX_VIRTUAL_DIMENSION, MILLIMETERS_PER_INCH, valid_physical_millimeters,21
};22
use crate::mapping::MappingPlan;23
use crate::network::preferred_lan_ipv4;24
use crate::session_gate::VirtualMode;25
use crate::win32_flyout;26
use crate::win32_tray::{NativeTray, TrayAction, TrayHandle};27
use slint::winit_030::winit::event::WindowEvent;28
use slint::winit_030::winit::platform::windows::{CornerPreference, WindowAttributesExtWindows};29
use slint::winit_030::{EventResult as WinitEventResult, WinitWindowAccessor};30
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};31
slint::include_modules!();32
33
const STREAM_ONLY_ID: &str = "__stream_only__";34
const DISPLAY_SETTINGS_URI: &str = "ms-settings:display";35
const PROJECT_HOME_URL: &str = "https://github.com/EvanZhu0721/SBMS";36
static INTRO_GENERATION: AtomicU64 = AtomicU64::new(0);37
static PROFILE_SAVE_GENERATION: AtomicU64 = AtomicU64::new(0);38
39
mod diagnostic_view;40
mod geometry_view;41
mod handlers;42
mod projection;43
mod state;44
45
#[cfg(test)]46
use diagnostic_view::format_recent_diagnostics;47
use diagnostic_view::{open_log_folder, populate_diagnostics};48
use geometry_view::*;49
use 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
};54
use projection::{hydrate_group, project_group_telemetry, snapshot_group, update_tab_projection};55
use 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
61
type 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
69
pub 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
attributes75
.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
&& flyout108
.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::Propagate124
});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 deciding366
// 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
386
fn active_after_removal(current: usize, removed: usize, remaining: usize) -> usize {387
debug_assert!(remaining > 0);388
if removed < current {389
current - 1390
} else if removed == current {391
current.min(remaining - 1)392
} else {393
current394
}395
}396
397
fn 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_overrides421
.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::Starting462
} else {463
SunshineState::Failed464
};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 == id488
&& 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
601
fn sunshine_web_url(base_port: u16) -> Result<String, String> {602
let web_port = base_port603
.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
608
fn 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
617
fn 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
625
fn 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
633
fn 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_path637
.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
explorer648
.spawn()649
.map(|_| ())650
.map_err(|error| format!("Couldn’t open the profile folder: {error}"))651
}652
653
fn 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
664
fn 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
679
fn is_guid(value: &str) -> bool {680
let bytes = value.as_bytes();681
bytes.len() == 38682
&& 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
691
fn 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_inches700
&& 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
712
fn 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
718
fn 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
group725
.target_id726
.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
733
fn display_diagonal_inches(display: &DisplayOption) -> Option<f64> {734
Some(735
display736
.physical_width_mm?737
.hypot(display.physical_height_mm?)738
/ MILLIMETERS_PER_INCH,739
)740
}741
742
fn dimensions_from_diagonal(743
display: &DisplayOption,744
diagonal_inches: f64,745
aspect_ratio: Option<AspectRatio>,746
) -> Option<(f64, f64)> {747
let (width, height) = aspect_ratio748
.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
763
fn 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
target807
};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
824
fn 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_INCH845
)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
display855
.physical_override_inches856
.map(|value| format!("{value:.1}"))857
.unwrap_or_default()858
.into(),859
);860
ui.set_screen_size_aspect_ratio(861
display862
.physical_override_aspect_ratio863
.map(|ratio| aspect_ratio_option_index(ratio) + 1)864
.unwrap_or(0),865
);866
}867
update_screen_size_preview(ui, displays);868
}869
870
fn 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
927
fn 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
951
fn 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
964
fn 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
direction977
);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
None995
}996
}997
}998
999
fn 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
1033
fn validate_group_drafts(groups: &[GroupDraft]) -> Result<Vec<ValidatedGroupDraft>, String> {1034
groups.iter().map(GroupDraft::validate).collect()1035
}1036
1037
fn build_mapping_plan(groups: &[ValidatedGroupDraft]) -> Result<MappingPlan, String> {1038
let requests = groups1039
.iter()1040
.map(ValidatedGroupDraft::to_mapping_request)1041
.collect::<Result<Vec<_>, String>>()?;1042
MappingPlan::new(requests).map_err(|error| error.to_string())1043
}1044
1045
fn 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
1052
fn 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 + 11056
{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
1065
fn 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_indices1073
.iter()1074
.map(|index| &displays[*index])1075
.collect();1076
let mut names: Vec<_> = candidates1077
.iter()1078
.map(|display| SharedString::from(display.name.as_str()))1079
.collect();1080
let mut labels: Vec<_> = candidates1081
.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<_> = candidates1093
.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
1106
fn reference_candidate_indices(displays: &[DisplayOption], target_id: Option<&str>) -> Vec<usize> {1107
displays1108
.iter()1109
.enumerate()1110
.filter_map(|(index, display)| (target_id != Some(display.id.as_str())).then_some(index))1111
.collect()1112
}1113
1114
fn 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)) => candidates1123
.iter()1124
.position(|index| displays[*index].id == *id)1125
.map(|index| index as i32)1126
.unwrap_or(if candidates.is_empty() {1127
manual_index1128
} else {1129
01130
}),1131
None => {1132
if candidates.is_empty() {1133
manual_index1134
} else {1135
01136
}1137
}1138
}1139
}1140
1141
fn 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
1155
fn loaded_groups(snapshot: crate::config::ProfileSnapshot) -> LoadedGroups {1156
let groups = snapshot1157
.config1158
.groups1159
.iter()1160
.map(GroupDraft::from_config)1161
.collect::<Vec<_>>();1162
let active = snapshot1163
.config1164
.selected_group_id1165
.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
1174
fn 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
1185
fn 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_revision1219
.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.revision1236
),1237
);1238
true1239
}1240
1241
fn 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
1249
fn project_profiles(ui: &QuickAccess, profiles: &ConfigProfiles) {1250
let names = profiles1251
.profiles1252
.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
1260
fn 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
1283
fn 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_revision1300
.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_revision1309
.lock()1310
.expect("config profile revision poisoned") = snapshot.profile;1311
finish_profile_save(ui.as_weak(), save_generation, true);1312
true1313
}1314
Err(error) => {1315
finish_profile_save(ui.as_weak(), save_generation, false);1316
surface_persistence_error(ui, &error.to_string());1317
false1318
}1319
}1320
}1321
1322
fn 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
generation1326
}1327
1328
fn 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
1346
fn 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
1352
fn 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
1361
fn 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 cache1371
// after Windows clears a hidden window during a display-topology change.1372
// Taking a snapshot temporarily selects a new repaint buffer and clears1373
// 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
1395
fn 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) == generation1419
&& 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) == generation1428
&& let Some(ui) = ui.upgrade()1429
{1430
ui.set_intro_active(false);1431
}1432
});1433
}1434
1435
#[cfg(test)]1436
mod 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: 91535
})1536
);1537
assert_eq!(1538
screen_override_aspect_ratio(5).unwrap(),1539
Some(AspectRatio {1540
width: 21,1541
height: 91542
})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
None1598
);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: 91640
})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: 101655
},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
01761
);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
01772
);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
01787
);1788
assert_eq!(1789
reference_selection_index(&displays, &candidates, Some(&ReferenceSource::Manual)),1790
01791
);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::Error1802
} else {1803
Level::Info1804
},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