Extend output profiles with monitor reconciliation and mirroring
This commit is contained in:
1 parent
ac184576f6
commit
d14cd3cbd2
23 files changed
+3614
-474
No files matched your search
@@ -0,0 +1,654 @@
|
||||
//! 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>,
|
||||
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;
|
||||
}
|
||||
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 = 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()));
|
||||
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 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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user