Add display mode chooser and align screen-sharing styles
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.
This commit is contained in:
1 parent
5100d533fb
commit
96f5ee442e
30 files changed
+1352
-70
No files matched your search
@@ -62,6 +62,7 @@ Shortcuts** whenever you want to change a shortcut or gesture.
|
||||
| Control | Action |
|
||||
| --- | --- |
|
||||
| Super + Enter | Open a terminal |
|
||||
| Display Fn key (`XF86Display`) | Choose the display mode |
|
||||
| Super + H / J / K / L | Focus left / down / up / right |
|
||||
| Super + Backspace | Return to the last focused window |
|
||||
| Super + Escape | Toggle between the last two visited workspaces on this monitor |
|
||||
|
||||
@@ -551,6 +551,7 @@ pub(crate) fn set_output_profile(state: &mut Ferese, name: &str) -> Result<(), S
|
||||
let known_good = backend.applied_outputs.clone();
|
||||
backend.manual_outputs.profile = (name != "auto").then(|| name.to_owned());
|
||||
backend.manual_outputs.internal = None;
|
||||
backend.manual_outputs.layout = None;
|
||||
let desired = crate::output_policy::select_profile(
|
||||
&backend.monitors,
|
||||
backend.lid.closed(),
|
||||
@@ -584,6 +585,47 @@ pub(crate) fn set_output_profile(state: &mut Ferese, name: &str) -> Result<(), S
|
||||
return Err("profile does not match the connected monitors/lid".into());
|
||||
}
|
||||
arm_output_confirmation(state, known_good, previous);
|
||||
state.notify_monitor_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_output_layout(state: &mut Ferese, layout: crate::config::OutputLayout) -> Result<(), String> {
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.ok_or("display modes require the direct DRM backend")?;
|
||||
if !backend.active || backend.lid.pending() {
|
||||
return Err("output session is inactive or refreshing lid state".into());
|
||||
}
|
||||
let usable = backend
|
||||
.monitors
|
||||
.iter()
|
||||
.filter(|monitor| monitor.usable)
|
||||
.collect::<Vec<_>>();
|
||||
let available = match layout {
|
||||
crate::config::OutputLayout::InternalOnly => usable.iter().any(|monitor| monitor.internal),
|
||||
crate::config::OutputLayout::ExternalOnly => usable.iter().any(|monitor| !monitor.internal),
|
||||
crate::config::OutputLayout::Extend | crate::config::OutputLayout::Mirror => usable.len() >= 2,
|
||||
};
|
||||
if !available {
|
||||
return Err("this display mode requires monitors that are not connected".into());
|
||||
}
|
||||
let previous = backend.manual_outputs.clone();
|
||||
let known_good = backend.applied_outputs.clone();
|
||||
backend.manual_outputs.profile = None;
|
||||
backend.manual_outputs.internal = None;
|
||||
backend.manual_outputs.layout = Some(layout);
|
||||
if let Err(error) = validate_live_outputs(state, &state.output_profiles) {
|
||||
state.direct_backend.as_mut().unwrap().manual_outputs = previous;
|
||||
return Err(error);
|
||||
}
|
||||
reconcile_outputs(state, false);
|
||||
if let Some(error) = state.direct_backend.as_ref().unwrap().output_error.clone() {
|
||||
state.direct_backend.as_mut().unwrap().manual_outputs = previous;
|
||||
return Err(error);
|
||||
}
|
||||
arm_output_confirmation(state, known_good, previous);
|
||||
state.notify_monitor_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -606,12 +648,14 @@ pub(crate) fn set_internal_output(state: &mut Ferese, enabled: bool) -> Result<(
|
||||
let previous = backend.manual_outputs.clone();
|
||||
let known_good = backend.applied_outputs.clone();
|
||||
backend.manual_outputs.internal = Some(enabled);
|
||||
backend.manual_outputs.layout = None;
|
||||
reconcile_outputs(state, false);
|
||||
if let Some(error) = state.direct_backend.as_ref().unwrap().output_error.clone() {
|
||||
state.direct_backend.as_mut().unwrap().manual_outputs = previous;
|
||||
return Err(error);
|
||||
}
|
||||
arm_output_confirmation(state, known_good, previous);
|
||||
state.notify_monitor_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -672,6 +716,7 @@ pub(crate) fn confirm_output_configuration(state: &mut Ferese, confirm: bool) ->
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
state.notify_monitor_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2193,11 +2238,21 @@ fn refresh_monitor_policy(state: &mut Ferese) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let previous_override = backend.manual_outputs.profile.clone();
|
||||
let previous_override = (
|
||||
backend.manual_outputs.profile.clone(),
|
||||
backend.manual_outputs.internal,
|
||||
backend.manual_outputs.layout,
|
||||
);
|
||||
backend
|
||||
.manual_outputs
|
||||
.observe(&backend.monitors, backend.lid.closed(), &state.output_profiles);
|
||||
if previous_override != backend.manual_outputs.profile {
|
||||
if previous_override
|
||||
!= (
|
||||
backend.manual_outputs.profile.clone(),
|
||||
backend.manual_outputs.internal,
|
||||
backend.manual_outputs.layout,
|
||||
)
|
||||
{
|
||||
backend.confirmation = None;
|
||||
if let Some(token) = backend.confirmation_timer.take() {
|
||||
state.loop_handle.remove(token);
|
||||
|
||||
@@ -351,6 +351,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
sync_battery_timer(state);
|
||||
state.restore_output_focus();
|
||||
state.relayout();
|
||||
state.notify_monitor_state();
|
||||
render_all(state);
|
||||
lid::apply_suspend_policy(state);
|
||||
}
|
||||
|
||||
@@ -386,6 +386,31 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_key_has_a_default_binding_and_can_be_rebound() {
|
||||
let bindings = parse("").runtime_config().unwrap().bindings;
|
||||
assert!(bindings.iter().any(
|
||||
|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_XF86Display)
|
||||
&& binding.action == BindingAction::ToggleDisplayMode
|
||||
));
|
||||
let bindings = parse(
|
||||
"binding keys=\"XF86Display\" disabled=#true\nbinding keys=\"Super+P\" action=\"toggle-display-mode\"\n",
|
||||
)
|
||||
.runtime_config()
|
||||
.unwrap()
|
||||
.bindings;
|
||||
assert!(
|
||||
!bindings
|
||||
.iter()
|
||||
.any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_XF86Display))
|
||||
);
|
||||
assert!(
|
||||
bindings
|
||||
.iter()
|
||||
.any(|binding| binding.modifiers.logo && binding.action == BindingAction::ToggleDisplayMode)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_hint_has_a_default_binding_and_can_be_rebound() {
|
||||
let bindings = parse("").runtime_config().unwrap().bindings;
|
||||
@@ -925,7 +950,7 @@ mod tests {
|
||||
let input = config.input_settings().unwrap();
|
||||
let bindings = config.bindings(&input).unwrap();
|
||||
|
||||
assert_eq!(bindings.len(), 53);
|
||||
assert_eq!(bindings.len(), 54);
|
||||
for (shift, action) in [
|
||||
(false, BindingAction::ToggleMaximized),
|
||||
(true, BindingAction::ToggleFullscreen),
|
||||
@@ -969,7 +994,7 @@ mod tests {
|
||||
|
||||
let input = replaced.input_settings().unwrap();
|
||||
let bindings = replaced.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 53);
|
||||
assert_eq!(bindings.len(), 54);
|
||||
assert!(bindings.iter().any(|binding| {
|
||||
binding.action
|
||||
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
|
||||
@@ -977,7 +1002,7 @@ mod tests {
|
||||
|
||||
let input = unbound.input_settings().unwrap();
|
||||
let bindings = unbound.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 52);
|
||||
assert_eq!(bindings.len(), 53);
|
||||
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
|
||||
}
|
||||
|
||||
|
||||
@@ -336,6 +336,10 @@ pub(super) fn parse_action(
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleOverview)
|
||||
}
|
||||
"toggle-display-mode" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleDisplayMode)
|
||||
}
|
||||
"toggle-keybinding-guide" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleKeybindingGuide)
|
||||
@@ -380,6 +384,7 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
binding("Super+]", "expel", None),
|
||||
binding("Super+Shift+Space", "toggle-floating", None),
|
||||
binding("Super+Tab", "toggle-overview", None),
|
||||
binding("XF86Display", "toggle-display-mode", None),
|
||||
binding("Super+F1", "toggle-keybinding-guide", None),
|
||||
binding("Super+Escape", "workspace-back-and-forth", None),
|
||||
binding("Super+BackSpace", "focus-last-window", None),
|
||||
@@ -467,6 +472,7 @@ pub enum BindingAction {
|
||||
ToggleFloating,
|
||||
ToggleOverview,
|
||||
ToggleKeybindingGuide,
|
||||
ToggleDisplayMode,
|
||||
}
|
||||
|
||||
impl Binding {
|
||||
@@ -576,6 +582,7 @@ impl Binding {
|
||||
BindingAction::Expel => "Move window out of column".into(),
|
||||
BindingAction::ToggleFloating => "Toggle floating window".into(),
|
||||
BindingAction::ToggleOverview => "Open or close overview".into(),
|
||||
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
|
||||
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
|
||||
@@ -1072,6 +1072,9 @@ impl Ferese {
|
||||
BindingAction::Expel => self.expel_focused_window(),
|
||||
BindingAction::ToggleFloating => self.toggle_focused_floating(),
|
||||
BindingAction::ToggleOverview => self.toggle_overview(),
|
||||
BindingAction::ToggleDisplayMode => {
|
||||
self.toggle_display_mode();
|
||||
}
|
||||
BindingAction::ToggleKeybindingGuide => {
|
||||
self.toggle_keybinding_guide();
|
||||
}
|
||||
|
||||
@@ -660,6 +660,9 @@ impl Ferese {
|
||||
"toggle-maximized" => self.toggle_focused_maximized(),
|
||||
"toggle-layout" => self.toggle_layout_mode(),
|
||||
"toggle-overview" => self.toggle_overview(),
|
||||
"toggle-display-mode" => {
|
||||
self.toggle_display_mode();
|
||||
}
|
||||
"toggle-keybinding-guide" => {
|
||||
if !self.toggle_keybinding_guide() {
|
||||
return Err(CommandError::new(
|
||||
@@ -699,6 +702,7 @@ impl Ferese {
|
||||
return Ok(json!({
|
||||
"confirmation_pending": self.direct_backend.as_ref().is_some_and(|backend| backend.confirmation_pending()),
|
||||
"manual_profile": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.profile.as_ref()),
|
||||
"manual_layout": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.layout),
|
||||
"manual_internal": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.internal),
|
||||
"profiles": self.output_profiles.iter().map(|profile| json!({
|
||||
"name": profile.name, "layout": profile.layout,
|
||||
@@ -720,6 +724,13 @@ impl Ferese {
|
||||
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
|
||||
return Ok(self.outputs_json());
|
||||
}
|
||||
"output-layout" => {
|
||||
let layout = serde_json::from_value::<crate::config::OutputLayout>(args["layout"].clone())
|
||||
.map_err(|_| CommandError::new("invalid_arguments", "unknown display layout"))?;
|
||||
crate::backends::direct::set_output_layout(self, layout)
|
||||
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
|
||||
return Ok(self.outputs_json());
|
||||
}
|
||||
"output-internal" => {
|
||||
let enabled = args["enabled"]
|
||||
.as_bool()
|
||||
|
||||
@@ -40,6 +40,7 @@ pub(crate) struct DesiredOutputConfiguration {
|
||||
pub(crate) struct ManualOverride {
|
||||
pub profile: Option<String>,
|
||||
pub internal: Option<bool>,
|
||||
pub layout: Option<OutputLayout>,
|
||||
topology: Option<(BTreeSet<String>, bool)>,
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ impl ManualOverride {
|
||||
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
|
||||
@@ -134,7 +136,9 @@ pub(crate) fn select_profile(
|
||||
})
|
||||
.map(|(_, profile)| profile)
|
||||
});
|
||||
let layout = selected.map_or(OutputLayout::Extend, |profile| profile.layout);
|
||||
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
|
||||
@@ -149,6 +153,9 @@ pub(crate) fn select_profile(
|
||||
})
|
||||
.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,
|
||||
@@ -465,6 +472,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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)];
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Backdrop {
|
||||
pub commit: CommitCounter,
|
||||
sources: Vec<Source>,
|
||||
region: Option<Rectangle<i32, Physical>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct Source {
|
||||
id: Id,
|
||||
commit: CommitCounter,
|
||||
@@ -19,9 +19,9 @@ struct Source {
|
||||
}
|
||||
|
||||
impl Backdrop {
|
||||
fn update<E: Element>(
|
||||
fn update<'a, E: Element + 'a>(
|
||||
&mut self,
|
||||
elements: &[E],
|
||||
elements: impl IntoIterator<Item = &'a E>,
|
||||
region: Rectangle<i32, Physical>,
|
||||
scale: RenderScale<f64>,
|
||||
) -> bool {
|
||||
@@ -90,15 +90,33 @@ impl Backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_shared<E: Element>(
|
||||
shared: &std::sync::Mutex<Backdrop>,
|
||||
elements: &[E],
|
||||
owner: &Id,
|
||||
region: Rectangle<i32, Physical>,
|
||||
scale: RenderScale<f64>,
|
||||
) -> bool {
|
||||
// Element queries can read this same backdrop's commit. Snapshot the state,
|
||||
// release its lock, then inspect sources. Rendering is the sole writer.
|
||||
let mut backdrop = shared.lock().unwrap().clone();
|
||||
let sources = elements.iter().filter(|element| element.id() != owner);
|
||||
let changed = backdrop.update(sources, region, scale);
|
||||
*shared.lock().unwrap() = backdrop;
|
||||
changed
|
||||
}
|
||||
|
||||
pub(super) fn update(elements: &[AnimatedWindowRenderElement], scale: f64) {
|
||||
// Resolve lower blurs first so their damage propagates to higher materials.
|
||||
for (index, element) in elements.iter().enumerate().rev() {
|
||||
if let AnimatedWindowRenderElement::Blur(blur) = element {
|
||||
let changed =
|
||||
blur.backdrop
|
||||
.lock()
|
||||
.unwrap()
|
||||
.update(&elements[index + 1..], blur.geometry(scale.into()), scale.into());
|
||||
let changed = update_shared(
|
||||
&blur.backdrop,
|
||||
&elements[index + 1..],
|
||||
blur.id(),
|
||||
blur.geometry(scale.into()),
|
||||
scale.into(),
|
||||
);
|
||||
if changed {
|
||||
blur.capture_dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
@@ -115,6 +133,7 @@ mod tests {
|
||||
commit: CommitCounter,
|
||||
rect: Rectangle<i32, Physical>,
|
||||
damage: Vec<Rectangle<i32, Physical>>,
|
||||
probe: Option<Arc<std::sync::Mutex<Backdrop>>>,
|
||||
opaque: bool,
|
||||
alpha: f32,
|
||||
}
|
||||
@@ -126,6 +145,7 @@ mod tests {
|
||||
commit: CommitCounter::default(),
|
||||
rect,
|
||||
damage: vec![],
|
||||
probe: None,
|
||||
opaque: false,
|
||||
alpha: 1.0,
|
||||
}
|
||||
@@ -142,6 +162,9 @@ mod tests {
|
||||
&self.id
|
||||
}
|
||||
fn current_commit(&self) -> CommitCounter {
|
||||
if let Some(backdrop) = &self.probe {
|
||||
let _guard = backdrop.try_lock().expect("backdrop lock held during an element query");
|
||||
}
|
||||
self.commit
|
||||
}
|
||||
fn src(&self) -> Rectangle<f64, Buffer> {
|
||||
@@ -173,6 +196,41 @@ mod tests {
|
||||
Rectangle::new((x, y).into(), (w, h).into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blurred_popup_open_update_close_releases_backdrop_before_element_queries() {
|
||||
let region = rect(0, 0, 100, 100);
|
||||
let backdrop = Arc::new(std::sync::Mutex::new(Backdrop::default()));
|
||||
let owner = Id::new();
|
||||
let mut panel = TestElement::new(region);
|
||||
panel.probe = Some(backdrop.clone());
|
||||
let mut popup_alias = TestElement::new(region);
|
||||
popup_alias.id = owner.clone();
|
||||
let mut elements = [popup_alias, panel];
|
||||
|
||||
// Open: ignore a repeated instance of the cached popup material, and
|
||||
// allow dependencies to query backdrop state without a nested lock.
|
||||
assert!(update_shared(&backdrop, &elements, &owner, region, 1.0.into()));
|
||||
assert_eq!(backdrop.lock().unwrap().sources.len(), 1);
|
||||
assert!(!update_shared(&backdrop, &elements, &owner, region, 1.0.into()));
|
||||
|
||||
// Update both content and bounds while the popup remains visible.
|
||||
elements[1].repaint(vec![rect(10, 10, 5, 5)]);
|
||||
assert!(update_shared(&backdrop, &elements, &owner, region, 1.0.into()));
|
||||
assert!(update_shared(
|
||||
&backdrop,
|
||||
&elements,
|
||||
&owner,
|
||||
rect(5, 5, 90, 90),
|
||||
1.0.into()
|
||||
));
|
||||
|
||||
// Close and reopen: dependencies disappear, then are tracked again.
|
||||
assert!(update_shared::<TestElement>(&backdrop, &[], &owner, region, 1.0.into()));
|
||||
assert!(backdrop.lock().unwrap().sources.is_empty());
|
||||
assert!(update_shared(&backdrop, &elements, &owner, region, 1.0.into()));
|
||||
assert!(!update_shared(&backdrop, &elements, &owner, region, 1.0.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_damage_intersecting_the_sample_invalidates_its_output() {
|
||||
let mut first = Backdrop::default();
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities;
|
||||
use crate::state::ClientState;
|
||||
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
display.create_global::<Ferese, FereseShellManagerV1, _>(5, ());
|
||||
display.create_global::<Ferese, FereseShellManagerV1, _>(6, ());
|
||||
}
|
||||
|
||||
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
|
||||
@@ -206,6 +206,34 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn notify_monitor_state(&self) {
|
||||
for shell in self
|
||||
.shell_resources
|
||||
.iter()
|
||||
.filter_map(|shell| shell.upgrade().ok())
|
||||
.filter(|shell| shell.version() >= 6)
|
||||
{
|
||||
shell.monitor_state_changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_display_mode(&mut self) -> bool {
|
||||
if self.session_lock.active() {
|
||||
return false;
|
||||
}
|
||||
let Some(shell) = self
|
||||
.shell_resources
|
||||
.iter()
|
||||
.filter_map(|shell| shell.upgrade().ok())
|
||||
.find(|shell| shell.version() >= 6)
|
||||
else {
|
||||
tracing::warn!("display mode chooser requires the updated Ferese shell");
|
||||
return false;
|
||||
};
|
||||
shell.toggle_display_mode(self.focused_output().map_or_else(String::new, |output| output.name()));
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_keybinding_guide(&mut self) -> bool {
|
||||
if self.session_lock.active() {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="10" width="8" height="6" rx="1"/><rect x="13" y="7" width="9" height="9" rx="1"/></svg>
|
||||
|
After Width: | Height: | Size: 216 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="10" width="8" height="6" rx="1" stroke-dasharray="2 2" opacity=".4"/><rect x="13" y="7" width="9" height="9" rx="1"/></svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="10" width="8" height="6" rx="1"/><rect x="13" y="7" width="9" height="9" rx="1" stroke-dasharray="2 2" opacity=".4"/></svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="13" height="10" rx="1"/><rect x="8" y="9" width="13" height="10" rx="1"/></svg>
|
||||
|
After Width: | Height: | Size: 218 B |
@@ -9,7 +9,24 @@ pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
|
||||
}
|
||||
|
||||
pub fn button_style(p: Palette, selected: bool) -> theme::Button {
|
||||
styled_button(p, selected, false)
|
||||
styled_button(p, selected, false, 1.0)
|
||||
}
|
||||
|
||||
/// Selection cards share native button states while revealing the modal material.
|
||||
pub fn material_button_style(p: Palette, selected: bool, material_opacity: f32) -> theme::Button {
|
||||
let opacity = if material_opacity.is_finite() {
|
||||
material_opacity.clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let fill = if opacity < 1.0 {
|
||||
opacity * if selected { 0.65 } else { 0.25 }
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
styled_button(p, selected, false, fill)
|
||||
}
|
||||
|
||||
pub fn text_button<'a, M: Clone + 'a>(
|
||||
@@ -65,7 +82,7 @@ pub(crate) fn switch_colors(palette: Palette, enabled: bool, hovered: bool) -> (
|
||||
}
|
||||
|
||||
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
|
||||
styled_button(p, selected, true)
|
||||
styled_button(p, selected, true, 1.0)
|
||||
}
|
||||
|
||||
pub fn settings_input(p: Palette) -> theme::TextInput {
|
||||
@@ -111,7 +128,7 @@ pub fn select<'a, M: 'a>(
|
||||
}))
|
||||
}
|
||||
|
||||
fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button {
|
||||
fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32) -> theme::Button {
|
||||
let style = move |hover: bool, focused: bool| {
|
||||
let background = if selected {
|
||||
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
|
||||
@@ -132,6 +149,13 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
|
||||
(background, p.text)
|
||||
};
|
||||
|
||||
let mut background = background;
|
||||
background.a *= if hover && opacity < 1.0 {
|
||||
(opacity + 0.12).min(1.0)
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
text_color: Some(on),
|
||||
|
||||
@@ -6,6 +6,10 @@ use cosmic::widget::icon;
|
||||
pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
|
||||
pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg");
|
||||
pub const CHECK: &[u8] = include_bytes!("../assets/icons/check.svg");
|
||||
pub const DISPLAY_INTERNAL: &[u8] = include_bytes!("../assets/icons/display-internal.svg");
|
||||
pub const DISPLAY_EXTERNAL: &[u8] = include_bytes!("../assets/icons/display-external.svg");
|
||||
pub const DISPLAY_EXTEND: &[u8] = include_bytes!("../assets/icons/display-extend.svg");
|
||||
pub const DISPLAY_MIRROR: &[u8] = include_bytes!("../assets/icons/display-mirror.svg");
|
||||
pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg");
|
||||
pub const FERESE: &[u8] = include_bytes!("../assets/icons/ferese.svg");
|
||||
pub const OVERVIEW: &[u8] = include_bytes!("../assets/icons/overview.svg");
|
||||
|
||||
@@ -520,6 +520,8 @@ restores evacuated workspaces.
|
||||
```sh
|
||||
feresectl outputs
|
||||
feresectl output-profiles
|
||||
feresectl toggle-display-mode
|
||||
feresectl output-layout extend
|
||||
feresectl output-profile presentation
|
||||
feresectl output-confirm
|
||||
feresectl output-profile auto
|
||||
@@ -527,6 +529,11 @@ feresectl output-internal off
|
||||
feresectl output-revert
|
||||
```
|
||||
|
||||
The display Fn key (`XF86Display`) and the bar’s display icon open the same
|
||||
display-mode chooser. The icon appears while an external monitor is connected.
|
||||
`output-layout` selects a layout without requiring a named profile and keeps
|
||||
the matching profile’s mode, scale and transform settings.
|
||||
|
||||
A manual choice overrides automatic selection until the monitor set or lid
|
||||
state changes. Reload keeps it only if its profile still exists and matches.
|
||||
Manual changes revert after `confirm-timeout` seconds unless confirmed;
|
||||
|
||||
@@ -87,11 +87,19 @@ by default. The action can also be bound as `workspace-back-and-forth` or run wi
|
||||
| Super + Shift + S | Select a screenshot area and open Satty |
|
||||
| Super + Shift + E | Ask to log out |
|
||||
| Super + F1 | Open or close the shortcut hint |
|
||||
| Display Fn key (`XF86Display`) | Open or close the display-mode chooser |
|
||||
|
||||
Super + F1 works even when the guide at login is disabled. It opens on the
|
||||
focused monitor. Bind `toggle-keybinding-guide` to change the shortcut, or run
|
||||
`feresectl toggle-keybinding-guide`.
|
||||
|
||||
The display icon appears in the bar when an external monitor is connected,
|
||||
including when that monitor is disabled or mirrored. Click it to choose Internal
|
||||
only, External only, Extend or Mirror. Arrow keys or 1–4 select a mode; Enter
|
||||
applies it. Tab moves between controls and Escape cancels. After applying, choose
|
||||
Keep before the confirmation timeout or Revert to restore the last confirmed
|
||||
configuration. Bind `toggle-display-mode` if your keyboard uses another key.
|
||||
|
||||
In Satty, Enter saves the edited screenshot and copies it to the clipboard;
|
||||
Escape discards it. See [Configuration](configuration.md#commands-and-bindings)
|
||||
for dependencies and custom screenshot commands.
|
||||
|
||||
+22
-1
@@ -111,6 +111,13 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
||||
let command = args.next().ok_or_else(usage)?;
|
||||
let positional = args.collect::<Vec<_>>();
|
||||
let payload = match command.as_str() {
|
||||
"output-layout" => {
|
||||
exactly_one(&command, &positional, "internal-only, external-only, extend or mirror")?;
|
||||
if !["internal-only", "external-only", "extend", "mirror"].contains(&positional[0].as_str()) {
|
||||
return Err("unknown display layout".into());
|
||||
}
|
||||
json!({ "layout": positional[0] })
|
||||
}
|
||||
"output-profile" => {
|
||||
exactly_one(&command, &positional, "profile name or auto")?;
|
||||
json!({ "name": positional[0] })
|
||||
@@ -170,6 +177,7 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
||||
| "get-workspaces"
|
||||
| "get-outputs"
|
||||
| "outputs"
|
||||
| "toggle-display-mode"
|
||||
| "output-profiles"
|
||||
| "output-confirm"
|
||||
| "output-revert"
|
||||
@@ -208,13 +216,26 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
|
||||
"usage: feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_mode_commands_validate_layouts() {
|
||||
for layout in ["internal-only", "external-only", "extend", "mirror"] {
|
||||
assert_eq!(
|
||||
parse_args(["output-layout".into(), layout.into()]).unwrap().1,
|
||||
json!({"layout":layout})
|
||||
);
|
||||
}
|
||||
assert!(parse_args(["output-layout".into(), "docked".into()]).is_err());
|
||||
assert!(parse_args(["output-layout".into()]).is_err());
|
||||
assert!(parse_args(["toggle-display-mode".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_hint_command_rejects_extra_arguments() {
|
||||
assert_eq!(
|
||||
|
||||
+33
-39
@@ -86,7 +86,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let (theme, font, background, palette) = appearance();
|
||||
let height = 180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 64. } else { 0. };
|
||||
let height = 180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 48. } else { 0. };
|
||||
cosmic::app::run::<Picker>(
|
||||
Settings::default()
|
||||
.size(cosmic::iced::Size::new(400., height))
|
||||
@@ -243,7 +243,7 @@ impl cosmic::Application for Picker {
|
||||
} else {
|
||||
&self.prompt.app
|
||||
};
|
||||
let mut content = column([]).width(Length::Fill).spacing(10).push(
|
||||
let mut content = column([]).width(Length::Fill).height(Length::Fill).spacing(10).push(
|
||||
row![
|
||||
glyph(ferese_theme::icons::DISPLAY, 24),
|
||||
column![
|
||||
@@ -299,16 +299,11 @@ impl cosmic::Application for Picker {
|
||||
}
|
||||
sources = sources.push(
|
||||
button::custom(entry)
|
||||
.class(if selected {
|
||||
ferese_theme::accent_button()
|
||||
} else {
|
||||
ferese_theme::controls::notification_button(
|
||||
self.palette.text,
|
||||
self.palette.text.scale_alpha(0.06),
|
||||
self.palette.radius.min(9.),
|
||||
false,
|
||||
)
|
||||
})
|
||||
.class(ferese_theme::controls::material_button_style(
|
||||
self.palette,
|
||||
selected,
|
||||
self.background.a,
|
||||
))
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::Select(index)),
|
||||
@@ -332,38 +327,37 @@ impl cosmic::Application for Picker {
|
||||
} else {
|
||||
"Allow this selection without asking again"
|
||||
};
|
||||
|
||||
content = content.push(
|
||||
button::custom(ferese_theme::menus::row(
|
||||
label,
|
||||
ferese_theme::menus::switch(self.remember, self.palette, 1.),
|
||||
self.font,
|
||||
))
|
||||
.class(ferese_theme::controls::navigation_style(self.palette, false))
|
||||
.width(Length::Fill)
|
||||
row![
|
||||
self.text(label).size(12).width(Length::Fill),
|
||||
ferese_theme::controls::switch(self.remember, self.palette)
|
||||
.on_press_maybe(self.can_remember().then_some(Message::Remember)),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
let mut share = button::custom(
|
||||
row![
|
||||
self.text(if self.prompt.window_capture { "Capture" } else { "Share" }),
|
||||
glyph(ferese_theme::icons::ARROW, 16)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
|
||||
let share = ferese_theme::controls::text_button(
|
||||
if self.prompt.window_capture { "Capture" } else { "Share" },
|
||||
self.font,
|
||||
self.palette,
|
||||
true,
|
||||
)
|
||||
.class(ferese_theme::accent_button())
|
||||
.padding([6, 16]);
|
||||
if !self.selected.is_empty() {
|
||||
share = share.on_press(Message::Share);
|
||||
}
|
||||
content = content.push(
|
||||
row![
|
||||
button::custom(self.text("Cancel"))
|
||||
.class(ferese_theme::controls::button_style(self.palette, false))
|
||||
.on_press(Message::Cancel),
|
||||
share
|
||||
]
|
||||
.spacing(8),
|
||||
.on_press_maybe((!self.selected.is_empty()).then_some(Message::Share));
|
||||
let cancel = ferese_theme::controls::text_button("Cancel", self.font, self.palette, false)
|
||||
.class(ferese_theme::controls::material_button_style(
|
||||
self.palette,
|
||||
false,
|
||||
self.background.a,
|
||||
))
|
||||
.on_press(Message::Cancel);
|
||||
content = content
|
||||
.push(cosmic::iced::widget::Space::new().height(Length::Fill))
|
||||
.push(
|
||||
row![cosmic::iced::widget::Space::new().width(Length::Fill), cancel, share]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
container(content).padding(14).width(Length::Fill).into()
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<interface name="ferese_shell_manager_v1" version="5">
|
||||
<interface name="ferese_shell_manager_v1" version="6">
|
||||
<description summary="create a capability-scoped Ferese shell controller">
|
||||
This private global is visible only to clients granted the shell-control
|
||||
capability. It does not assign a Wayland surface role.
|
||||
@@ -35,7 +35,7 @@
|
||||
<request name="destroy" type="destructor"/>
|
||||
</interface>
|
||||
|
||||
<interface name="ferese_shell_v1" version="5">
|
||||
<interface name="ferese_shell_v1" version="6">
|
||||
<description summary="managed-window metadata and shell control">
|
||||
Window, workspace, and output identifiers are unsigned 64-bit values
|
||||
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
||||
@@ -171,5 +171,15 @@
|
||||
</description>
|
||||
<arg name="output_name" type="string"/>
|
||||
</event>
|
||||
<event name="toggle_display_mode" since="6">
|
||||
<description summary="toggle the display mode chooser on the focused output"/>
|
||||
<arg name="output_name" type="string"/>
|
||||
</event>
|
||||
<event name="monitor_state_changed" since="6">
|
||||
<description summary="physical monitor configuration changed">
|
||||
Query the output IPC inventory after reconciliation. This includes
|
||||
disabled monitors and mirror targets without a logical wl_output.
|
||||
</description>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#include <wayland-client.h>
|
||||
#include "xdg-shell-client-protocol.h"
|
||||
#include "ferese-effects-client-protocol.h"
|
||||
|
||||
static struct wl_compositor *compositor;
|
||||
static struct wl_shm *shm;
|
||||
static struct xdg_wm_base *wm;
|
||||
static struct ferese_effects_manager_v1 *effects;
|
||||
|
||||
static void ping(void *data, struct xdg_wm_base *object, uint32_t serial) {
|
||||
(void)data;
|
||||
xdg_wm_base_pong(object, serial);
|
||||
}
|
||||
static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
|
||||
|
||||
static void global(void *data, struct wl_registry *registry, uint32_t name,
|
||||
const char *interface, uint32_t version) {
|
||||
(void)data;
|
||||
(void)version;
|
||||
if (!strcmp(interface, "wl_compositor"))
|
||||
compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
|
||||
else if (!strcmp(interface, "wl_shm"))
|
||||
shm = wl_registry_bind(registry, name, &wl_shm_interface, 1);
|
||||
else if (!strcmp(interface, "xdg_wm_base")) {
|
||||
wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1);
|
||||
xdg_wm_base_add_listener(wm, &wm_listener, NULL);
|
||||
} else if (!strcmp(interface, "ferese_effects_manager_v1"))
|
||||
effects = wl_registry_bind(registry, name, &ferese_effects_manager_v1_interface, 3);
|
||||
}
|
||||
static void removed(void *data, struct wl_registry *registry, uint32_t name) {
|
||||
(void)data; (void)registry; (void)name;
|
||||
}
|
||||
static const struct wl_registry_listener registry_listener = {global, removed};
|
||||
|
||||
static void configure(void *data, struct xdg_surface *surface, uint32_t serial) {
|
||||
xdg_surface_ack_configure(surface, serial);
|
||||
*(int *)data = 1;
|
||||
}
|
||||
static const struct xdg_surface_listener surface_listener = {.configure = configure};
|
||||
|
||||
static void frame_done(void *data, struct wl_callback *callback, uint32_t time) {
|
||||
(void)time;
|
||||
*(int *)data = 1;
|
||||
wl_callback_destroy(callback);
|
||||
}
|
||||
static const struct wl_callback_listener frame_listener = {.done = frame_done};
|
||||
|
||||
static void present(struct wl_display *display, struct wl_surface *surface,
|
||||
struct wl_buffer *buffer, const char *phase) {
|
||||
int done = 0;
|
||||
struct wl_callback *callback = wl_surface_frame(surface);
|
||||
wl_callback_add_listener(callback, &frame_listener, &done);
|
||||
wl_surface_attach(surface, buffer, 0, 0);
|
||||
wl_surface_damage(surface, 0, 0, 320, 240);
|
||||
wl_surface_commit(surface);
|
||||
while (!done)
|
||||
assert(wl_display_dispatch(display) >= 0);
|
||||
puts(phase);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
struct wl_display *display = wl_display_connect(NULL);
|
||||
assert(display);
|
||||
struct wl_registry *registry = wl_display_get_registry(display);
|
||||
wl_registry_add_listener(registry, ®istry_listener, NULL);
|
||||
assert(wl_display_roundtrip(display) >= 0);
|
||||
assert(compositor && shm && wm && effects);
|
||||
|
||||
int fd = memfd_create("blurred-popup", MFD_CLOEXEC);
|
||||
assert(fd >= 0 && ftruncate(fd, 320 * 240 * 4) == 0);
|
||||
uint32_t *pixels = mmap(NULL, 320 * 240 * 4, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
assert(pixels != MAP_FAILED);
|
||||
for (size_t i = 0; i < 320 * 240; i++)
|
||||
pixels[i] = 0x20101010;
|
||||
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, 320 * 240 * 4);
|
||||
struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, 320, 240, 320 * 4, WL_SHM_FORMAT_ARGB8888);
|
||||
wl_shm_pool_destroy(pool);
|
||||
close(fd);
|
||||
|
||||
int configured = 0;
|
||||
struct wl_surface *parent = wl_compositor_create_surface(compositor);
|
||||
struct xdg_surface *parent_xdg = xdg_wm_base_get_xdg_surface(wm, parent);
|
||||
xdg_surface_add_listener(parent_xdg, &surface_listener, &configured);
|
||||
struct xdg_toplevel *top = xdg_surface_get_toplevel(parent_xdg);
|
||||
xdg_toplevel_set_app_id(top, "ferese.test.blurred-popup");
|
||||
struct ferese_surface_effects_v1 *parent_effects = ferese_effects_manager_v1_get_surface_effects(effects, parent);
|
||||
ferese_surface_effects_v1_set_role(parent_effects, FERESE_SURFACE_EFFECTS_V1_ROLE_PANEL);
|
||||
wl_surface_commit(parent);
|
||||
while (!configured)
|
||||
assert(wl_display_dispatch(display) >= 0);
|
||||
present(display, parent, buffer, "parent");
|
||||
|
||||
for (int cycle = 0; cycle < 3; cycle++) {
|
||||
configured = 0;
|
||||
struct wl_surface *surface = wl_compositor_create_surface(compositor);
|
||||
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
|
||||
xdg_surface_add_listener(xdg, &surface_listener, &configured);
|
||||
struct xdg_positioner *positioner = xdg_wm_base_create_positioner(wm);
|
||||
xdg_positioner_set_size(positioner, 320, 240);
|
||||
xdg_positioner_set_anchor_rect(positioner, 20, 20, 10, 10);
|
||||
struct xdg_popup *popup = xdg_surface_get_popup(xdg, parent_xdg, positioner);
|
||||
xdg_positioner_destroy(positioner);
|
||||
struct ferese_surface_effects_v1 *material = ferese_effects_manager_v1_get_surface_effects(effects, surface);
|
||||
ferese_surface_effects_v1_set_role(material, FERESE_SURFACE_EFFECTS_V1_ROLE_POPOVER);
|
||||
wl_surface_commit(surface);
|
||||
while (!configured)
|
||||
assert(wl_display_dispatch(display) >= 0);
|
||||
present(display, surface, buffer, "open");
|
||||
for (int step = 0; step < 3; step++) {
|
||||
ferese_surface_effects_v1_set_opacity(material, 400 + step * 200);
|
||||
pixels[step] = 0x30202020;
|
||||
present(display, surface, buffer, "update");
|
||||
}
|
||||
ferese_surface_effects_v1_destroy(material);
|
||||
xdg_popup_destroy(popup);
|
||||
xdg_surface_destroy(xdg);
|
||||
wl_surface_destroy(surface);
|
||||
present(display, parent, buffer, "close");
|
||||
}
|
||||
ferese_surface_effects_v1_destroy(parent_effects);
|
||||
xdg_toplevel_destroy(top);
|
||||
xdg_surface_destroy(parent_xdg);
|
||||
wl_surface_destroy(parent);
|
||||
wl_buffer_destroy(buffer);
|
||||
munmap(pixels, 320 * 240 * 4);
|
||||
wl_display_disconnect(display);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Open, update and close blurred popups on a private nested compositor.
|
||||
|
||||
FERESE_TEST_BLURRED_POPUP=1 FERESE_TEST_BINARY=target/release/ferese \\
|
||||
FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_blurred_popup_isolated.py
|
||||
Requires a Wayland host, wayland-scanner and a C compiler.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_BLURRED_POPUP") == "1", "opt-in nested blur test")
|
||||
class BlurredPopupTest(unittest.TestCase):
|
||||
def test_popup_lifecycle_keeps_presenting_frames(self):
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
with tempfile.TemporaryDirectory(prefix="ferese-blurred-popup-") as temporary:
|
||||
root = Path(temporary)
|
||||
protocols = Path(subprocess.check_output(
|
||||
["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True
|
||||
).strip())
|
||||
generated = []
|
||||
for name, xml in [
|
||||
("xdg-shell", protocols / "stable/xdg-shell/xdg-shell.xml"),
|
||||
("ferese-effects", repo / "protocols/ferese-effects-v1.xml"),
|
||||
]:
|
||||
source = root / f"{name}.c"
|
||||
subprocess.run(["wayland-scanner", "client-header", str(xml),
|
||||
str(root / f"{name}-client-protocol.h")], check=True)
|
||||
subprocess.run(["wayland-scanner", "private-code", str(xml), str(source)], check=True)
|
||||
generated.append(str(source))
|
||||
flags = subprocess.check_output(
|
||||
["pkg-config", "--cflags", "--libs", "wayland-client"], text=True
|
||||
).split()
|
||||
client = root / "client"
|
||||
subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root),
|
||||
str(repo / "scripts/tests/fixtures/blurred-popup.c"),
|
||||
*generated, "-o", str(client), *flags], check=True)
|
||||
runtime = root / "runtime"
|
||||
runtime.mkdir(mode=0o700)
|
||||
config = root / "config/ferese"
|
||||
config.mkdir(parents=True)
|
||||
(config / "config.kdl").write_text('theme { material { style "translucent"; } }\n')
|
||||
host = Path(os.environ["WAYLAND_DISPLAY"])
|
||||
if not host.is_absolute():
|
||||
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
|
||||
env = dict(os.environ, WAYLAND_DISPLAY=str(host), XDG_RUNTIME_DIR=str(runtime),
|
||||
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
|
||||
XDG_CACHE_HOME=str(root / "cache"), RUST_LOG="ferese=info")
|
||||
env.pop("WAYLAND_SOCKET", None)
|
||||
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
|
||||
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
|
||||
log_path = root / "compositor.log"
|
||||
with log_path.open("w") as log:
|
||||
compositor = subprocess.Popen(
|
||||
[str(binary), "--backend", "nested", "--grant-effects", "--", str(client)],
|
||||
env=env, stdout=log, stderr=log,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 30
|
||||
while log_path.read_text().splitlines().count("close") < 3:
|
||||
self.assertIsNone(compositor.poll(), log_path.read_text())
|
||||
if time.monotonic() >= deadline:
|
||||
self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text())
|
||||
time.sleep(.025)
|
||||
control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
|
||||
subprocess.run([str(control), "get-outputs"], env=env, check=True,
|
||||
stdout=subprocess.DEVNULL, timeout=5)
|
||||
phases = log_path.read_text().splitlines()
|
||||
for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]:
|
||||
self.assertEqual(phases.count(name), count, log_path.read_text())
|
||||
finally:
|
||||
if compositor.poll() is None:
|
||||
compositor.terminate()
|
||||
try:
|
||||
compositor.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
compositor.kill()
|
||||
compositor.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -136,7 +136,7 @@ impl FereseShell {
|
||||
.height(bar.control_height)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
.class(theme::Container::custom(move |_| bar_group_style(shell_theme)));
|
||||
let right = row![
|
||||
let mut right = row![
|
||||
self.view_status_bar().map(move |action| match action {
|
||||
cosmic::Action::App(Message::OpenMenu(kind, anchor)) =>
|
||||
cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)),
|
||||
@@ -146,6 +146,23 @@ impl FereseShell {
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(cosmic::iced::Alignment::Center);
|
||||
if self.display_mode.external_connected() {
|
||||
right = right.push(motion::button(
|
||||
button::custom(bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground))
|
||||
.name("Display mode")
|
||||
.height(bar.control_height)
|
||||
.padding([0, 7])
|
||||
.on_press(cosmic::Action::App(Message::OpenDisplays(
|
||||
self.outputs
|
||||
.iter()
|
||||
.find(|output| output.bar == id)
|
||||
.and_then(|output| output.name.clone()),
|
||||
))),
|
||||
foreground,
|
||||
self.display_mode.open,
|
||||
1.0,
|
||||
));
|
||||
}
|
||||
let available = self
|
||||
.outputs
|
||||
.iter()
|
||||
|
||||
@@ -49,7 +49,7 @@ impl SessionConnection {
|
||||
}
|
||||
}
|
||||
|
||||
fn request(command: &str, args: Value) -> Result<Value, String> {
|
||||
pub(super) fn request(command: &str, args: Value) -> Result<Value, String> {
|
||||
SessionConnection::connect()?.request(command, args)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ pub(crate) enum ControlCommand {
|
||||
Logout(u32, String),
|
||||
CancelLogout(u32),
|
||||
ToggleGuide(String),
|
||||
ToggleDisplays(String),
|
||||
MonitorsChanged,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -116,6 +118,8 @@ enum ControlUpdate {
|
||||
Logout(u32, String),
|
||||
LogoutCancelled(u32),
|
||||
ToggleGuide(String),
|
||||
ToggleDisplays(String),
|
||||
MonitorsChanged,
|
||||
}
|
||||
|
||||
impl ShellControl {
|
||||
@@ -123,7 +127,7 @@ impl ShellControl {
|
||||
let connection = control_connection()?;
|
||||
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
||||
let qh = queue.handle();
|
||||
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 4..=5, ())?;
|
||||
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 4..=6, ())?;
|
||||
let shell = manager.get_shell(&qh, ());
|
||||
let (sender, updates) = mpsc::channel();
|
||||
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
|
||||
@@ -165,6 +169,8 @@ impl ShellControl {
|
||||
loop {
|
||||
match self.updates.try_recv() {
|
||||
Ok(ControlUpdate::Logout(serial, output)) => poll.commands.push(ControlCommand::Logout(serial, output)),
|
||||
Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)),
|
||||
Ok(ControlUpdate::MonitorsChanged) => poll.commands.push(ControlCommand::MonitorsChanged),
|
||||
Ok(ControlUpdate::ToggleGuide(output)) => poll.commands.push(ControlCommand::ToggleGuide(output)),
|
||||
Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)),
|
||||
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
|
||||
@@ -340,12 +346,21 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
fullscreen,
|
||||
});
|
||||
}
|
||||
ferese_shell_v1::Event::Capabilities { .. } => {
|
||||
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
|
||||
}
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => {
|
||||
let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone()));
|
||||
}
|
||||
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
|
||||
let _ = state.sender.send(ControlUpdate::Logout(serial, output_name));
|
||||
}
|
||||
ferese_shell_v1::Event::ToggleDisplayMode { output_name } => {
|
||||
let _ = state.sender.send(ControlUpdate::ToggleDisplays(output_name));
|
||||
}
|
||||
ferese_shell_v1::Event::MonitorStateChanged => {
|
||||
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
|
||||
}
|
||||
ferese_shell_v1::Event::ToggleKeybindingGuide { output_name } => {
|
||||
let _ = state.sender.send(ControlUpdate::ToggleGuide(output_name));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum Mode {
|
||||
Internal,
|
||||
External,
|
||||
#[default]
|
||||
Extend,
|
||||
Mirror,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub const ALL: [Self; 4] = [Self::Internal, Self::External, Self::Extend, Self::Mirror];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Internal => "internal-only",
|
||||
Self::External => "external-only",
|
||||
Self::Extend => "extend",
|
||||
Self::Mirror => "mirror",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Internal => "Internal only",
|
||||
Self::External => "External only",
|
||||
Self::Extend => "Extend",
|
||||
Self::Mirror => "Mirror",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(self) -> &'static [u8] {
|
||||
match self {
|
||||
Self::Internal => ferese_theme::icons::DISPLAY_INTERNAL,
|
||||
Self::External => ferese_theme::icons::DISPLAY_EXTERNAL,
|
||||
Self::Extend => ferese_theme::icons::DISPLAY_EXTEND,
|
||||
Self::Mirror => ferese_theme::icons::DISPLAY_MIRROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Display {
|
||||
label: String,
|
||||
internal: bool,
|
||||
physical: bool,
|
||||
enabled: bool,
|
||||
usable: bool,
|
||||
mirrored: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Inventory {
|
||||
displays: Vec<Display>,
|
||||
profile: Option<String>,
|
||||
pub pending: bool,
|
||||
timeout: u64,
|
||||
}
|
||||
|
||||
impl Inventory {
|
||||
pub fn external_connected(&self) -> bool {
|
||||
self.displays
|
||||
.iter()
|
||||
.any(|display| display.physical && !display.internal)
|
||||
}
|
||||
|
||||
fn available(&self, mode: Mode) -> bool {
|
||||
let usable = self.displays.iter().filter(|display| display.usable);
|
||||
match mode {
|
||||
Mode::Internal => usable.clone().any(|display| display.internal),
|
||||
Mode::External => usable.clone().any(|display| !display.internal),
|
||||
Mode::Extend | Mode::Mirror => usable.count() >= 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn current(&self) -> Mode {
|
||||
let enabled = self.displays.iter().filter(|display| display.enabled);
|
||||
if enabled.clone().any(|display| display.mirrored) {
|
||||
Mode::Mirror
|
||||
} else if enabled.clone().any(|display| display.internal) {
|
||||
if enabled.clone().any(|display| !display.internal) {
|
||||
Mode::Extend
|
||||
} else {
|
||||
Mode::Internal
|
||||
}
|
||||
} else {
|
||||
Mode::External
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(outputs: Value, profiles: Value) -> Result<Self, String> {
|
||||
let outputs = outputs.as_array().ok_or("Missing monitor inventory")?;
|
||||
let mut profile = None;
|
||||
let mut displays = Vec::new();
|
||||
for output in outputs
|
||||
.iter()
|
||||
.filter(|output| output["connected"].as_bool().unwrap_or(true))
|
||||
{
|
||||
if let Some(name) = output["profile"].as_str() {
|
||||
profile = Some(name.to_owned());
|
||||
}
|
||||
|
||||
let internal = output["internal"].as_bool().unwrap_or(false);
|
||||
let name = if internal {
|
||||
"Built-in display"
|
||||
} else {
|
||||
output["connector"]
|
||||
.as_str()
|
||||
.or_else(|| output["name"].as_str())
|
||||
.unwrap_or("External display")
|
||||
};
|
||||
let mode = if output["current_mode"].is_object() {
|
||||
&output["current_mode"]
|
||||
} else {
|
||||
output["available_modes"]
|
||||
.as_array()
|
||||
.and_then(|modes| {
|
||||
modes
|
||||
.iter()
|
||||
.find(|mode| mode["preferred"] == true)
|
||||
.or_else(|| modes.first())
|
||||
})
|
||||
.unwrap_or(&Value::Null)
|
||||
};
|
||||
let label = match (mode["width"].as_u64(), mode["height"].as_u64()) {
|
||||
(Some(width), Some(height)) => format!("{name}, {width}×{height}"),
|
||||
_ => name.to_owned(),
|
||||
};
|
||||
displays.push(Display {
|
||||
label,
|
||||
internal,
|
||||
physical: output["internal"].is_boolean(),
|
||||
enabled: output["applied_enabled"]
|
||||
.as_bool()
|
||||
.or_else(|| output["enabled"].as_bool())
|
||||
.unwrap_or(false),
|
||||
usable: output["available_modes"]
|
||||
.as_array()
|
||||
.is_some_and(|modes| !modes.is_empty()),
|
||||
mirrored: output["mirror_source"].is_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let timeout = profiles["profiles"]
|
||||
.as_array()
|
||||
.and_then(|profiles| {
|
||||
profiles
|
||||
.iter()
|
||||
.find(|entry| entry["name"].as_str() == profile.as_deref())
|
||||
})
|
||||
.and_then(|entry| entry["confirm_timeout"].as_u64())
|
||||
.unwrap_or(15);
|
||||
Ok(Self {
|
||||
displays,
|
||||
profile,
|
||||
pending: profiles["confirmation_pending"].as_bool().unwrap_or(false),
|
||||
timeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load() -> Result<Inventory, String> {
|
||||
Inventory::parse(
|
||||
compositor_ipc::call("outputs")?,
|
||||
compositor_ipc::call("output-profiles")?,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Model {
|
||||
pub open: bool,
|
||||
pub inventory: Option<Inventory>,
|
||||
selected: Mode,
|
||||
pub busy: bool,
|
||||
error: Option<String>,
|
||||
generation: u64,
|
||||
operation: u64,
|
||||
confirmation_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn begin(&mut self) {
|
||||
self.open = true;
|
||||
self.error = None;
|
||||
if let Some(inventory) = &self.inventory {
|
||||
self.selected = inventory.current();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn external_connected(&self) -> bool {
|
||||
self.inventory.as_ref().is_some_and(Inventory::external_connected)
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> bool {
|
||||
self.inventory.as_ref().is_some_and(|inventory| inventory.pending)
|
||||
}
|
||||
|
||||
fn update(&mut self, inventory: Inventory) {
|
||||
if inventory.pending && !self.pending() {
|
||||
self.confirmation_since = Some(Instant::now());
|
||||
}
|
||||
|
||||
if !inventory.pending {
|
||||
self.confirmation_since = None;
|
||||
}
|
||||
|
||||
self.selected = inventory.current();
|
||||
self.inventory = Some(inventory);
|
||||
}
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn load_display_inventory(&mut self) -> Task<Message> {
|
||||
if self.display_mode.busy {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.display_mode.generation = self.display_mode.generation.wrapping_add(1);
|
||||
let generation = self.display_mode.generation;
|
||||
Task::perform(
|
||||
async {
|
||||
tokio::task::spawn_blocking(load)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|result| result)
|
||||
},
|
||||
move |result| cosmic::Action::App(Message::DisplaysLoaded(generation, result)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn finish_display_inventory(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
result: Result<Inventory, String>,
|
||||
) -> Task<Message> {
|
||||
if generation != self.display_mode.generation || self.display_mode.busy {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(inventory) => {
|
||||
self.display_mode.update(inventory);
|
||||
}
|
||||
Err(error) => self.display_mode.error = Some(error),
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn select_display_mode(&mut self, mode: Mode, preserve_focus: bool) -> Task<Message> {
|
||||
if !self.display_mode.busy
|
||||
&& !self.display_mode.pending()
|
||||
&& self
|
||||
.display_mode
|
||||
.inventory
|
||||
.as_ref()
|
||||
.is_some_and(|inventory| inventory.available(mode))
|
||||
{
|
||||
self.display_mode.selected = mode;
|
||||
self.display_mode.error = None;
|
||||
// Native buttons clear focus on mouse clicks and retain it for
|
||||
// keyboard activation. Do not add focus back after a click.
|
||||
if preserve_focus {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Arrow/number selection leaves Enter for the default action,
|
||||
// rather than activating a previously clicked or tabbed mode.
|
||||
return cosmic::iced::advanced::widget::operate(cosmic::iced::advanced::widget::operation::scoped(
|
||||
cosmic::iced::advanced::widget::Id::new("ferese-system-modal-controls"),
|
||||
cosmic::iced::advanced::widget::operation::focusable::unfocus(),
|
||||
));
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn display_mode_key(&mut self, key: &cosmic::iced::keyboard::Key) -> Task<Message> {
|
||||
use cosmic::iced::keyboard::{Key, key::Named};
|
||||
let current = Mode::ALL
|
||||
.iter()
|
||||
.position(|mode| *mode == self.display_mode.selected)
|
||||
.unwrap();
|
||||
let mode = match key {
|
||||
Key::Character(character) => character
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|number| (1..=4).contains(number))
|
||||
.map(|number| Mode::ALL[number - 1]),
|
||||
Key::Named(Named::ArrowLeft | Named::ArrowUp | Named::ArrowRight | Named::ArrowDown) => {
|
||||
let backwards = matches!(key, Key::Named(Named::ArrowLeft | Named::ArrowUp));
|
||||
(1..=4)
|
||||
.map(|step| Mode::ALL[(current + if backwards { 4 - step } else { step }) % 4])
|
||||
.find(|mode| {
|
||||
self.display_mode
|
||||
.inventory
|
||||
.as_ref()
|
||||
.is_some_and(|inventory| inventory.available(*mode))
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
mode.map_or_else(Task::none, |mode| self.select_display_mode(mode, false))
|
||||
}
|
||||
|
||||
pub(super) fn apply_display_mode(&mut self) -> Task<Message> {
|
||||
if self.display_mode.busy
|
||||
|| self.display_mode.pending()
|
||||
|| !self
|
||||
.display_mode
|
||||
.inventory
|
||||
.as_ref()
|
||||
.is_some_and(|inventory| inventory.available(self.display_mode.selected))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let mode = self.display_mode.selected;
|
||||
self.display_operation(false, move || {
|
||||
compositor_ipc::request("output-layout", json!({"layout": mode.name()})).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn confirm_display_mode(&mut self, keep: bool) -> Task<Message> {
|
||||
if self.display_mode.busy || !self.display_mode.pending() {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.display_operation(true, move || {
|
||||
compositor_ipc::call(if keep { "output-confirm" } else { "output-revert" }).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn display_operation(
|
||||
&mut self,
|
||||
close: bool,
|
||||
operation: impl FnOnce() -> Result<(), String> + Send + 'static,
|
||||
) -> Task<Message> {
|
||||
self.display_mode.busy = true;
|
||||
self.display_mode.generation = self.display_mode.generation.wrapping_add(1);
|
||||
self.display_mode.operation = self.display_mode.operation.wrapping_add(1);
|
||||
let serial = self.display_mode.operation;
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
operation()?;
|
||||
load()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|result| result)
|
||||
},
|
||||
move |result| cosmic::Action::App(Message::DisplayModeCompleted(serial, close, result)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn finish_display_mode(
|
||||
&mut self,
|
||||
serial: u64,
|
||||
close: bool,
|
||||
result: Result<Inventory, String>,
|
||||
) -> Task<Message> {
|
||||
if serial != self.display_mode.operation {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.display_mode.busy = false;
|
||||
match result {
|
||||
Ok(inventory) => {
|
||||
self.display_mode.update(inventory);
|
||||
self.display_mode.error = None;
|
||||
if close || !self.display_mode.pending() {
|
||||
return self.close_system_modal();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Err(error) => {
|
||||
self.display_mode.error = Some(error);
|
||||
self.load_display_inventory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_mode_rows(&self) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> {
|
||||
let palette = self.config.theme.palette();
|
||||
let material_opacity = if self.config.theme.high_contrast {
|
||||
1.0
|
||||
} else {
|
||||
f32::from(self.config.theme.surface_popover[3]) / 255.0
|
||||
};
|
||||
let model = &self.display_mode;
|
||||
let pending = model.pending();
|
||||
let mut header = row![
|
||||
text("Display mode").size(17),
|
||||
cosmic::iced::widget::Space::new().width(Length::Fill)
|
||||
]
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.spacing(12);
|
||||
if let Some(profile) = model
|
||||
.inventory
|
||||
.as_ref()
|
||||
.and_then(|inventory| inventory.profile.as_deref())
|
||||
{
|
||||
header = header.push(
|
||||
text(format!("{profile} profile"))
|
||||
.size(12)
|
||||
.class(theme::Text::Color(palette.muted)),
|
||||
);
|
||||
}
|
||||
|
||||
let mut cards = row::with_capacity(4).spacing(8);
|
||||
for (index, mode) in Mode::ALL.into_iter().enumerate() {
|
||||
let available = !model.busy
|
||||
&& !pending
|
||||
&& model
|
||||
.inventory
|
||||
.as_ref()
|
||||
.is_some_and(|inventory| inventory.available(mode));
|
||||
let card = cosmic::widget::column![
|
||||
ferese_theme::icons::tinted(mode.icon(), 40, palette.text),
|
||||
text(mode.label())
|
||||
.size(13)
|
||||
.wrapping(cosmic::iced::widget::text::Wrapping::None),
|
||||
text((index + 1).to_string())
|
||||
.size(11)
|
||||
.class(theme::Text::Color(palette.muted)),
|
||||
]
|
||||
.align_x(cosmic::iced::Alignment::Center)
|
||||
.spacing(8)
|
||||
.width(Length::Fill);
|
||||
cards = cards.push(
|
||||
button::custom(card)
|
||||
.id(format!("ferese-display-mode-{}", mode.name()).into())
|
||||
.width(Length::Fill)
|
||||
.padding([14, 6])
|
||||
.class(ferese_theme::controls::material_button_style(
|
||||
palette,
|
||||
model.selected == mode,
|
||||
material_opacity,
|
||||
))
|
||||
.on_press_maybe(available.then_some(cosmic::Action::App(Message::SelectDisplayMode(mode)))),
|
||||
);
|
||||
}
|
||||
|
||||
let mut rows = cosmic::widget::column![header, cards].spacing(14);
|
||||
if let Some(inventory) = &model.inventory {
|
||||
let mut displays = cosmic::widget::column::with_capacity(inventory.displays.len()).spacing(6);
|
||||
for display in &inventory.displays {
|
||||
displays = displays.push(
|
||||
row![
|
||||
ferese_theme::icons::tinted(ferese_theme::icons::DISPLAY, 16, palette.muted),
|
||||
text(&display.label).size(12).class(theme::Text::Color(palette.muted))
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(cosmic::iced::Alignment::Center),
|
||||
);
|
||||
}
|
||||
|
||||
rows = rows.push(displays);
|
||||
}
|
||||
|
||||
if let Some(error) = &model.error {
|
||||
rows = rows.push(text(error).size(12));
|
||||
}
|
||||
|
||||
let hint = if model.busy {
|
||||
"Applying display configuration…".to_owned()
|
||||
} else if pending {
|
||||
let remaining = model
|
||||
.inventory
|
||||
.as_ref()
|
||||
.map_or(15, |inventory| inventory.timeout)
|
||||
.saturating_sub(model.confirmation_since.map_or(0, |since| since.elapsed().as_secs()));
|
||||
format!("Keep this configuration? Reverts in {remaining}s.")
|
||||
} else {
|
||||
"Arrow keys or 1–4 to choose. Enter to apply.".to_owned()
|
||||
};
|
||||
let apply = if pending {
|
||||
Message::KeepDisplayMode
|
||||
} else {
|
||||
Message::ApplyDisplayMode
|
||||
};
|
||||
let cancel = if pending {
|
||||
Message::RevertDisplayMode
|
||||
} else {
|
||||
Message::CancelPower
|
||||
};
|
||||
let can_apply = !model.busy
|
||||
&& (pending
|
||||
|| model
|
||||
.inventory
|
||||
.as_ref()
|
||||
.is_some_and(|inventory| inventory.available(model.selected)));
|
||||
rows.push(text(hint).size(12).class(theme::Text::Color(palette.muted)))
|
||||
.push(
|
||||
container(
|
||||
row![
|
||||
cosmic::iced::widget::Space::new().width(Length::Fill),
|
||||
ferese_theme::controls::text_button(
|
||||
if pending { "Revert" } else { "Cancel" },
|
||||
shell_font(),
|
||||
palette,
|
||||
false
|
||||
)
|
||||
.id("ferese-modal-cancel".into())
|
||||
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
|
||||
ferese_theme::controls::text_button(
|
||||
if pending { "Keep" } else { "Apply" },
|
||||
shell_font(),
|
||||
palette,
|
||||
true
|
||||
)
|
||||
.id("ferese-display-apply".into())
|
||||
.on_press_maybe(can_apply.then_some(cosmic::Action::App(apply))),
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.id("ferese-display-modal-actions"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn output(internal: bool, enabled: bool, connected: bool, mirror: bool) -> Value {
|
||||
json!({
|
||||
"connector": if internal { "eDP-1" } else { "DP-1" },
|
||||
"connected": connected, "internal": internal, "applied_enabled": enabled,
|
||||
"mirror_source": mirror.then_some("source"),
|
||||
"available_modes": [{"width": 1920, "height": 1080, "preferred": true}],
|
||||
"profile": "desk"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_and_mirrored_external_monitors_keep_the_bar_trigger() {
|
||||
for mirror in [false, true] {
|
||||
let inventory = Inventory::parse(
|
||||
json!([output(true, true, true, false), output(false, mirror, true, mirror)]),
|
||||
json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(inventory.external_connected());
|
||||
assert_eq!(inventory.current(), if mirror { Mode::Mirror } else { Mode::Internal });
|
||||
assert!(Mode::ALL.into_iter().all(|mode| inventory.available(mode)));
|
||||
}
|
||||
|
||||
let removed = Inventory::parse(
|
||||
json!([output(true, true, true, false), output(false, false, false, false)]),
|
||||
json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!removed.external_connected());
|
||||
assert!(removed.available(Mode::Internal));
|
||||
assert!(!removed.available(Mode::External));
|
||||
assert!(!removed.available(Mode::Extend));
|
||||
assert!(!removed.available(Mode::Mirror));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_tracks_backend_state_and_profile_timeout() {
|
||||
let mut model = Model::default();
|
||||
let outputs = json!([output(true, true, true, false), output(false, true, true, false)]);
|
||||
model.update(
|
||||
Inventory::parse(
|
||||
outputs.clone(),
|
||||
json!({"confirmation_pending": true, "profiles": [{"name": "desk", "confirm_timeout": 7}]}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(model.pending());
|
||||
assert_eq!(model.selected, Mode::Extend);
|
||||
assert_eq!(model.inventory.as_ref().unwrap().timeout, 7);
|
||||
let since = model.confirmation_since;
|
||||
model.update(Inventory::parse(outputs.clone(), json!({"confirmation_pending": true})).unwrap());
|
||||
assert_eq!(
|
||||
model.confirmation_since, since,
|
||||
"inventory refresh must not restart the countdown"
|
||||
);
|
||||
model.update(Inventory::parse(outputs, json!({"confirmation_pending": false})).unwrap());
|
||||
assert!(!model.pending());
|
||||
assert_eq!(model.confirmation_since, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_monitor_does_not_enable_an_unavailable_mode() {
|
||||
let inventory = Inventory::parse(
|
||||
json!([{"connector":"DP-1", "connected":true, "internal":false, "available_modes":[]}]),
|
||||
json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(inventory.external_connected());
|
||||
assert!(Mode::ALL.into_iter().all(|mode| !inventory.available(mode)));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use surfaces::*;
|
||||
mod compositor_ipc;
|
||||
mod config;
|
||||
mod control;
|
||||
mod display_mode;
|
||||
mod keybinding_guide;
|
||||
mod motion;
|
||||
mod note_store;
|
||||
@@ -177,6 +178,7 @@ struct FereseShell {
|
||||
theme_error: Option<String>,
|
||||
menu: Option<status_ui::OpenMenu>,
|
||||
system_modal: Option<system_modal::SystemModal>,
|
||||
display_mode: display_mode::Model,
|
||||
guide_shown: bool,
|
||||
guide_load: keybinding_guide::LoadState,
|
||||
guide_attempts: u8,
|
||||
@@ -262,6 +264,13 @@ enum Message {
|
||||
output: Option<String>,
|
||||
result: Result<Vec<keybinding_guide::Entry>, String>,
|
||||
},
|
||||
OpenDisplays(Option<String>),
|
||||
DisplaysLoaded(u64, Result<display_mode::Inventory, String>),
|
||||
SelectDisplayMode(display_mode::Mode),
|
||||
ApplyDisplayMode,
|
||||
DisplayModeCompleted(u64, bool, Result<display_mode::Inventory, String>),
|
||||
KeepDisplayMode,
|
||||
RevertDisplayMode,
|
||||
SystemInhibitors(window::Id, Result<compositor_ipc::Approval, String>),
|
||||
ConfirmPower(status::Action),
|
||||
CancelPower,
|
||||
@@ -303,6 +312,7 @@ impl cosmic::Application for FereseShell {
|
||||
theme_error: None,
|
||||
menu: None,
|
||||
system_modal: None,
|
||||
display_mode: display_mode::Model::default(),
|
||||
guide_shown: false,
|
||||
guide_load: Default::default(),
|
||||
guide_attempts: 0,
|
||||
@@ -358,7 +368,11 @@ impl cosmic::Application for FereseShell {
|
||||
self.notifications
|
||||
.tick_subscription()
|
||||
.map(|_| Message::NotificationTick),
|
||||
event::listen_with(|event, _status, id| match &event {
|
||||
event::listen_with(|event, status, id| match &event {
|
||||
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
|
||||
..
|
||||
}) if status == cosmic::iced::event::Status::Captured => None,
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING
|
||||
.load(Ordering::Relaxed)
|
||||
.then_some(Message::Event(event, id)),
|
||||
@@ -691,6 +705,13 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::OpenDisplays(output) => self.open_display_modal(output.as_deref()),
|
||||
Message::DisplaysLoaded(generation, result) => self.finish_display_inventory(generation, result),
|
||||
Message::SelectDisplayMode(mode) => self.select_display_mode(mode, true),
|
||||
Message::ApplyDisplayMode => self.apply_display_mode(),
|
||||
Message::DisplayModeCompleted(serial, close, result) => self.finish_display_mode(serial, close, result),
|
||||
Message::KeepDisplayMode => self.confirm_display_mode(true),
|
||||
Message::RevertDisplayMode => self.confirm_display_mode(false),
|
||||
Message::ExecutePower => self.execute_system_modal(),
|
||||
Message::AnimatePower => self.animate_system_modal(),
|
||||
Message::PowerCompleted(id, result) => self.finish_power_action(id, result),
|
||||
@@ -812,6 +833,8 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
for command in poll.commands {
|
||||
let task = match command {
|
||||
control::ControlCommand::ToggleDisplays(output) => self.open_display_modal(Some(&output)),
|
||||
control::ControlCommand::MonitorsChanged => self.load_display_inventory(),
|
||||
control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output),
|
||||
control::ControlCommand::CancelLogout(serial) => self.cancel_logout_modal(serial),
|
||||
control::ControlCommand::Logout(serial, output) => {
|
||||
|
||||
@@ -217,6 +217,40 @@ impl FereseShell {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
|
||||
..
|
||||
}) = &event
|
||||
&& self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.is_display_mode() && modal.contains(id))
|
||||
{
|
||||
return if self.display_mode.pending() {
|
||||
self.confirm_display_mode(true)
|
||||
} else {
|
||||
self.apply_display_mode()
|
||||
};
|
||||
}
|
||||
|
||||
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key, .. }) = &event
|
||||
&& self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.is_display_mode() && modal.contains(id))
|
||||
&& matches!(
|
||||
key,
|
||||
cosmic::iced::keyboard::Key::Character(_)
|
||||
| cosmic::iced::keyboard::Key::Named(
|
||||
cosmic::iced::keyboard::key::Named::ArrowLeft
|
||||
| cosmic::iced::keyboard::key::Named::ArrowRight
|
||||
| cosmic::iced::keyboard::key::Named::ArrowUp
|
||||
| cosmic::iced::keyboard::key::Named::ArrowDown
|
||||
)
|
||||
)
|
||||
{
|
||||
return self.display_mode_key(key);
|
||||
}
|
||||
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab),
|
||||
modifiers,
|
||||
|
||||
@@ -59,6 +59,7 @@ struct ModalSurface {
|
||||
enum Content {
|
||||
Power(PowerAction),
|
||||
Guide(Vec<keybinding_guide::Entry>),
|
||||
Displays,
|
||||
}
|
||||
|
||||
pub(super) struct SystemModal {
|
||||
@@ -75,6 +76,10 @@ impl SystemModal {
|
||||
matches!(self.content, Content::Guide(_))
|
||||
}
|
||||
|
||||
pub(super) fn is_display_mode(&self) -> bool {
|
||||
matches!(self.content, Content::Displays)
|
||||
}
|
||||
|
||||
pub(super) fn contains(&self, id: window::Id) -> bool {
|
||||
self.surfaces.iter().any(|surface| surface.id == id)
|
||||
}
|
||||
@@ -85,6 +90,20 @@ impl FereseShell {
|
||||
self.open_modal(Content::Power(action), output_name)
|
||||
}
|
||||
|
||||
pub(super) fn open_display_modal(&mut self, output: Option<&str>) -> Task<Message> {
|
||||
if self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.is_display_mode() && !modal.motion.closing())
|
||||
{
|
||||
return self.close_system_modal();
|
||||
}
|
||||
self.display_mode.begin();
|
||||
let open = self.open_modal(Content::Displays, output);
|
||||
let load = self.load_display_inventory();
|
||||
Task::batch([open, load])
|
||||
}
|
||||
|
||||
pub(super) fn rebuild_system_modal(&mut self) -> Task<Message> {
|
||||
let guide = self.system_modal.as_ref().and_then(|modal| match &modal.content {
|
||||
Content::Guide(entries)
|
||||
@@ -95,6 +114,10 @@ impl FereseShell {
|
||||
_ => None,
|
||||
});
|
||||
let destroy = self.destroy_system_modal(true);
|
||||
if self.display_mode.open {
|
||||
let open = self.open_modal(Content::Displays, None);
|
||||
return Task::batch([destroy, open]);
|
||||
}
|
||||
if let Some(entries) = guide {
|
||||
if self.outputs.is_empty() {
|
||||
self.guide_shown = false;
|
||||
@@ -136,6 +159,9 @@ impl FereseShell {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if content != Content::Displays {
|
||||
self.display_mode.open = false;
|
||||
}
|
||||
let primary = output_name
|
||||
.and_then(|name| {
|
||||
self.outputs
|
||||
@@ -187,7 +213,7 @@ impl FereseShell {
|
||||
Content::Power(PowerAction::Suspend) => Some(compositor_ipc::Mode::Suspend),
|
||||
Content::Power(PowerAction::Logout(_)) => Some(compositor_ipc::Mode::Logout),
|
||||
Content::Power(_) => Some(compositor_ipc::Mode::Shutdown),
|
||||
Content::Guide(_) => None,
|
||||
Content::Guide(_) | Content::Displays => None,
|
||||
};
|
||||
if let Some(mode) = mode {
|
||||
let id = surfaces[0].id;
|
||||
@@ -276,6 +302,16 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub(super) fn close_system_modal(&mut self) -> Task<Message> {
|
||||
if self.system_modal.as_ref().is_some_and(SystemModal::is_display_mode) {
|
||||
if self.display_mode.busy {
|
||||
return Task::none();
|
||||
}
|
||||
if self.display_mode.pending() {
|
||||
return self.confirm_display_mode(false);
|
||||
}
|
||||
self.display_mode.open = false;
|
||||
}
|
||||
|
||||
if let Some(modal) = &mut self.system_modal {
|
||||
if let Content::Power(PowerAction::Logout(serial)) = modal.content
|
||||
&& let Some(control) = &self.control
|
||||
@@ -341,7 +377,9 @@ impl FereseShell {
|
||||
|
||||
pub(super) fn focus_system_modal(&self, id: window::Id) -> Task<Message> {
|
||||
if self.system_modal.as_ref().is_some_and(|modal| {
|
||||
!modal.motion.closing() && modal.surfaces.iter().any(|surface| surface.id == id && surface.primary)
|
||||
!modal.is_display_mode()
|
||||
&& !modal.motion.closing()
|
||||
&& modal.surfaces.iter().any(|surface| surface.id == id && surface.primary)
|
||||
}) {
|
||||
button::focus("ferese-modal-cancel".into())
|
||||
} else {
|
||||
@@ -487,6 +525,7 @@ impl FereseShell {
|
||||
.id("ferese-system-modal-controls"),
|
||||
)
|
||||
}
|
||||
Content::Displays => column![container(self.display_mode_rows()).id("ferese-system-modal-controls")],
|
||||
Content::Guide(entries) => {
|
||||
let mut bindings = column::with_capacity(entries.len()).spacing(12);
|
||||
for entry in entries {
|
||||
@@ -529,7 +568,11 @@ impl FereseShell {
|
||||
container(rows)
|
||||
.id("ferese-blur-card")
|
||||
.width(Length::Fill)
|
||||
.max_width(if modal.is_guide() { 600 } else { 360 })
|
||||
.max_width(if modal.is_display_mode() || modal.is_guide() {
|
||||
600
|
||||
} else {
|
||||
360
|
||||
})
|
||||
.padding(if modal.is_guide() { 24 } else { 18 })
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: (!material)
|
||||
@@ -608,9 +651,19 @@ mod tests {
|
||||
}
|
||||
|
||||
let mut outside = Focus(true);
|
||||
let mut cancel = Focus(true);
|
||||
let mut cancel = Focus(false);
|
||||
let mut confirm = Focus(false);
|
||||
for (backwards, expected_cancel) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
assert!(
|
||||
!cancel.0 && !confirm.0,
|
||||
"opening the chooser leaves its buttons unfocused"
|
||||
);
|
||||
for (backwards, expected_cancel) in [
|
||||
(false, true),
|
||||
(false, false),
|
||||
(false, true),
|
||||
(true, false),
|
||||
(true, true),
|
||||
] {
|
||||
let mut current: Box<dyn Operation<()>> = modal_focus_operation(backwards);
|
||||
loop {
|
||||
current.focusable(Some(&Id::new("bar")), Rectangle::default(), &mut outside);
|
||||
|
||||
Reference in new issue
Block a user