Open the four display modes from XF86Display or the external-monitor bar icon. Reuse output reconciliation and confirmation for manual layout changes, and notify the shell when monitor state changes. Release backdrop locks before render-element queries to prevent blurred popups from freezing the compositor. Cover popup open, update and close with regression tests. Share translucent card styles between display modes and capture sources, show focus rings during keyboard navigation, and align picker buttons, label sizing and footer spacing with system modals.
713 lines
26 KiB
Rust
713 lines
26 KiB
Rust
//! Pure monitor selection. DRM handles and side effects never enter this module.
|
|
use std::collections::{BTreeSet, HashSet};
|
|
|
|
use crate::config::{LidPolicy, OutputLayout, OutputProfile, OutputSettings, OutputTransform};
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) struct Monitor {
|
|
/// Qualified hardware connector (GPU identity + connector), never a runtime Output handle.
|
|
pub key: String,
|
|
pub connector: String,
|
|
pub identity: String,
|
|
pub aliases: Vec<String>,
|
|
pub internal: bool,
|
|
pub usable: bool,
|
|
}
|
|
|
|
impl Monitor {
|
|
pub fn matches(&self, selector: &str) -> bool {
|
|
selector == self.connector || selector == self.identity || self.aliases.iter().any(|alias| alias == selector)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct DesiredOutput {
|
|
pub key: String,
|
|
pub settings: OutputSettings,
|
|
/// A mirror target has no workspace ownership of its own.
|
|
pub mirror_source: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(crate) struct DesiredOutputConfiguration {
|
|
pub profile: Option<String>,
|
|
pub outputs: Vec<DesiredOutput>,
|
|
pub suspend: bool,
|
|
}
|
|
|
|
/// Overrides expire on a *material* topology/lid change, independent of event ordering.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct ManualOverride {
|
|
pub profile: Option<String>,
|
|
pub internal: Option<bool>,
|
|
pub layout: Option<OutputLayout>,
|
|
topology: Option<(BTreeSet<String>, bool)>,
|
|
}
|
|
|
|
impl ManualOverride {
|
|
pub fn observe(&mut self, monitors: &[Monitor], lid_closed: bool, profiles: &[OutputProfile]) {
|
|
let topology = (
|
|
monitors
|
|
.iter()
|
|
.map(|monitor| format!("{}={}", monitor.key, monitor.identity))
|
|
.collect(),
|
|
lid_closed,
|
|
);
|
|
if self.topology.as_ref().is_some_and(|old| old != &topology) {
|
|
self.profile = None;
|
|
self.internal = None;
|
|
self.layout = None;
|
|
}
|
|
if self.profile.as_ref().is_some_and(|name| {
|
|
!profiles
|
|
.iter()
|
|
.any(|profile| profile.name == *name && profile_matches(profile, monitors, lid_closed))
|
|
}) {
|
|
self.profile = None;
|
|
}
|
|
self.topology = Some(topology);
|
|
}
|
|
}
|
|
|
|
fn profile_matches(profile: &OutputProfile, monitors: &[Monitor], lid_closed: bool) -> bool {
|
|
let mut matched = HashSet::new();
|
|
if profile.outputs.iter().any(|settings| {
|
|
monitors
|
|
.iter()
|
|
.filter(|monitor| monitor.matches(&settings.matcher))
|
|
.any(|monitor| !matched.insert(&monitor.key))
|
|
}) {
|
|
return false;
|
|
}
|
|
profile.lid_closed.is_none_or(|required| required == lid_closed)
|
|
&& profile
|
|
.outputs
|
|
.iter()
|
|
.filter(|settings| settings.required)
|
|
.all(|settings| {
|
|
// Ambiguous selectors cannot silently configure two monitors as one.
|
|
monitors
|
|
.iter()
|
|
.filter(|monitor| monitor.matches(&settings.matcher))
|
|
.count()
|
|
== 1
|
|
})
|
|
}
|
|
|
|
pub(crate) fn defaults(matcher: String) -> OutputSettings {
|
|
OutputSettings {
|
|
auto_refresh: false,
|
|
matcher,
|
|
required: true,
|
|
enabled: true,
|
|
mode: None,
|
|
scale: 1.0,
|
|
transform: OutputTransform::Normal,
|
|
position: None,
|
|
}
|
|
}
|
|
|
|
/// Most required monitors wins; equal specificity retains config order.
|
|
/// Unlisted outputs keep working in extend profiles. Exclusive layouts deliberately filter them.
|
|
pub(crate) fn select_profile(
|
|
monitors: &[Monitor],
|
|
lid_closed: bool,
|
|
profiles: &[OutputProfile],
|
|
manual: &ManualOverride,
|
|
) -> DesiredOutputConfiguration {
|
|
let selected = manual
|
|
.profile
|
|
.as_ref()
|
|
.and_then(|name| {
|
|
profiles
|
|
.iter()
|
|
.find(|profile| profile.name == *name && profile_matches(profile, monitors, lid_closed))
|
|
})
|
|
.or_else(|| {
|
|
profiles
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, profile)| profile_matches(profile, monitors, lid_closed))
|
|
.max_by_key(|(index, profile)| {
|
|
(
|
|
profile.outputs.iter().filter(|output| output.required).count(),
|
|
std::cmp::Reverse(*index),
|
|
)
|
|
})
|
|
.map(|(_, profile)| profile)
|
|
});
|
|
let layout = manual
|
|
.layout
|
|
.unwrap_or_else(|| selected.map_or(OutputLayout::Extend, |profile| profile.layout));
|
|
let manage_lid = selected.is_none_or(|profile| profile.lid_policy == LidPolicy::DockOrSuspend);
|
|
let external = monitors.iter().any(|monitor| monitor.usable && !monitor.internal);
|
|
let mut outputs = monitors
|
|
.iter()
|
|
.map(|monitor| {
|
|
let mut settings = selected
|
|
.and_then(|profile| {
|
|
profile
|
|
.outputs
|
|
.iter()
|
|
.find(|settings| monitor.matches(&settings.matcher))
|
|
})
|
|
.cloned()
|
|
.unwrap_or_else(|| defaults(monitor.identity.clone()));
|
|
if manual.layout.is_some() {
|
|
settings.enabled = true;
|
|
}
|
|
settings.enabled &= monitor.usable;
|
|
settings.enabled &= match layout {
|
|
OutputLayout::InternalOnly => monitor.internal,
|
|
OutputLayout::ExternalOnly => !monitor.internal,
|
|
OutputLayout::Extend | OutputLayout::Mirror => true,
|
|
};
|
|
if monitor.internal {
|
|
if let Some(enabled) = manual.internal {
|
|
settings.enabled = enabled && monitor.usable;
|
|
} else if manage_lid && lid_closed && external {
|
|
settings.enabled = false;
|
|
}
|
|
}
|
|
DesiredOutput {
|
|
key: monitor.key.clone(),
|
|
settings,
|
|
mirror_source: None,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !outputs.iter().any(|output| output.settings.enabled) {
|
|
// Policy never disables the last usable display, even for an inapplicable exclusive layout.
|
|
if let Some((index, _)) = monitors
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, monitor)| monitor.usable)
|
|
.min_by_key(|(_, monitor)| (!monitor.internal, &monitor.key))
|
|
{
|
|
outputs[index].settings = defaults(monitors[index].identity.clone());
|
|
}
|
|
}
|
|
if layout == OutputLayout::Mirror {
|
|
let source = selected
|
|
.and_then(|profile| profile.mirror_source.as_ref())
|
|
.and_then(|selector| {
|
|
monitors
|
|
.iter()
|
|
.zip(&outputs)
|
|
.find(|(monitor, output)| monitor.matches(selector) && output.settings.enabled)
|
|
.map(|(_, output)| output.key.clone())
|
|
})
|
|
.or_else(|| {
|
|
outputs
|
|
.iter()
|
|
.filter(|output| output.settings.enabled)
|
|
.map(|output| output.key.clone())
|
|
.min()
|
|
});
|
|
if let Some(source) = source {
|
|
for output in &mut outputs {
|
|
if output.settings.enabled && output.key != source {
|
|
output.mirror_source = Some(source.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
DesiredOutputConfiguration {
|
|
profile: selected.map(|profile| profile.name.clone()),
|
|
outputs,
|
|
suspend: manage_lid && lid_closed && !external,
|
|
}
|
|
}
|
|
|
|
/// Restore actual settings only for surviving hardware; new monitors remain safe fallbacks.
|
|
pub(crate) fn restore_configuration(
|
|
previous: &DesiredOutputConfiguration,
|
|
monitors: &[Monitor],
|
|
) -> DesiredOutputConfiguration {
|
|
let mut restored = previous.clone();
|
|
restored
|
|
.outputs
|
|
.retain(|output| monitors.iter().any(|monitor| monitor.key == output.key));
|
|
for monitor in monitors {
|
|
if !restored.outputs.iter().any(|output| output.key == monitor.key) {
|
|
let mut settings = defaults(monitor.identity.clone());
|
|
settings.enabled = false;
|
|
restored.outputs.push(DesiredOutput {
|
|
key: monitor.key.clone(),
|
|
settings,
|
|
mirror_source: None,
|
|
});
|
|
}
|
|
}
|
|
let source_keys = restored
|
|
.outputs
|
|
.iter()
|
|
.filter(|output| output.settings.enabled)
|
|
.map(|output| output.key.clone())
|
|
.collect::<HashSet<_>>();
|
|
for output in &mut restored.outputs {
|
|
if output
|
|
.mirror_source
|
|
.as_ref()
|
|
.is_some_and(|source| !source_keys.contains(source))
|
|
{
|
|
output.mirror_source = None;
|
|
}
|
|
}
|
|
if !restored.outputs.iter().any(|output| output.settings.enabled) {
|
|
return select_profile(monitors, false, &[], &ManualOverride::default());
|
|
}
|
|
restored
|
|
}
|
|
|
|
/// A diff carries only affected connectors; removing a connector is an explicit disable.
|
|
pub(crate) fn changed_outputs<'a>(
|
|
old: &'a DesiredOutputConfiguration,
|
|
new: &'a DesiredOutputConfiguration,
|
|
) -> Vec<&'a str> {
|
|
let mut keys = HashSet::new();
|
|
old.outputs
|
|
.iter()
|
|
.chain(&new.outputs)
|
|
.filter_map(|output| {
|
|
let key = output.key.as_str();
|
|
if !keys.insert(key) {
|
|
return None;
|
|
}
|
|
let previous = old.outputs.iter().find(|candidate| candidate.key == key);
|
|
let next = new.outputs.iter().find(|candidate| candidate.key == key);
|
|
let same = match (previous, next) {
|
|
(Some(previous), Some(next)) => {
|
|
previous.settings.same_configuration(&next.settings) && previous.mirror_source == next.mirror_source
|
|
}
|
|
(None, None) => true,
|
|
_ => false,
|
|
};
|
|
(!same).then_some(key)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct Debouncer {
|
|
deadline: Option<std::time::Duration>,
|
|
}
|
|
|
|
impl Debouncer {
|
|
pub fn request(&mut self, now: std::time::Duration, active: bool) -> Option<std::time::Duration> {
|
|
self.deadline = active.then(|| now + std::time::Duration::from_millis(150));
|
|
self.deadline
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.deadline = None;
|
|
}
|
|
|
|
pub fn take_due(&mut self, now: std::time::Duration) -> bool {
|
|
if self.deadline.is_some_and(|deadline| deadline <= now) {
|
|
self.deadline = None;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) struct Placement {
|
|
pub key: String,
|
|
pub size: (i32, i32),
|
|
pub previous: Option<[i32; 2]>,
|
|
}
|
|
|
|
/// Explicit positions win. Preserve an automatic output's old position when
|
|
/// it still fits; append new/conflicting outputs in stable hardware-key order.
|
|
pub(crate) fn arrange_positions(
|
|
desired: &mut DesiredOutputConfiguration,
|
|
placements: &[Placement],
|
|
) -> Result<(), String> {
|
|
let mut occupied = Vec::new();
|
|
let mut automatic = Vec::new();
|
|
for (index, output) in desired.outputs.iter().enumerate() {
|
|
if !output.settings.enabled || output.mirror_source.is_some() {
|
|
continue;
|
|
}
|
|
let placement = placements
|
|
.iter()
|
|
.find(|placement| placement.key == output.key)
|
|
.ok_or("missing output dimensions")?;
|
|
if let Some(position) = output.settings.position {
|
|
occupied.push((position[0], position[1], placement.size.0, placement.size.1));
|
|
} else {
|
|
automatic.push((index, placement));
|
|
}
|
|
}
|
|
automatic.sort_by(|(_, a), (_, b)| (a.previous.is_none(), &a.key).cmp(&(b.previous.is_none(), &b.key)));
|
|
for (index, placement) in automatic {
|
|
let fits = |position: [i32; 2]| {
|
|
let (x, y, w, h) = (
|
|
position[0] as i64,
|
|
position[1] as i64,
|
|
placement.size.0 as i64,
|
|
placement.size.1 as i64,
|
|
);
|
|
!occupied.iter().any(|&(ox, oy, ow, oh)| {
|
|
x < ox as i64 + ow as i64 && x + w > ox as i64 && y < oy as i64 + oh as i64 && y + h > oy as i64
|
|
})
|
|
};
|
|
let previous = placement.previous.filter(|position| fits(*position));
|
|
let position = if let Some(previous) = previous {
|
|
previous
|
|
} else {
|
|
let right = occupied
|
|
.iter()
|
|
.map(|&(x, _, w, _)| x as i64 + w as i64)
|
|
.max()
|
|
.unwrap_or(0)
|
|
.max(0);
|
|
let x = i32::try_from(right).map_err(|_| "automatic output position overflows")?;
|
|
x.checked_add(placement.size.0)
|
|
.ok_or("automatic output geometry overflows")?;
|
|
[x, 0]
|
|
};
|
|
desired.outputs[index].settings.position = Some(position);
|
|
occupied.push((position[0], position[1], placement.size.0, placement.size.1));
|
|
}
|
|
let source_positions = desired
|
|
.outputs
|
|
.iter()
|
|
.filter(|output| output.mirror_source.is_none())
|
|
.map(|output| (output.key.clone(), output.settings.position))
|
|
.collect::<std::collections::HashMap<_, _>>();
|
|
for output in &mut desired.outputs {
|
|
if let Some(source) = &output.mirror_source {
|
|
output.settings.position = source_positions.get(source).copied().flatten();
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod placement_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn automatic_placement_is_stable_and_only_moves_overlapping_neighbors() {
|
|
let monitors = [
|
|
Monitor {
|
|
key: "a".into(),
|
|
connector: "a".into(),
|
|
identity: "a".into(),
|
|
aliases: vec![],
|
|
internal: true,
|
|
usable: true,
|
|
},
|
|
Monitor {
|
|
key: "b".into(),
|
|
connector: "b".into(),
|
|
identity: "b".into(),
|
|
aliases: vec![],
|
|
internal: false,
|
|
usable: true,
|
|
},
|
|
];
|
|
let mut desired = select_profile(&monitors, false, &[], &ManualOverride::default());
|
|
let placements = [
|
|
Placement {
|
|
key: "b".into(),
|
|
size: (100, 100),
|
|
previous: Some([100, 0]),
|
|
},
|
|
Placement {
|
|
key: "a".into(),
|
|
size: (100, 100),
|
|
previous: Some([0, 0]),
|
|
},
|
|
];
|
|
arrange_positions(&mut desired, &placements).unwrap();
|
|
assert_eq!(desired.outputs[0].settings.position, Some([0, 0]));
|
|
assert_eq!(desired.outputs[1].settings.position, Some([100, 0]));
|
|
let mut expanded = select_profile(&monitors, false, &[], &ManualOverride::default());
|
|
let placements = [
|
|
Placement {
|
|
key: "a".into(),
|
|
size: (150, 100),
|
|
previous: Some([0, 0]),
|
|
},
|
|
Placement {
|
|
key: "b".into(),
|
|
size: (100, 100),
|
|
previous: Some([100, 0]),
|
|
},
|
|
];
|
|
arrange_positions(&mut expanded, &placements).unwrap();
|
|
assert_eq!(expanded.outputs[0].settings.position, Some([0, 0]));
|
|
assert_eq!(expanded.outputs[1].settings.position, Some([150, 0]));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn monitor(name: &str, internal: bool) -> Monitor {
|
|
Monitor {
|
|
key: name.into(),
|
|
connector: name.into(),
|
|
identity: format!("edid:{name}"),
|
|
aliases: vec![],
|
|
internal,
|
|
usable: true,
|
|
}
|
|
}
|
|
|
|
fn profile(name: &str, outputs: &[&str], layout: OutputLayout) -> OutputProfile {
|
|
OutputProfile {
|
|
name: name.into(),
|
|
outputs: outputs.iter().map(|name| defaults((*name).into())).collect(),
|
|
layout,
|
|
confirm_timeout: 15,
|
|
lid_policy: LidPolicy::DockOrSuspend,
|
|
lid_closed: None,
|
|
mirror_source: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn manual_layouts_reuse_profiles_and_expire_on_monitor_changes() {
|
|
let connected = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let mut settings = defaults("DP-1".into());
|
|
settings.enabled = false;
|
|
settings.scale = 1.5;
|
|
let mut mobile = profile("mobile", &["eDP-1"], OutputLayout::InternalOnly);
|
|
mobile.outputs.push(settings);
|
|
let profiles = [mobile];
|
|
for layout in [
|
|
OutputLayout::InternalOnly,
|
|
OutputLayout::ExternalOnly,
|
|
OutputLayout::Extend,
|
|
OutputLayout::Mirror,
|
|
] {
|
|
let mut manual = ManualOverride {
|
|
layout: Some(layout),
|
|
..ManualOverride::default()
|
|
};
|
|
manual.observe(&connected, false, &profiles);
|
|
let desired = select_profile(&connected, false, &profiles, &manual);
|
|
assert_eq!(
|
|
desired.outputs[0].settings.enabled,
|
|
layout != OutputLayout::ExternalOnly
|
|
);
|
|
assert_eq!(
|
|
desired.outputs[1].settings.enabled,
|
|
layout != OutputLayout::InternalOnly
|
|
);
|
|
assert_eq!(
|
|
desired.outputs[1].settings.scale, 1.5,
|
|
"manual layout must preserve configured scale"
|
|
);
|
|
assert_eq!(
|
|
desired
|
|
.outputs
|
|
.iter()
|
|
.filter(|output| output.mirror_source.is_some())
|
|
.count(),
|
|
usize::from(layout == OutputLayout::Mirror)
|
|
);
|
|
manual.observe(&connected[..1], false, &profiles);
|
|
assert_eq!(manual.layout, None);
|
|
assert!(
|
|
select_profile(&connected[..1], false, &profiles, &manual).outputs[0]
|
|
.settings
|
|
.enabled
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn specificity_and_config_order() {
|
|
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let profiles = [
|
|
profile("mobile", &["eDP-1"], OutputLayout::Extend),
|
|
profile("desk", &["eDP-1", "DP-1"], OutputLayout::Extend),
|
|
profile("tie", &["DP-1", "eDP-1"], OutputLayout::Extend),
|
|
];
|
|
assert_eq!(
|
|
select_profile(&monitors, false, &profiles, &ManualOverride::default())
|
|
.profile
|
|
.as_deref(),
|
|
Some("desk")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn layouts_unknown_outputs_and_safe_fallback() {
|
|
let monitors = [
|
|
monitor("eDP-1", true),
|
|
monitor("DP-1", false),
|
|
monitor("HDMI-A-1", false),
|
|
];
|
|
for (layout, count) in [
|
|
(OutputLayout::InternalOnly, 1),
|
|
(OutputLayout::ExternalOnly, 2),
|
|
(OutputLayout::Extend, 3),
|
|
(OutputLayout::Mirror, 3),
|
|
] {
|
|
let desired = select_profile(
|
|
&monitors,
|
|
false,
|
|
&[profile("test", &["eDP-1"], layout)],
|
|
&ManualOverride::default(),
|
|
);
|
|
assert_eq!(
|
|
desired.outputs.iter().filter(|output| output.settings.enabled).count(),
|
|
count
|
|
);
|
|
assert_eq!(
|
|
desired
|
|
.outputs
|
|
.iter()
|
|
.filter(|output| output.mirror_source.is_some())
|
|
.count(),
|
|
if layout == OutputLayout::Mirror { 2 } else { 0 }
|
|
);
|
|
}
|
|
let missing = [profile("absent", &["DP-9"], OutputLayout::ExternalOnly)];
|
|
let fallback = select_profile(&monitors, false, &missing, &ManualOverride::default());
|
|
assert_eq!(fallback.profile, None);
|
|
assert!(fallback.outputs.iter().all(|output| output.settings.enabled));
|
|
let internal = &monitors[..1];
|
|
let mut disabled = profile("disabled", &["eDP-1"], OutputLayout::Extend);
|
|
disabled.outputs[0].enabled = false;
|
|
assert!(
|
|
select_profile(internal, false, &[disabled], &ManualOverride::default()).outputs[0]
|
|
.settings
|
|
.enabled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn override_expires_only_on_material_change_or_invalid_reload() {
|
|
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let profiles = [profile("mobile", &["eDP-1"], OutputLayout::InternalOnly)];
|
|
let mut manual = ManualOverride::default();
|
|
manual.observe(&monitors, false, &profiles);
|
|
manual.profile = Some("mobile".into());
|
|
manual.observe(&monitors, false, &profiles);
|
|
assert_eq!(manual.profile.as_deref(), Some("mobile"));
|
|
manual.observe(&monitors, true, &profiles);
|
|
assert_eq!(manual.profile, None);
|
|
manual.profile = Some("mobile".into());
|
|
manual.observe(&monitors, true, &[]);
|
|
assert_eq!(manual.profile, None);
|
|
}
|
|
|
|
#[test]
|
|
fn lid_dock_undock_and_internal_toggle_never_disable_last_output() {
|
|
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let docked = select_profile(&monitors, true, &[], &ManualOverride::default());
|
|
assert!(!docked.suspend);
|
|
assert!(!docked.outputs[0].settings.enabled);
|
|
assert!(docked.outputs[1].settings.enabled);
|
|
let manual = ManualOverride {
|
|
internal: Some(false),
|
|
..ManualOverride::default()
|
|
};
|
|
let undocked = select_profile(&monitors[..1], true, &[], &manual);
|
|
assert!(undocked.suspend);
|
|
assert!(undocked.outputs[0].settings.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn desired_diff_is_local() {
|
|
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let old = select_profile(&monitors, false, &[], &ManualOverride::default());
|
|
assert!(changed_outputs(&old, &old).is_empty());
|
|
let mut new = old.clone();
|
|
new.outputs[1].settings.scale = 1.5;
|
|
assert_eq!(changed_outputs(&old, &new), vec!["DP-1"]);
|
|
}
|
|
#[test]
|
|
fn mirror_removal_and_extend_restore_single_ownership_plan() {
|
|
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let mut mirror = profile("mirror", &["eDP-1", "DP-1"], OutputLayout::Mirror);
|
|
mirror.mirror_source = Some("eDP-1".into());
|
|
let old = select_profile(&monitors, false, &[mirror.clone()], &ManualOverride::default());
|
|
assert_eq!(old.outputs[1].mirror_source.as_deref(), Some("eDP-1"));
|
|
let removed = select_profile(&monitors[..1], false, &[mirror], &ManualOverride::default());
|
|
assert!(removed.outputs[0].mirror_source.is_none());
|
|
assert!(removed.outputs[0].settings.enabled);
|
|
let extended = select_profile(&monitors, false, &[], &ManualOverride::default());
|
|
assert!(extended.outputs.iter().all(|output| output.mirror_source.is_none()));
|
|
assert_eq!(changed_outputs(&old, &extended), vec!["DP-1"]);
|
|
}
|
|
|
|
#[test]
|
|
fn optional_outputs_and_lid_specific_profiles() {
|
|
let monitors = [monitor("eDP-1", true)];
|
|
let mut mobile = profile("mobile", &["eDP-1", "DP-1"], OutputLayout::Extend);
|
|
mobile.outputs[1].required = false;
|
|
mobile.lid_closed = Some(false);
|
|
assert_eq!(
|
|
select_profile(&monitors, false, &[mobile.clone()], &ManualOverride::default())
|
|
.profile
|
|
.as_deref(),
|
|
Some("mobile")
|
|
);
|
|
assert_eq!(
|
|
select_profile(&monitors, true, &[mobile], &ManualOverride::default()).profile,
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn swapping_monitor_on_same_connector_expires_override() {
|
|
let mut monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let profiles = [profile("mobile", &["eDP-1"], OutputLayout::InternalOnly)];
|
|
let mut manual = ManualOverride::default();
|
|
manual.observe(&monitors, false, &profiles);
|
|
manual.profile = Some("mobile".into());
|
|
monitors[1].identity = "edid:new-monitor".into();
|
|
manual.observe(&monitors, false, &profiles);
|
|
assert!(manual.profile.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn restoration_survives_disconnect_and_keeps_new_monitor_as_emergency_fallback() {
|
|
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
|
let previous = select_profile(&monitors, false, &[], &ManualOverride::default());
|
|
let restored = restore_configuration(&previous, &monitors[..1]);
|
|
assert_eq!(restored.outputs.len(), 1);
|
|
assert!(restored.outputs[0].settings.enabled);
|
|
let unknown = [monitor("HDMI-A-2", false)];
|
|
assert!(restore_configuration(&previous, &unknown).outputs[0].settings.enabled);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod debounce_tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn a_dock_burst_produces_one_reconciliation_after_last_event() {
|
|
let mut debounce = Debouncer::default();
|
|
for time in [0, 40, 110] {
|
|
debounce.request(Duration::from_millis(time), true);
|
|
}
|
|
assert!(!debounce.take_due(Duration::from_millis(150)));
|
|
assert!(!debounce.take_due(Duration::from_millis(259)));
|
|
assert!(debounce.take_due(Duration::from_millis(260)));
|
|
assert!(!debounce.take_due(Duration::from_millis(400)));
|
|
}
|
|
|
|
#[test]
|
|
fn inactivity_cancels_deadlines_and_resume_uses_fresh_state() {
|
|
let mut debounce = Debouncer::default();
|
|
debounce.request(Duration::ZERO, true);
|
|
assert_eq!(debounce.request(Duration::from_millis(30), false), None);
|
|
assert!(!debounce.take_due(Duration::from_secs(10)));
|
|
debounce.request(Duration::from_secs(10), true);
|
|
assert!(debounce.take_due(Duration::from_millis(10150)));
|
|
}
|
|
}
|