use cosmic::Element; use cosmic::iced::{Length, window}; use cosmic::widget::{button, container, icon, mouse_area, row}; use crate::control::{ShellSnapshot, WindowSnapshot}; use crate::{FereseShell, Message, color, motion}; // Launch order stays stable as focus changes. Each open window gets its own // entry, including multiple windows belonging to the same application. fn windows(snapshot: &ShellSnapshot) -> Vec<&WindowSnapshot> { let mut windows: Vec<_> = snapshot.windows.iter().collect(); windows.sort_by_key(|window| window.id); windows } pub(super) fn caption(window: &WindowSnapshot) -> &str { if !window.title.trim().is_empty() { &window.title } else if !window.app_id.is_empty() { &window.app_id } else { ferese_config::tr!("Window") } } impl FereseShell { pub(super) fn view_window_tray( &self, bar: window::Id, instance: &str, allocated: Option, height: f32, ) -> Element<'_, cosmic::Action> { let windows = windows(&self.snapshot); if windows.is_empty() { return container(row([])).width(0).into(); } let palette = self.config.theme.for_bar(); let foreground = color(palette.text_primary); let mut entries = row([]).spacing(2); let natural_width = windows.len() as f32 * 32. - 2.; for window in windows { let foreground = if window.urgent { color(palette.accent) } else { foreground }; let entry = button::custom( container(icon(self.workspace_ui.app_icon(&window.app_id)).size(18)) .center_x(Length::Fill) .center_y(Length::Fill), ) .name(caption(window)) .width(30) .height(height) .padding(4) .on_press(cosmic::Action::App(Message::ActivateWindow(window.id))); let entry = motion::button( self.render.with_radius_limit(5.), entry, foreground, window.focused, 1., ); let target = cosmic::iced::advanced::widget::Id::new(format!( "window-tray:{bar:?}:{instance}:{}", window.id )); let entry = mouse_area(container(entry).id(target.clone())) .on_enter(cosmic::Action::App(Message::HoverWindow( bar, window.id, target.clone(), true, ))) .on_exit(cosmic::Action::App(Message::HoverWindow( bar, window.id, target.clone(), false, ))); let entry = if self.workspace_ui.hovering(bar, &target) { entry } else { entry.on_move(move |_| { cosmic::Action::App(Message::HoverWindow(bar, window.id, target.clone(), true)) }) }; entries = entries.push(entry); } cosmic::iced::widget::scrollable(entries) .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( cosmic::iced::widget::scrollable::Scrollbar::default() .width(0) .scroller_width(0), )) .width(allocated.unwrap_or(natural_width.min(360.)).max(0.)) .height(height) .into() } } #[cfg(test)] mod tests { use super::*; fn window(id: u64, title: &str) -> WindowSnapshot { WindowSnapshot { id, title: title.into(), app_id: "test.app".into(), workspace: 1, geometry: None, focused: false, urgent: false, fullscreen: false, maximized: false, floating: false, } } #[test] fn each_icon_activates_its_own_window_at_the_drawn_bounds() { use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, widget}; use cosmic::iced::{Event, Rectangle, Size, mouse}; let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = crate::tests::shell_with_measured_panel(); app.snapshot.windows = vec![window(1, "One"), window(2, "Two"), window(3, "Three")]; let bar = app.outputs[0].bar; struct Bounds(Vec<(widget::Id, Option)>); impl widget::Operation for Bounds { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { if let Some((_, found)) = self.0.iter_mut().find(|(target, _)| Some(&*target) == id) { *found = Some(bounds); } } } let mut bounds = Bounds( (1..=3) .map(|id| { ( widget::Id::new(format!("window-tray:{bar:?}:test:{id}")), None, ) }) .collect(), ); let mut view = app.view_window_tray(bar, "test", None, 28.); let mut tree = widget::Tree::new(&view); let node = view .as_widget_mut() .layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(360., 36.)), ) .move_to((300., 0.)); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut bounds); for (index, (_, bounds)) in bounds.0.iter().enumerate() { let point = bounds.expect("icon bounds").center(); let mut messages = Vec::new(); for event in [ mouse::Event::CursorMoved { position: point }, mouse::Event::ButtonPressed(mouse::Button::Left), mouse::Event::ButtonReleased(mouse::Button::Left), ] { view.as_widget_mut().update( &mut tree, &Event::Mouse(event), Layout::new(&node), mouse::Cursor::Available(point), &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &node.bounds(), ); } let activated: Vec<_> = messages .iter() .filter_map(|m| match m { cosmic::Action::App(Message::ActivateWindow(id)) => Some(*id), _ => None, }) .collect(); assert_eq!(activated, [index as u64 + 1], "icon at {point:?}"); } } #[test] fn focus_changes_preserve_order_and_closed_windows_disappear() { let mut snapshot = ShellSnapshot::default(); snapshot.windows = vec![window(3, "Third"), window(1, "First"), window(2, "Second")]; snapshot.windows[0].focused = true; assert_eq!( windows(&snapshot).iter().map(|w| w.id).collect::>(), [1, 2, 3] ); snapshot.windows[0].focused = false; snapshot.windows[1].focused = true; snapshot.windows.retain(|w| w.id != 2); assert_eq!( windows(&snapshot).iter().map(|w| w.id).collect::>(), [1, 3] ); assert_eq!(caption(windows(&snapshot)[0]), "First"); snapshot.windows.clear(); assert!(windows(&snapshot).is_empty()); } #[test] fn empty_titles_fall_back_to_app_identity() { assert_eq!(caption(&window(1, " ")), "test.app"); } }