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
+1354
-72
No files matched your search
@@ -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