Files
feresaria/shell/ferese-shell/src/window_tray.rs
T

226 lines
7.9 KiB
Rust

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<f32>,
height: f32,
) -> Element<'_, cosmic::Action<Message>> {
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<Rectangle>)>);
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::<Vec<_>>(),
[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::<Vec<_>>(),
[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");
}
}