diff --git a/Cargo.lock b/Cargo.lock index a0b8149..ae0cf13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1186,7 +1186,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1207,7 +1207,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "quote", "syn 2.0.110", @@ -1384,7 +1384,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "almost", "cosmic-config", @@ -2600,7 +2600,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "dnd", "iced_accessibility", @@ -2618,7 +2618,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "accesskit", "accesskit_winit", @@ -2627,7 +2627,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bitflags 2.10.0", "bytes", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "futures", "iced_core", @@ -2678,7 +2678,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -2700,7 +2700,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -2712,7 +2712,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bytes", "cosmic-client-toolkit", @@ -2728,7 +2728,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bytemuck", "cosmic-text", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "as-raw-xcb-connection", "bitflags 2.10.0", @@ -2775,7 +2775,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -2795,7 +2795,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -3271,7 +3271,7 @@ checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "apply", "ashpd 0.12.0", diff --git a/src/components/app.rs b/src/components/app.rs index 1b531fc..1d30db7 100644 --- a/src/components/app.rs +++ b/src/components/app.rs @@ -16,14 +16,16 @@ use cosmic::{ alignment::Horizontal, event::{ self, listen_with, - wayland::{self, LayerEvent, OverlapNotifyEvent}, + wayland::{self, LayerEvent, OutputEvent, OverlapNotifyEvent}, }, keyboard::{Key, key::Named}, platform_specific::shell::commands::activation::request_token, time, window::Id as SurfaceId, }, - iced_runtime::platform_specific::wayland::layer_surface::SctkLayerSurfaceSettings, + iced_runtime::platform_specific::wayland::layer_surface::{ + IcedOutput, SctkLayerSurfaceSettings, + }, iced_winit::commands::layer_surface::{ Anchor, KeyboardInteractivity, destroy_layer_surface, get_layer_surface, }, @@ -49,6 +51,9 @@ use std::{ }; use zbus::Connection; +// Type alias for Wayland output. Matches what's used in SctkLayerSurfaceSettings +type WlOutput = cosmic::cctk::sctk::reexports::client::protocol::wl_output::WlOutput; + const COUNTDOWN_LENGTH: u8 = 60; static CONFIRM_ID: LazyLock = LazyLock::new(|| iced::id::Id::new("confirm-id")); static AUTOSIZE_DIALOG_ID: LazyLock = @@ -66,6 +71,10 @@ pub struct Args { pub enum OsdTask { #[clap(about = "Display external display toggle indicator")] Display, + #[clap(about = "Show numbers on all displays for identification")] + IdentifyDisplays, + #[clap(about = "Dismiss display identification numbers")] + DismissDisplayIdentifiers, #[clap(about = "Toggle the on screen display and start the log out timer")] LogOut, #[clap(about = "Toggle the on screen display and start the restart timer")] @@ -114,6 +123,8 @@ impl OsdTask { .map(msg), OsdTask::Touchpad => Task::none(), OsdTask::Display => Task::none(), + OsdTask::IdentifyDisplays => Task::none(), + OsdTask::DismissDisplayIdentifiers => Task::none(), } } } @@ -269,10 +280,17 @@ pub enum Msg { SoundSettings, TouchpadEnabled(Option), ActivationToken(Option), + DisplayIdentifierSurface((SurfaceId, osd_indicator::Msg)), + ResetDisplayIdentifierTimer(SurfaceId), + CreateDisplayIdentifiers(Vec<(String, u32)>), + DismissDisplayIdentifiers, + OutputInfo(WlOutput, String), + OutputRemoved(WlOutput), } enum Surface { PolkitDialog(polkit_dialog::State), + OsdIndicator(osd_indicator::State), } struct App { @@ -294,6 +312,9 @@ struct App { overlap: HashMap, size: Option, action_to_confirm: Option<(SurfaceId, OsdTask, u8)>, + wayland_outputs: HashMap, + display_identifier_displays: HashMap, + identifiers_dismissed: bool, } impl App { @@ -359,6 +380,53 @@ impl App { } state.margin = (top, right, bottom, left); } + + fn trigger_identify_displays(&self) -> cosmic::app::Task { + cosmic::task::future(async move { + // Add a small delay to allow cosmic-randr to sync with display changes + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let Ok(output_lists) = cosmic_randr_shell::list().await else { + log::error!("Failed to list displays with cosmic-randr"); + return Msg::CreateDisplayIdentifiers(Vec::new()); + }; + + // Get all enabled outputs and number them the same way cosmic-settings does: + // Sort alphabetically by output name, then assign numbers 1, 2, 3... + use std::collections::BTreeMap; + + let sorted_outputs: BTreeMap<&str, _> = output_lists + .outputs + .iter() + .filter(|(_, o)| o.enabled) + .map(|(key, output)| (output.name.as_str(), (key, output))) + .collect(); + + let displays: Vec<(String, u32)> = sorted_outputs + .into_iter() + .enumerate() + .map(|(index, (name, _))| (name.to_string(), (index + 1) as u32)) + .collect(); + + log::debug!( + "Identified {} enabled displays: {:?}", + displays.len(), + displays + ); + + // Only show identifiers if there are 2 or more displays + if displays.len() < 2 { + log::info!( + "Skipping display identifiers: only {} enabled display(s)", + displays.len() + ); + return Msg::CreateDisplayIdentifiers(Vec::new()); + } + + Msg::CreateDisplayIdentifiers(displays) + }) + .map(cosmic::Action::App) + } } impl cosmic::Application for App { @@ -388,6 +456,9 @@ impl cosmic::Application for App { overlap: HashMap::new(), size: None, action_to_confirm: None, + wayland_outputs: HashMap::new(), + display_identifier_displays: HashMap::new(), + identifiers_dismissed: false, }, Task::none(), ) @@ -404,7 +475,14 @@ impl cosmic::Application for App { fn update(&mut self, message: Msg) -> Task { match message { Msg::Action(action) => { - if matches!(action, OsdTask::Restart) + // Some actions don't require confirmation and execute immediately + if matches!(action, OsdTask::IdentifyDisplays) { + // Clear dismissed flag to allow showing identifiers + self.identifiers_dismissed = false; + return self.trigger_identify_displays(); + } else if matches!(action, OsdTask::DismissDisplayIdentifiers) { + return Task::done(cosmic::Action::App(Msg::DismissDisplayIdentifiers)); + } else if matches!(action, OsdTask::Restart) && matches!(self.action_to_confirm, Some((_, OsdTask::Shutdown, _))) { action.perform() @@ -498,6 +576,21 @@ impl cosmic::Application for App { } Task::none() } + Msg::DisplayIdentifierSurface((id, msg)) => { + if let Some(Surface::OsdIndicator(state)) = self.surfaces.remove(&id) { + let (state, cmd) = state.update(msg); + if let Some(state) = state { + self.surfaces.insert(id, Surface::OsdIndicator(state)); + } else { + self.display_identifier_displays.remove(&id); + log::debug!("Display identifier surface {:?} closed", id); + } + return cmd.map(move |msg| { + cosmic::action::app(Msg::DisplayIdentifierSurface((id, msg))) + }); + } + Task::none() + } Msg::OsdIndicator(msg) => { if let Some((id, state)) = self.indicator.take() { let (state, cmd) = state.update(msg); @@ -656,7 +749,7 @@ impl cosmic::Application for App { } Msg::Zbus(result) => { if let Err(e) = result { - eprintln!("cosmic-osd ERROR: '{}'", e); + log::error!("D-Bus error: {}", e); } Task::none() } @@ -717,6 +810,240 @@ impl cosmic::Application for App { self.indicator = Some((id, state)); cmd.map(|x| cosmic::Action::App(Msg::OsdIndicator(x))) } + Msg::OutputInfo(output, name) => { + let is_new = !self.wayland_outputs.contains_key(&name); + self.wayland_outputs + .insert(name.clone(), (output, name.clone())); + + if is_new { + log::debug!("Display '{}' added to wayland outputs tracking", name); + } + Task::none() + } + Msg::OutputRemoved(output) => { + // Find and remove the output from our tracking map + let mut removed_name = None; + self.wayland_outputs.retain(|name, (out, _)| { + if out == &output { + removed_name = Some(name.clone()); + false + } else { + true + } + }); + + if let Some(name) = removed_name { + log::info!( + "Display '{}' disconnected, updating display identifiers", + name + ); + // Trigger display identifier OSD to show the updated numbering + Task::done(cosmic::Action::App(Msg::Action(OsdTask::IdentifyDisplays))) + } else { + log::warn!( + "OutputRemoved event received but display not found in wayland_outputs" + ); + Task::none() + } + } + Msg::CreateDisplayIdentifiers(displays) => { + if displays.is_empty() { + log::warn!("CreateDisplayIdentifiers called with empty display list"); + return Task::none(); + } + + if self.identifiers_dismissed { + log::debug!( + "Ignoring CreateDisplayIdentifiers: identifiers were explicitly dismissed" + ); + return Task::none(); + } + + log::info!( + "Creating display identifiers for {} displays: {:?}", + displays.len(), + displays + ); + log::debug!( + "Current wayland_outputs: {:?}", + self.wayland_outputs.keys().collect::>() + ); + + self.identifiers_dismissed = false; + + let mut tasks = Vec::new(); + + let requested_displays: HashMap = displays.iter().cloned().collect(); + + let mut existing_identifiers: HashMap = HashMap::new(); + for (id, display_name) in &self.display_identifier_displays { + if let Some(Surface::OsdIndicator(state)) = self.surfaces.get(id) { + if let osd_indicator::Params::DisplayNumber(num) = state.params() { + existing_identifiers.insert(display_name.clone(), (*id, *num)); + } + } + } + + log::debug!("Found {} existing identifiers", existing_identifiers.len()); + + let mut kept_ids = std::collections::HashSet::new(); + + // Process each requested display + for (display_name, display_number) in &displays { + if let Some((existing_id, existing_number)) = + existing_identifiers.get(display_name) + { + // We have an existing identifier for this display + if existing_number == display_number { + log::debug!( + "Display '{}' already has correct identifier (number {}), resetting timer", + display_name, + display_number + ); + kept_ids.insert(*existing_id); + tasks.push(Task::done(cosmic::Action::App( + Msg::ResetDisplayIdentifierTimer(*existing_id), + ))); + } else { + log::debug!( + "Display '{}' has wrong number (has {}, needs {}), recreating", + display_name, + existing_number, + display_number + ); + self.surfaces.remove(existing_id); + self.display_identifier_displays.remove(existing_id); + tasks.push(destroy_layer_surface(*existing_id)); + + let id = SurfaceId::unique(); + log::debug!( + "Creating identifier surface for display '{}' (number {})", + display_name, + display_number + ); + + let iced_output = + if let Some((output, _)) = self.wayland_outputs.get(display_name) { + IcedOutput::Output(output.clone()) + } else { + log::warn!( + "Display '{}' not found in wayland_outputs", + display_name + ); + IcedOutput::Active + }; + + let (state, cmd) = osd_indicator::State::new_with_output( + id, + osd_indicator::Params::DisplayNumber(*display_number), + iced_output, + ); + + self.surfaces.insert(id, Surface::OsdIndicator(state)); + self.display_identifier_displays + .insert(id, display_name.clone()); + tasks.push(cmd.map(move |msg| { + cosmic::action::app(Msg::DisplayIdentifierSurface((id, msg))) + })); + } + } else { + // No existing identifier for this display, create new one + let id = SurfaceId::unique(); + log::debug!( + "Creating identifier surface for display '{}' (number {})", + display_name, + display_number + ); + + let iced_output = if let Some((output, _)) = + self.wayland_outputs.get(display_name) + { + IcedOutput::Output(output.clone()) + } else { + log::warn!("Display '{}' not found in wayland_outputs", display_name); + IcedOutput::Active + }; + + let (state, cmd) = osd_indicator::State::new_with_output( + id, + osd_indicator::Params::DisplayNumber(*display_number), + iced_output, + ); + + self.surfaces.insert(id, Surface::OsdIndicator(state)); + self.display_identifier_displays + .insert(id, display_name.clone()); + tasks.push(cmd.map(move |msg| { + cosmic::action::app(Msg::DisplayIdentifierSurface((id, msg))) + })); + } + } + + // Remove any identifiers that weren't in the requested list + let ids_to_remove: Vec = existing_identifiers + .iter() + .filter_map(|(name, (id, _))| { + if !requested_displays.contains_key(name) && !kept_ids.contains(id) { + Some(*id) + } else { + None + } + }) + .collect(); + + if !ids_to_remove.is_empty() { + log::debug!("Removing {} obsolete identifiers", ids_to_remove.len()); + for id in ids_to_remove { + self.surfaces.remove(&id); + self.display_identifier_displays.remove(&id); + tasks.push(destroy_layer_surface(id)); + } + } + + Task::batch(tasks) + } + Msg::ResetDisplayIdentifierTimer(id) => { + if let Some(Surface::OsdIndicator(state)) = self.surfaces.get_mut(&id) { + return state.reset_display_identifier_timer().map(move |msg| { + cosmic::action::app(Msg::DisplayIdentifierSurface((id, msg))) + }); + } + Task::none() + } + Msg::DismissDisplayIdentifiers => { + let ids_to_remove: Vec = self + .surfaces + .iter() + .filter_map(|(id, surface)| { + if let Surface::OsdIndicator(state) = surface { + if matches!(state.params(), osd_indicator::Params::DisplayNumber(_)) { + Some(*id) + } else { + None + } + } else { + None + } + }) + .collect(); + + log::info!( + "Dismissing {} display identifier surfaces", + ids_to_remove.len() + ); + + // Mark as explicitly dismissed to prevent race conditions + self.identifiers_dismissed = true; + + let mut tasks = Vec::new(); + for id in ids_to_remove { + self.surfaces.remove(&id); + self.display_identifier_displays.remove(&id); + tasks.push(destroy_layer_surface(id)); + } + + Task::batch(tasks) + } } } @@ -746,8 +1073,35 @@ impl cosmic::Application for App { event::Event::Window(iced::window::Event::Resized(s)) => Some(Msg::Size(s)), event::Event::PlatformSpecific(event::PlatformSpecific::Wayland(wayland_event)) => { match wayland_event { - event::wayland::Event::OverlapNotify(event, _, _) => Some(Msg::Overlap(event)), + event::wayland::Event::OverlapNotify(event, ..) => Some(Msg::Overlap(event)), wayland::Event::Layer(LayerEvent::Unfocused, ..) => Some(Msg::Cancel), + event::wayland::Event::Output(output_event, output) => { + match output_event { + OutputEvent::Created(Some(info)) => { + // Track this output for creating per-display surfaces + if let Some(name) = info.name.clone() { + log::debug!("Output Created: {}", name); + Some(Msg::OutputInfo(output.clone(), name)) + } else { + None + } + } + OutputEvent::InfoUpdate(info) => { + // Update existing output info + if let Some(name) = info.name.clone() { + log::debug!("Output InfoUpdate: {}", name); + Some(Msg::OutputInfo(output.clone(), name)) + } else { + None + } + } + OutputEvent::Removed => { + log::debug!("Output Removed"); + Some(Msg::OutputRemoved(output.clone())) + } + OutputEvent::Created(None) => None, + } + } _ => None, } } @@ -760,9 +1114,16 @@ impl cosmic::Application for App { _ => None, })); - subscriptions.extend(self.surfaces.iter().map(|(id, surface)| match surface { - Surface::PolkitDialog(state) => state.subscription().with(*id).map(Msg::PolkitDialog), - })); + subscriptions.extend( + self.surfaces + .iter() + .filter_map(|(id, surface)| match surface { + Surface::PolkitDialog(state) => { + Some(state.subscription().with(*id).map(Msg::PolkitDialog)) + } + Surface::OsdIndicator(_) => None, // OSD indicators don't have subscriptions + }), + ); if self.action_to_confirm.is_some() { subscriptions.push(time::every(Duration::from_millis(1000)).map(|_| Msg::Countdown)); } @@ -780,6 +1141,9 @@ impl cosmic::Application for App { Surface::PolkitDialog(state) => { state.view().map(move |msg| Msg::PolkitDialog((id, msg))) } + Surface::OsdIndicator(state) => state + .view() + .map(move |msg| Msg::DisplayIdentifierSurface((id, msg))), }; } else if let Some((indicator_id, state)) = &self.indicator { if id == *indicator_id { @@ -796,6 +1160,8 @@ impl cosmic::Application for App { OsdTask::ConfirmHeadphones { .. } => "confirm-device-type", OsdTask::Touchpad => "touchpad", OsdTask::Display => "external-display", + OsdTask::IdentifyDisplays => "identify-displays", + OsdTask::DismissDisplayIdentifiers => "dismiss-display-identifiers", }; let title = fl!( @@ -1099,6 +1465,12 @@ impl cosmic::Application for App { Msg::Display(enabled) }); + } else if let OsdTask::IdentifyDisplays = cmd { + // Clear dismissed flag to allow showing identifiers + self.identifiers_dismissed = false; + return self.trigger_identify_displays(); + } else if let OsdTask::DismissDisplayIdentifiers = cmd { + return Task::done(cosmic::Action::App(Msg::DismissDisplayIdentifiers)); } if let Some(prev) = self.action_to_confirm.take() { diff --git a/src/components/osd_indicator.rs b/src/components/osd_indicator.rs index 75b50be..350f5f0 100644 --- a/src/components/osd_indicator.rs +++ b/src/components/osd_indicator.rs @@ -5,7 +5,9 @@ use crate::{components::app::DisplayMode, config}; use cosmic::{ Apply, Element, Task, iced::{self, Alignment, Border, Length, window::Id as SurfaceId}, - iced_runtime::platform_specific::wayland::layer_surface::SctkLayerSurfaceSettings, + iced_runtime::platform_specific::wayland::layer_surface::{ + IcedMargin, IcedOutput, SctkLayerSurfaceSettings, + }, iced_winit::commands::{ layer_surface::{ Anchor, KeyboardInteractivity, Layer, destroy_layer_surface, get_layer_surface, @@ -26,6 +28,7 @@ pub static OSD_INDICATOR_ID: LazyLock = pub enum Params { DisplayBrightness(f64), DisplayToggle(DisplayMode), + DisplayNumber(u32), KeyboardBrightness(f64), SinkVolume(u32, bool), SourceVolume(u32, bool), @@ -39,6 +42,9 @@ impl Params { Self::DisplayBrightness(_) => "display-brightness-symbolic", Self::DisplayToggle(DisplayMode::All) => "laptop-symbolic", Self::DisplayToggle(DisplayMode::External) => "display-symbolic", + Self::DisplayNumber(_) => { + unreachable!("DisplayNumber uses custom rendering and should not call icon_name()") + } Self::KeyboardBrightness(_) => "keyboard-brightness-symbolic", Self::AirplaneMode(true) => "airplane-mode-symbolic", Self::AirplaneMode(false) => "airplane-mode-disabled-symbolic", @@ -82,6 +88,7 @@ impl Params { Self::AirplaneMode(_) => None, Self::TouchpadEnabled(_) => None, Self::DisplayToggle(_) => None, + Self::DisplayNumber(_) => None, } } } @@ -107,11 +114,26 @@ fn close_timer() -> (Task, AbortHandle) { let duration = Duration::from_secs(3); tokio::time::sleep(duration).await; }); - let command = Task::perform(future, |res| { - if res == Err(Aborted) { - Msg::Ignore - } else { - Msg::Close + let command = cosmic::task::future(async move { + match future.await { + Ok(_) => Msg::Close, + Err(Aborted) => Msg::Ignore, + } + }); + (command, timer_abort) +} + +/// Creates a 1-second timer for display identifiers +/// When the timer expires, it sends Msg::Close to remove the display identifier +fn display_identifier_timer() -> (Task, AbortHandle) { + let (future, timer_abort) = abortable(async { + let duration = Duration::from_secs(1); + tokio::time::sleep(duration).await; + }); + let command = cosmic::task::future(async move { + match future.await { + Ok(_) => Msg::Close, + Err(Aborted) => Msg::Ignore, } }); (command, timer_abort) @@ -119,30 +141,83 @@ fn close_timer() -> (Task, AbortHandle) { impl State { pub fn new(id: SurfaceId, params: Params) -> (Self, Task) { - // Anchor to bottom right, with margin? + Self::new_with_output(id, params, IcedOutput::Active) + } + + pub fn new_with_output(id: SurfaceId, params: Params, output: IcedOutput) -> (Self, Task) { let mut cmds = vec![]; + + let is_display_number = matches!(params, Params::DisplayNumber(_)); + let anchor = if is_display_number { + Anchor::TOP + } else { + Anchor::BOTTOM + }; + + // For display numbers, set exclusive_zone to -1 so they don't block input + // in transparent areas. For other OSDs, use default behavior. + let exclusive_zone = if is_display_number { -1 } else { 0 }; + let margin = if is_display_number { + // Set top margin for display identifiers + IcedMargin { + top: 48, + right: 0, + bottom: 0, + left: 0, + } + } else { + // No margin for other OSDs (they use widget-based margins) + IcedMargin { + top: 0, + right: 0, + bottom: 0, + left: 0, + } + }; + cmds.push(get_layer_surface(SctkLayerSurfaceSettings { id, keyboard_interactivity: KeyboardInteractivity::None, namespace: "osd".into(), layer: Layer::Overlay, size: None, - anchor: Anchor::BOTTOM, + anchor, + output, + exclusive_zone, + margin, ..Default::default() })); + cmds.push(overlap_notify(id, true)); - let (cmd, timer_abort) = close_timer(); - cmds.push(cmd); + + // Display numbers auto-close after 1 second, other OSDs after 3 seconds + let timer_abort = if is_display_number { + let (cmd, timer_abort) = display_identifier_timer(); + cmds.push(cmd); + timer_abort + } else { + let (cmd, timer_abort) = close_timer(); + cmds.push(cmd); + timer_abort + }; let amplification_sink = config::amplification_sink(); let amplification_source = config::amplification_source(); + // Margin: (top, right, bottom, left) + // Display numbers at top, other OSDs at bottom + let margin = if is_display_number { + (48, 0, 0, 0) // Top margin for display numbers + } else { + (0, 0, 48, 0) // Bottom margin for other OSDs + }; + ( Self { id, params, timer_abort, - margin: (0, 0, 48, 0), + margin, amplification_sink, amplification_source, }, @@ -150,6 +225,10 @@ impl State { ) } + pub fn params(&self) -> &Params { + &self.params + } + // Re-use OSD surface to show a different OSD // Resets close timer pub fn replace_params(&mut self, params: Params) -> Task { @@ -161,6 +240,19 @@ impl State { cmd } + // Reset the timer for display identifiers + // This is called when a new identify message is received to keep them visible + pub fn reset_display_identifier_timer(&mut self) -> Task { + if !matches!(self.params, Params::DisplayNumber(_)) { + return Task::none(); + } + + self.timer_abort.abort(); + let (cmd, timer_abort) = display_identifier_timer(); + self.timer_abort = timer_abort; + cmd + } + fn max_value(&self) -> f32 { match self.params { Params::SinkVolume(_, _) => { @@ -182,6 +274,11 @@ impl State { } pub fn view(&self) -> Element<'_, Msg> { + // Display numbers use a completely different rendering + if let Params::DisplayNumber(display_number) = self.params { + return self.view_display_number(display_number); + } + let icon = widget::icon::from_name(self.params.icon_name()); // Use large radius on value-OSD to enforce pill-shape with "Round" system style @@ -275,6 +372,58 @@ impl State { .into() } + fn view_display_number(&self, display_number: u32) -> Element<'_, Msg> { + const CONTAINER_BASE_SIZE: u16 = 27; + const TEXT_SIZE: u16 = 45; + + let theme = cosmic::theme::active(); + let cosmic_theme = theme.cosmic(); + + let number_text = widget::text::title1(format!("{}", display_number)) + .size(TEXT_SIZE) + .line_height(cosmic::iced::widget::text::LineHeight::Absolute( + cosmic::iced::Pixels(TEXT_SIZE as f32), + )) + .width(Length::Shrink) + .align_x(Alignment::Center) + .align_y(Alignment::Center); + + let content = widget::container(number_text) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center); + + let padding = cosmic_theme.space_l(); + let square_size = (CONTAINER_BASE_SIZE + (padding * 2)) as f32; + + let container = widget::container(content) + .padding(padding) + .width(Length::Fixed(square_size)) + .height(Length::Fixed(square_size)) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .class(cosmic::theme::Container::custom(move |theme| { + widget::container::Style { + text_color: Some(iced::Color::from(theme.cosmic().on_accent_color()).into()), + background: Some(iced::Color::from(theme.cosmic().accent_color()).into()), + border: Border { + radius: theme.cosmic().radius_m().into(), + width: 0.0, + color: iced::Color::TRANSPARENT, + }, + shadow: Default::default(), + icon_color: Some(iced::Color::from(theme.cosmic().on_accent_color()).into()), + } + })); + + let autosize_id = iced::id::Id::new(format!("display-number-{}", display_number)); + widget::autosize::autosize(container, autosize_id) + .min_width(1.) + .min_height(1.) + .into() + } + pub fn update(self, msg: Msg) -> (Option, Task) { log::trace!("indicator msg: {:?}", msg); match msg { diff --git a/src/subscriptions/polkit_agent.rs b/src/subscriptions/polkit_agent.rs index 2db1176..5961ef1 100644 --- a/src/subscriptions/polkit_agent.rs +++ b/src/subscriptions/polkit_agent.rs @@ -19,8 +19,9 @@ pub fn subscription(system_connection: zbus::Connection) -> iced::Subscription