Add editable panels and refine shell settings (#18)
* Integrate panel composition, adaptive sizing, and overflow * Add instance editing controls to panel settings * Add panel group editing and measured composition preview * Reorganize panel settings and add drag-and-drop item editing * Simplify panel settings and place inspectors beside the editor * Refine Panel & Shell settings, previews, and panel rendering * Organize settings navigation and modernize packaged panel config * Support top and bottom panels and refine shell surfaces Add edge-aware settings, previews, popup placement, notifications, margins, and corner rendering. Preserve translucency in notification and dialog controls, and tighten spacing around the clock and display-mode icon. * Add workspace styles and fix Settings text rendering * Make panel allocation independent of measurement order * Normalize panel item settings and visibility * Move shell geometry into each panel * Add independent group surfaces and finalize panel configuration * Keep panel fit checks and restoration consistent with allocation * Reject unknown panel item options and namespace metadata * Reject invalid shell startup configuration and remove old radius fields * Discover workspace application icons in a background task * Remove the panel architecture document and improve reference prose * Let panel media fill available space and improve Settings scrolling * Preserve panel labels across clipped GPU rendering * Use matching alpine lake wallpapers for light and dark modes * Fix panel accessibility and popup lifecycle Apply Reduce Transparency to panel opacity in the shell, compositor, and Settings preview. Reject unknown theme tokens before snapshot substitution and validate the current geometry fields in both consumers. Dismiss popovers and workspace tooltips after panel layout changes. Handle bar pointer exits directly and recover workspace hover after reentry without repeated motion updates. Cover the transitions with regression tests and update the wallpaper dimensions check for the bundled lake defaults. * Use tagged Ferese toolkit fixes * Fix center-only panel spacing and headless text rendering * Bundle native 4K abstract wallpapers for both appearances * Dismiss workspace tooltips before captured pointer dispatch * Verify installed JPEG wallpapers when replacing a release
This commit is contained in:
1 parent
d4c8c10b39
commit
65fcaef13f
84 files changed
+12809
-1258
No files matched your search
@@ -17,7 +17,8 @@ libc.workspace = true
|
||||
# Keep GPU rendering available as a fallback/override. The shell defaults to
|
||||
# tiny-skia to avoid a second GPU initialization during desktop startup.
|
||||
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
|
||||
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.0" }
|
||||
freedesktop-desktop-entry = { version = "0.8.3", default-features = false }
|
||||
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
+1010
-313
File diff suppressed because it is too large.
Load diff
@@ -1,24 +1,33 @@
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
||||
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
||||
use cosmic::{Element, Theme};
|
||||
use ferese_config::BarLayout;
|
||||
use ferese_config::panel::PanelSurface;
|
||||
|
||||
pub(crate) fn island_id() -> widget::Id {
|
||||
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
|
||||
ID.get_or_init(widget::Id::unique).clone()
|
||||
}
|
||||
|
||||
pub(crate) fn input_id() -> widget::Id {
|
||||
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
|
||||
ID.get_or_init(widget::Id::unique).clone()
|
||||
}
|
||||
|
||||
pub(super) fn frame<'a, M: 'a>(
|
||||
content: Element<'a, M>,
|
||||
mode: BarLayout,
|
||||
mode: PanelSurface,
|
||||
radius: f32,
|
||||
present: impl Fn(&[[f32; 5]]) + 'a,
|
||||
materials: Vec<(widget::Id, f32)>,
|
||||
hover: Option<HoverTarget<'a, M>>,
|
||||
present: impl Fn(&[[f32; 5]], &[f32]) + 'a,
|
||||
changed: impl Fn(Vec<[f32; 5]>) -> M + 'a,
|
||||
) -> Element<'a, M> {
|
||||
Element::new(Presentation {
|
||||
content,
|
||||
mode,
|
||||
radius,
|
||||
materials,
|
||||
hover,
|
||||
present: Box::new(present),
|
||||
changed: Box::new(changed),
|
||||
})
|
||||
@@ -26,18 +35,45 @@ pub(super) fn frame<'a, M: 'a>(
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
mode: Option<BarLayout>,
|
||||
mode: Option<PanelSurface>,
|
||||
current: Vec<[f32; 5]>,
|
||||
scratch: Vec<[f32; 5]>,
|
||||
scroll_starts: Vec<usize>,
|
||||
input: Vec<[f32; 5]>,
|
||||
input_scratch: Vec<[f32; 5]>,
|
||||
scroll_starts: Vec<(usize, usize)>,
|
||||
opacity_scratch: Vec<f32>,
|
||||
}
|
||||
|
||||
type Present<'a> = Box<dyn Fn(&[[f32; 5]]) + 'a>;
|
||||
pub(super) struct HoverTarget<'a, M> {
|
||||
pub(super) id: widget::Id,
|
||||
pub(super) exited: Box<dyn Fn() -> M + 'a>,
|
||||
}
|
||||
|
||||
struct HoverBounds<'a> {
|
||||
target: &'a widget::Id,
|
||||
bounds: Option<Rectangle>,
|
||||
}
|
||||
|
||||
impl widget::Operation for HoverBounds<'_> {
|
||||
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
children(self);
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(self.target) {
|
||||
self.bounds = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Present<'a> = Box<dyn Fn(&[[f32; 5]], &[f32]) + 'a>;
|
||||
|
||||
struct Presentation<'a, M> {
|
||||
content: Element<'a, M>,
|
||||
mode: BarLayout,
|
||||
mode: PanelSurface,
|
||||
radius: f32,
|
||||
materials: Vec<(widget::Id, f32)>,
|
||||
hover: Option<HoverTarget<'a, M>>,
|
||||
present: Present<'a>,
|
||||
changed: Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>,
|
||||
}
|
||||
@@ -94,6 +130,32 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||
shell: &mut Shell<'_, M>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
// The stack may stop dispatching after another control captures input.
|
||||
// Reconcile the active tooltip before any child handles the pointer.
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
|
||||
) && let Some(hover) = &self.hover
|
||||
{
|
||||
let mut target = HoverBounds {
|
||||
target: &hover.id,
|
||||
bounds: None,
|
||||
};
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, &mut target);
|
||||
if matches!(event, Event::Mouse(mouse::Event::CursorLeft))
|
||||
|| !cursor.is_over(*viewport)
|
||||
|| !target.bounds.is_some_and(|bounds| cursor.is_over(bounds))
|
||||
{
|
||||
shell.publish((hover.exited)());
|
||||
}
|
||||
}
|
||||
let cursor = if matches!(event, Event::Mouse(mouse::Event::CursorLeft)) {
|
||||
mouse::Cursor::Unavailable
|
||||
} else {
|
||||
cursor
|
||||
};
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
@@ -111,12 +173,17 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let mut collector = Collect {
|
||||
regions: std::mem::take(&mut state.scratch),
|
||||
input: std::mem::take(&mut state.input_scratch),
|
||||
scroll_starts: std::mem::take(&mut state.scroll_starts),
|
||||
radius: self.radius,
|
||||
materials: &self.materials,
|
||||
opacities: std::mem::take(&mut state.opacity_scratch),
|
||||
};
|
||||
collector.regions.clear();
|
||||
collector.opacities.clear();
|
||||
collector.input.clear();
|
||||
collector.scroll_starts.clear();
|
||||
if self.mode == BarLayout::Islands {
|
||||
if self.mode == PanelSurface::None {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, &mut collector);
|
||||
@@ -125,18 +192,27 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||
collector
|
||||
.regions
|
||||
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||
collector.opacities.push(1.);
|
||||
}
|
||||
|
||||
collector.clip_from(0, layout.bounds(), Vector::ZERO);
|
||||
collector.regions.retain(|region| region[2] > 0.0 && region[3] > 0.0);
|
||||
(self.present)(&collector.regions);
|
||||
if state.mode != Some(self.mode) || state.current != collector.regions {
|
||||
collector.clip_input_from(0, layout.bounds(), Vector::ZERO);
|
||||
collector.discard_empty();
|
||||
(self.present)(&collector.regions, &collector.opacities);
|
||||
if state.mode != Some(self.mode) || state.current != collector.regions || state.input != collector.input {
|
||||
state.mode = Some(self.mode);
|
||||
state.current.clone_from(&collector.regions);
|
||||
shell.publish((self.changed)(collector.regions.clone()));
|
||||
state.input.clone_from(&collector.input);
|
||||
shell.publish((self.changed)(if self.mode == PanelSurface::Solid {
|
||||
collector.regions.clone()
|
||||
} else {
|
||||
collector.input.clone()
|
||||
}));
|
||||
}
|
||||
|
||||
state.opacity_scratch = collector.opacities;
|
||||
state.scratch = collector.regions;
|
||||
state.input_scratch = collector.input;
|
||||
state.scroll_starts = collector.scroll_starts;
|
||||
}
|
||||
|
||||
@@ -194,13 +270,34 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||
}
|
||||
}
|
||||
|
||||
struct Collect {
|
||||
#[derive(Default)]
|
||||
struct Collect<'a> {
|
||||
regions: Vec<[f32; 5]>,
|
||||
scroll_starts: Vec<usize>,
|
||||
input: Vec<[f32; 5]>,
|
||||
scroll_starts: Vec<(usize, usize)>,
|
||||
radius: f32,
|
||||
materials: &'a [(widget::Id, f32)],
|
||||
opacities: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Collect {
|
||||
impl Collect<'_> {
|
||||
fn discard_empty(&mut self) {
|
||||
let mut index = 0;
|
||||
self.opacities.retain(|_| {
|
||||
let keep = self.regions[index][2] > 0. && self.regions[index][3] > 0.;
|
||||
index += 1;
|
||||
keep
|
||||
});
|
||||
self.regions.retain(|region| region[2] > 0. && region[3] > 0.);
|
||||
self.input.retain(|region| region[2] > 0. && region[3] > 0.);
|
||||
}
|
||||
|
||||
fn clip_input_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) {
|
||||
std::mem::swap(&mut self.regions, &mut self.input);
|
||||
self.clip_from(start, viewport, translation);
|
||||
std::mem::swap(&mut self.regions, &mut self.input);
|
||||
}
|
||||
|
||||
fn clip_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) {
|
||||
for region in &mut self.regions[start..] {
|
||||
let bounds = Rectangle {
|
||||
@@ -215,22 +312,32 @@ impl Collect {
|
||||
}
|
||||
}
|
||||
|
||||
impl widget::Operation for Collect {
|
||||
impl widget::Operation for Collect<'_> {
|
||||
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
children(self);
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&island_id()) {
|
||||
let opacity = self
|
||||
.materials
|
||||
.iter()
|
||||
.find(|(target, _)| Some(target) == id)
|
||||
.map(|(_, opacity)| *opacity);
|
||||
if opacity.is_some() || id == Some(&island_id()) || id == Some(&input_id()) {
|
||||
self.input
|
||||
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||
}
|
||||
if opacity.is_some() || id == Some(&island_id()) {
|
||||
self.regions
|
||||
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||
self.opacities.push(opacity.unwrap_or(1.));
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_operation(&mut self, _id: Option<&widget::Id>) {
|
||||
// Iced visits scrollable content before its scrollable callback. Record
|
||||
// the range so only that subtree receives its offset and viewport clip.
|
||||
self.scroll_starts.push(self.regions.len());
|
||||
self.scroll_starts.push((self.regions.len(), self.input.len()));
|
||||
}
|
||||
|
||||
fn scrollable(
|
||||
@@ -241,8 +348,9 @@ impl widget::Operation for Collect {
|
||||
translation: Vector,
|
||||
_state: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if let Some(start) = self.scroll_starts.pop() {
|
||||
if let Some((start, input_start)) = self.scroll_starts.pop() {
|
||||
self.clip_from(start, bounds, translation);
|
||||
self.clip_input_from(input_start, bounds, translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,6 +362,82 @@ mod tests {
|
||||
use cosmic::iced::{Font, Pixels, widget as widgets};
|
||||
use cosmic::widget::{container, row};
|
||||
|
||||
#[test]
|
||||
fn clipping_preserves_group_opacity_and_transparent_groups_keep_input() {
|
||||
use widget::Operation;
|
||||
let ids = [widget::Id::unique(), widget::Id::unique(), widget::Id::unique()];
|
||||
let materials = [(ids[0].clone(), 0.2), (ids[1].clone(), 0.), (ids[2].clone(), 0.7)];
|
||||
let mut collector = Collect {
|
||||
materials: &materials,
|
||||
radius: 12.,
|
||||
..Default::default()
|
||||
};
|
||||
for (id, x) in ids.iter().zip([-80., 20., 80.]) {
|
||||
collector.container(Some(id), Rectangle::new((x, 0.).into(), (30., 28.).into()));
|
||||
}
|
||||
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
|
||||
collector.clip_from(0, viewport, Vector::ZERO);
|
||||
collector.clip_input_from(0, viewport, Vector::ZERO);
|
||||
collector.discard_empty();
|
||||
assert_eq!(collector.regions, [[20., 0., 30., 28., 12.], [80., 0., 20., 28., 12.]]);
|
||||
assert_eq!(collector.opacities, [0., 0.7]);
|
||||
assert_eq!(
|
||||
collector.input, collector.regions,
|
||||
"background opacity does not disable controls"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_only_changes_publish_material_without_rebuilding_input() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
|
||||
let id = widget::Id::unique();
|
||||
let mut tree = None;
|
||||
for (index, opacity) in [1., 0.5, 0.].into_iter().enumerate() {
|
||||
let published = std::cell::RefCell::new(Vec::new());
|
||||
let content = container(widgets::Space::new().width(60).height(28))
|
||||
.id(id.clone())
|
||||
.into();
|
||||
let mut view = frame(
|
||||
content,
|
||||
PanelSurface::None,
|
||||
12.,
|
||||
vec![(id.clone(), opacity)],
|
||||
None,
|
||||
|regions, opacities| published.borrow_mut().push((regions.to_vec(), opacities.to_vec())),
|
||||
|regions| regions,
|
||||
);
|
||||
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
||||
tree.diff(&mut view);
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let mut messages = Vec::new();
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
assert_eq!(*published.borrow(), [(vec![[0., 0., 60., 28., 12.]], vec![opacity])]);
|
||||
assert_eq!(messages.len(), usize::from(index == 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_islands_and_live_layout_switches_publish_only_changed_bounds() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -270,18 +454,16 @@ mod tests {
|
||||
let viewport = Rectangle::new((0., 0.).into(), (600., 28.).into());
|
||||
let mut tree = None;
|
||||
let theme = crate::ShellTheme {
|
||||
bar_height: 28.,
|
||||
panel_padding: 12.,
|
||||
bar_radius: 14.,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (mode, padding) in [
|
||||
(BarLayout::Islands, 12.),
|
||||
(BarLayout::Islands, 4.),
|
||||
(BarLayout::Islands, 0.),
|
||||
(BarLayout::Continuous, 32.),
|
||||
(BarLayout::Islands, 12.),
|
||||
(PanelSurface::None, 12.),
|
||||
(PanelSurface::None, 4.),
|
||||
(PanelSurface::None, 0.),
|
||||
(PanelSurface::Solid, 32.),
|
||||
(PanelSurface::None, 12.),
|
||||
] {
|
||||
let section = |width| {
|
||||
let inner = container(widgets::Space::new().width(width).height(24))
|
||||
@@ -289,7 +471,15 @@ mod tests {
|
||||
crate::bar::bar_group_style(theme)
|
||||
}))
|
||||
.into();
|
||||
crate::bar::island(inner, theme, padding, mode == BarLayout::Islands, true)
|
||||
crate::bar::island(
|
||||
inner,
|
||||
theme,
|
||||
crate::panel::PanelGeometry::default(),
|
||||
padding,
|
||||
mode == PanelSurface::None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let islands = row![section(100), section(200),].spacing(60);
|
||||
let content = container(islands)
|
||||
@@ -302,7 +492,9 @@ mod tests {
|
||||
content,
|
||||
mode,
|
||||
14.,
|
||||
|_| {},
|
||||
vec![],
|
||||
None,
|
||||
|_, _| {},
|
||||
|regions| {
|
||||
cosmic::Action::App(crate::Message::BarRegionsChanged(
|
||||
cosmic::iced::window::Id::unique(),
|
||||
@@ -331,7 +523,7 @@ mod tests {
|
||||
}
|
||||
|
||||
assert_eq!(messages.len(), 1, "unchanged frames must not rebuild input regions");
|
||||
let expected = if mode == BarLayout::Islands {
|
||||
let expected = if mode == PanelSurface::None {
|
||||
vec![
|
||||
[12., 0., 100. + 2. * padding, 28., 14.],
|
||||
[172. + 2. * padding, 0., 200. + 2. * padding, 28., 14.],
|
||||
@@ -350,8 +542,10 @@ mod tests {
|
||||
fn scrolling_clips_only_its_own_islands_and_preserves_fractional_positions() {
|
||||
let mut collector = Collect {
|
||||
regions: vec![[400., 2., 80., 24., 14.], [10.25, 2., 180., 24., 14.]],
|
||||
input: Vec::new(),
|
||||
scroll_starts: Vec::new(),
|
||||
radius: 14.,
|
||||
..Default::default()
|
||||
};
|
||||
collector.clip_from(
|
||||
1,
|
||||
@@ -366,4 +560,66 @@ mod tests {
|
||||
collector.regions.retain(|region| region[2] > 0. && region[3] > 0.);
|
||||
assert_eq!(collector.regions, vec![[20., 2., 100., 24., 14.]]);
|
||||
}
|
||||
#[test]
|
||||
fn undecorated_controls_have_input_regions_without_creating_material_or_gap_regions() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let content = row![
|
||||
container(widgets::Space::new().width(30).height(24)).id(input_id()),
|
||||
container(widgets::Space::new().width(40).height(24)).id(input_id()),
|
||||
]
|
||||
.spacing(20)
|
||||
.into();
|
||||
let materials = std::cell::RefCell::new(Vec::new());
|
||||
let mut view = frame(
|
||||
content,
|
||||
PanelSurface::None,
|
||||
14.,
|
||||
vec![],
|
||||
None,
|
||||
|regions, _| *materials.borrow_mut() = regions.to_vec(),
|
||||
|regions| regions,
|
||||
);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let mut messages = Vec::new();
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
assert!(materials.borrow().is_empty());
|
||||
assert_eq!(messages, vec![vec![[0., 0., 30., 24., 14.], [50., 0., 40., 24., 14.]]]);
|
||||
let mut collector = Collect {
|
||||
regions: vec![[300., 0., 20., 24., 14.]],
|
||||
input: messages.remove(0),
|
||||
scroll_starts: Vec::new(),
|
||||
radius: 14.,
|
||||
..Default::default()
|
||||
};
|
||||
collector.clip_input_from(
|
||||
1,
|
||||
Rectangle::new((40., 0.).into(), (20., 28.).into()),
|
||||
Vector::new(10., 0.),
|
||||
);
|
||||
assert_eq!(collector.input, vec![[0., 0., 30., 24., 14.], [40., 0., 20., 24., 14.]]);
|
||||
assert_eq!(collector.regions, vec![[300., 0., 20., 24., 14.]]);
|
||||
}
|
||||
}
|
||||
+224
-149
@@ -5,7 +5,7 @@ use serde::Deserialize;
|
||||
|
||||
const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ShellConfig {
|
||||
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
|
||||
pub(crate) desktop_widgets: ferese_config::desktop::DesktopWidgets,
|
||||
@@ -15,17 +15,30 @@ pub(crate) struct ShellConfig {
|
||||
pub(crate) theme: ShellTheme,
|
||||
pub(crate) theme_mode: ferese_config::theme::Mode,
|
||||
pub(crate) status: StatusConfig,
|
||||
pub(crate) panels: Vec<crate::panel::Panel>,
|
||||
}
|
||||
|
||||
impl Default for ShellConfig {
|
||||
fn default() -> Self {
|
||||
let status = StatusConfig::default();
|
||||
Self {
|
||||
notifications: Default::default(),
|
||||
desktop_widgets: Default::default(),
|
||||
animations: Default::default(),
|
||||
font_family: None,
|
||||
wallpaper: Default::default(),
|
||||
theme: Default::default(),
|
||||
theme_mode: Default::default(),
|
||||
panels: vec![crate::panel::Panel::default()],
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct StatusConfig {
|
||||
pub(crate) bar_layout: ferese_config::BarLayout,
|
||||
#[serde(deserialize_with = "ferese_config::deserialize_bar_island_padding")]
|
||||
pub(crate) bar_island_padding: f32,
|
||||
pub(crate) keybinding_guide: bool,
|
||||
pub(crate) battery_percentage: bool,
|
||||
pub(crate) window_title: bool,
|
||||
pub(crate) low_battery_threshold: u8,
|
||||
pub(crate) settings_command: Option<Vec<String>>,
|
||||
}
|
||||
@@ -33,11 +46,7 @@ pub(crate) struct StatusConfig {
|
||||
impl Default for StatusConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bar_layout: ferese_config::BarLayout::Continuous,
|
||||
bar_island_padding: ferese_config::default_bar_island_padding(),
|
||||
keybinding_guide: true,
|
||||
battery_percentage: true,
|
||||
window_title: true,
|
||||
low_battery_threshold: 20,
|
||||
settings_command: Some(vec!["ferese-settings".into()]),
|
||||
}
|
||||
@@ -48,6 +57,7 @@ impl Default for StatusConfig {
|
||||
pub(crate) struct ShellTheme {
|
||||
pub(crate) appearance: ferese_config::theme::Appearance,
|
||||
pub(crate) high_contrast: bool,
|
||||
pub(crate) reduce_transparency: bool,
|
||||
pub(crate) bar_background: [u8; 4],
|
||||
pub(crate) bar_text_primary: [u8; 4],
|
||||
pub(crate) bar_text_muted: [u8; 4],
|
||||
@@ -60,13 +70,8 @@ pub(crate) struct ShellTheme {
|
||||
pub(crate) on_accent: [u8; 4],
|
||||
pub(crate) border: [u8; 4],
|
||||
pub(crate) shadow: [u8; 4],
|
||||
pub(crate) bar_height: f32,
|
||||
pub(crate) bar_margin_top: i32,
|
||||
pub(crate) bar_window_gap: i32,
|
||||
pub(crate) bar_margin_horizontal: i32,
|
||||
pub(crate) bar_radius: f32,
|
||||
pub(crate) material_radius: f32,
|
||||
pub(crate) panel_padding: f32,
|
||||
pub(crate) control_gap: f32,
|
||||
pub(crate) shadow_offset_y: f32,
|
||||
pub(crate) shadow_blur: f32,
|
||||
@@ -78,6 +83,7 @@ impl Default for ShellTheme {
|
||||
Self {
|
||||
appearance: Default::default(),
|
||||
high_contrast: false,
|
||||
reduce_transparency: false,
|
||||
bar_background: [28, 32, 46, 255],
|
||||
bar_text_primary: [240, 243, 250, 255],
|
||||
bar_text_muted: [170, 180, 199, 255],
|
||||
@@ -90,13 +96,8 @@ impl Default for ShellTheme {
|
||||
on_accent: [244, 247, 251, 255],
|
||||
border: [255, 255, 255, 24],
|
||||
shadow: [0, 0, 0, 85],
|
||||
bar_height: 28.0,
|
||||
bar_margin_top: 0,
|
||||
bar_window_gap: 0,
|
||||
bar_margin_horizontal: 0,
|
||||
bar_radius: 14.0,
|
||||
material_radius: 14.0,
|
||||
panel_padding: 12.0,
|
||||
control_gap: 12.0,
|
||||
shadow_offset_y: 4.0,
|
||||
shadow_blur: 18.0,
|
||||
@@ -106,6 +107,22 @@ impl Default for ShellTheme {
|
||||
}
|
||||
|
||||
impl ShellTheme {
|
||||
pub(crate) fn panel_opacity(self, custom: Option<f32>) -> f32 {
|
||||
ferese_config::theme::effective_panel_opacity(
|
||||
f32::from(self.bar_background[3]) / 255.,
|
||||
custom,
|
||||
self.reduce_transparency,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn material_opacity(self) -> f32 {
|
||||
if self.high_contrast {
|
||||
1.0
|
||||
} else {
|
||||
f32::from(self.surface_popover[3]) / 255.0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn palette(self) -> ferese_theme::Palette {
|
||||
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
|
||||
let surface = color(self.surface_base);
|
||||
@@ -164,6 +181,8 @@ pub(crate) enum WallpaperMode {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct FereseConfig {
|
||||
#[serde(default)]
|
||||
panels: Option<Vec<crate::panel::Panel>>,
|
||||
#[serde(default)]
|
||||
notifications: ferese_config::notifications::NotificationConfig,
|
||||
#[serde(default)]
|
||||
@@ -171,18 +190,11 @@ struct FereseConfig {
|
||||
#[serde(default)]
|
||||
animations: crate::motion::Settings,
|
||||
#[serde(default)]
|
||||
appearance: AppearanceConfig,
|
||||
#[serde(default)]
|
||||
theme: ThemeConfig,
|
||||
#[serde(default)]
|
||||
status: StatusConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct AppearanceConfig {
|
||||
corner_radius: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
@@ -270,21 +282,16 @@ impl Default for ThemeColorsConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ThemeGeometryConfig {
|
||||
#[serde(default = "default_bar_height")]
|
||||
top_bar_height: f32,
|
||||
#[serde(default = "default_bar_margin_top")]
|
||||
top_bar_margin_top: i32,
|
||||
#[serde(default = "default_bar_window_gap")]
|
||||
top_bar_window_gap: i32,
|
||||
#[serde(default = "default_bar_margin_horizontal")]
|
||||
top_bar_margin_horizontal: i32,
|
||||
#[serde(default, rename = "border_width")]
|
||||
_border_width: Option<f64>,
|
||||
#[serde(default, rename = "focus_ring_width")]
|
||||
_focus_ring_width: Option<f64>,
|
||||
#[serde(default, rename = "window_radius")]
|
||||
_window_radius: Option<f64>,
|
||||
#[serde(default)]
|
||||
shell_radius: Option<f32>,
|
||||
#[serde(default)]
|
||||
top_bar_radius: Option<f32>,
|
||||
#[serde(default = "default_panel_padding")]
|
||||
panel_padding: f32,
|
||||
#[serde(default = "default_control_gap")]
|
||||
control_gap: f32,
|
||||
}
|
||||
@@ -292,13 +299,10 @@ struct ThemeGeometryConfig {
|
||||
impl Default for ThemeGeometryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
top_bar_height: default_bar_height(),
|
||||
top_bar_margin_top: default_bar_margin_top(),
|
||||
top_bar_window_gap: default_bar_window_gap(),
|
||||
top_bar_margin_horizontal: default_bar_margin_horizontal(),
|
||||
_border_width: None,
|
||||
_focus_ring_width: None,
|
||||
_window_radius: None,
|
||||
shell_radius: None,
|
||||
top_bar_radius: None,
|
||||
panel_padding: default_panel_padding(),
|
||||
control_gap: default_control_gap(),
|
||||
}
|
||||
}
|
||||
@@ -330,31 +334,33 @@ impl Default for SoftShadowConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load() -> ShellConfig {
|
||||
pub(crate) fn load() -> Result<ShellConfig, String> {
|
||||
let Some(path) = config_path() else {
|
||||
return ShellConfig::default();
|
||||
};
|
||||
let Ok(source) = fs::read_to_string(&path) else {
|
||||
return ShellConfig::default();
|
||||
return Ok(ShellConfig::default());
|
||||
};
|
||||
load_path(&path)
|
||||
}
|
||||
|
||||
match parse_source(&source) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
|
||||
ShellConfig::default()
|
||||
}
|
||||
fn load_path(path: &std::path::Path) -> Result<ShellConfig, String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(ShellConfig::default()),
|
||||
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
|
||||
}
|
||||
let source = fs::read_to_string(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
|
||||
parse_source(&source).map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
ferese_config::theme::validate_overrides(&document).map_err(ferese_config::Error::from)?;
|
||||
let snapshot = ferese_theme_client::service::current();
|
||||
let mut config = parse_document(
|
||||
&document.with_theme(&snapshot.presented),
|
||||
snapshot.presented.appearance,
|
||||
snapshot.mode,
|
||||
)?;
|
||||
|
||||
config.apply_theme(&snapshot.presented);
|
||||
Ok(config)
|
||||
}
|
||||
@@ -368,21 +374,16 @@ fn parse_document(
|
||||
.map_err(|error| ferese_config::Error::from(error.to_string()))
|
||||
{
|
||||
Ok(config) => {
|
||||
if let Some(panels) = &config.panels {
|
||||
ferese_config::panel::validate(panels).map_err(ferese_config::Error::from)?;
|
||||
}
|
||||
|
||||
config.animations.validate().map_err(ferese_config::Error::from)?;
|
||||
config.notifications.validate().map_err(ferese_config::Error::from)?;
|
||||
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
|
||||
let mut theme = shell_theme(&config.theme);
|
||||
theme.appearance = appearance;
|
||||
theme.material_radius = nonnegative_or(
|
||||
config
|
||||
.theme
|
||||
.geometry
|
||||
.shell_radius
|
||||
.or(config.appearance.corner_radius)
|
||||
.or(config.theme.geometry.top_bar_radius)
|
||||
.unwrap_or(14.0),
|
||||
14.0,
|
||||
);
|
||||
theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
|
||||
theme.bar_radius = theme.material_radius;
|
||||
|
||||
Ok(ShellConfig {
|
||||
@@ -393,6 +394,7 @@ fn parse_document(
|
||||
wallpaper: config.theme.background,
|
||||
theme,
|
||||
theme_mode: mode,
|
||||
panels: config.panels.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
|
||||
status: StatusConfig {
|
||||
low_battery_threshold: config.status.low_battery_threshold.min(100),
|
||||
..config.status
|
||||
@@ -409,11 +411,13 @@ impl ShellConfig {
|
||||
self.theme = shell_theme(&config);
|
||||
self.theme.appearance = theme.appearance;
|
||||
self.theme.high_contrast = theme.accessibility.increase_contrast;
|
||||
self.theme.reduce_transparency = theme.accessibility.reduce_transparency;
|
||||
self.theme.accent_gradient = ferese_theme::Palette::from_resolved(theme).accent_gradient;
|
||||
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
|
||||
self.theme.bar_radius = self.theme.material_radius;
|
||||
self.font_family = Some(theme.tokens.typography.font_family.clone());
|
||||
self.wallpaper = config.background;
|
||||
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
||||
self.wallpaper.path = None;
|
||||
}
|
||||
@@ -439,6 +443,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
ShellTheme {
|
||||
appearance: defaults.appearance,
|
||||
high_contrast: defaults.high_contrast,
|
||||
reduce_transparency: defaults.reduce_transparency,
|
||||
material_radius: defaults.material_radius,
|
||||
bar_background: {
|
||||
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
|
||||
@@ -460,19 +465,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
|
||||
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
|
||||
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
|
||||
bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height),
|
||||
bar_margin_top: theme.geometry.top_bar_margin_top.max(0),
|
||||
bar_window_gap: theme.geometry.top_bar_window_gap.max(0),
|
||||
bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0),
|
||||
bar_radius: nonnegative_or(
|
||||
theme
|
||||
.geometry
|
||||
.shell_radius
|
||||
.or(theme.geometry.top_bar_radius)
|
||||
.unwrap_or(14.0),
|
||||
defaults.bar_radius,
|
||||
),
|
||||
panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding),
|
||||
bar_radius: nonnegative_or(theme.geometry.shell_radius.unwrap_or(14.0), defaults.bar_radius),
|
||||
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
|
||||
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
|
||||
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
|
||||
@@ -498,14 +491,6 @@ pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
|
||||
Some([red, green, blue, alpha])
|
||||
}
|
||||
|
||||
fn positive_or(value: f32, fallback: f32) -> f32 {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
value
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn nonnegative_or(value: f32, fallback: f32) -> f32 {
|
||||
if value.is_finite() && value >= 0.0 {
|
||||
value
|
||||
@@ -542,26 +527,6 @@ fn default_shadow() -> String {
|
||||
"#00000055".to_owned()
|
||||
}
|
||||
|
||||
const fn default_bar_height() -> f32 {
|
||||
28.0
|
||||
}
|
||||
|
||||
const fn default_bar_window_gap() -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
const fn default_bar_margin_top() -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
const fn default_bar_margin_horizontal() -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
const fn default_panel_padding() -> f32 {
|
||||
12.0
|
||||
}
|
||||
|
||||
const fn default_control_gap() -> f32 {
|
||||
12.0
|
||||
}
|
||||
@@ -588,6 +553,67 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn removed_geometry_is_rejected_before_a_runtime_snapshot_can_hide_it() {
|
||||
for key in [
|
||||
"top-bar-radius",
|
||||
"top-bar-height",
|
||||
"top-bar-margin-top",
|
||||
"top-bar-margin-horizontal",
|
||||
"top-bar-window-clearance",
|
||||
"shell-raduis",
|
||||
] {
|
||||
let source = format!("theme {{ geometry {{ {key} 8; }}; }}");
|
||||
assert!(
|
||||
parse_test_source(&source)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown field")
|
||||
);
|
||||
for section in ["", "light", "dark"] {
|
||||
let layer = if section.is_empty() {
|
||||
format!("geometry {{ {key} 8; }}")
|
||||
} else {
|
||||
format!("{section} {{ geometry {{ {key} 8; }}; }}")
|
||||
};
|
||||
assert!(
|
||||
parse_source(&format!("theme {{ {layer}; }}"))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains(&key.replace('-', "_"))
|
||||
);
|
||||
}
|
||||
}
|
||||
parse_test_source("theme { geometry { border-width 1; focus-ring-width 2; window-radius 12; shell-radius 14; control-gap 8; }; }").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_transparency_overrides_panel_opacity_for_both_surfaces() {
|
||||
for surface in ["solid", "none"] {
|
||||
for opacity in [0., 0.4, 1.] {
|
||||
for reduce in [false, true] {
|
||||
let source = format!(
|
||||
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}"
|
||||
);
|
||||
let document = ferese_config::Document::parse(&source).unwrap();
|
||||
let resolved = ferese_config::theme::resolve(
|
||||
&document,
|
||||
std::path::Path::new("/tmp"),
|
||||
jiff::Timestamp::now(),
|
||||
|_| unreachable!(),
|
||||
)
|
||||
.unwrap()
|
||||
.theme;
|
||||
let mut config = parse_test_source(&source).unwrap();
|
||||
config.apply_theme(&resolved);
|
||||
let effective = config.theme.panel_opacity(config.panels[0].background_opacity);
|
||||
assert_eq!(effective, if reduce { 1. } else { opacity });
|
||||
assert_eq!(effective, resolved.panel_opacity(config.panels[0].background_opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_removed_physics_keys_before_publication() {
|
||||
for property in ["spring", "viewport-spring"] {
|
||||
@@ -640,43 +666,66 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn island_layout_is_opt_in_and_does_not_change_modal_opacity() {
|
||||
fn config_loading_uses_only_authored_panel_settings() {
|
||||
use crate::panel::{GroupSurface, ItemKind, PanelSurface};
|
||||
let defaults = parse_test_source("").unwrap();
|
||||
assert_eq!(ShellConfig::default().panels, defaults.panels);
|
||||
let source = "panel main { surface none; group-surface island; geometry { height 36; edge-margin 6; }; center { group title { surface none; item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }";
|
||||
let current = parse_test_source(source).unwrap();
|
||||
let panel = ¤t.panels[0];
|
||||
assert_eq!(panel.surface, PanelSurface::None);
|
||||
assert_eq!(panel.group_surface, GroupSurface::Island);
|
||||
assert_eq!(panel.geometry.height, 36.);
|
||||
assert_eq!(panel.geometry.edge_margin, 6);
|
||||
assert_eq!(panel.end.groups[0].island_padding, 9.5);
|
||||
assert!(!panel.center.groups[0].items[0].visible);
|
||||
assert_eq!(
|
||||
parse_test_source("").unwrap().status.bar_layout,
|
||||
ferese_config::BarLayout::Continuous
|
||||
panel.end.groups[0].items[0].kind,
|
||||
ItemKind::Battery { percentage: false }
|
||||
);
|
||||
let normal = parse_test_source("theme { material { style \"translucent\"; opacity 0.7; }; }").unwrap();
|
||||
let islands = parse_test_source(
|
||||
"status { bar-layout \"islands\"; }\ntheme { material { style \"translucent\"; opacity 0.7; }; }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(islands.status.bar_layout, ferese_config::BarLayout::Islands);
|
||||
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
|
||||
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
|
||||
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
|
||||
assert!(parse_test_source("status { bar-layout \"invalid\"; }").is_err());
|
||||
assert_eq!(parse_test_source("").unwrap().panels, defaults.panels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn island_padding_accepts_compact_and_fractional_values_without_changing_panel_padding() {
|
||||
let defaults = parse_test_source("").unwrap();
|
||||
assert_eq!(defaults.status.bar_island_padding, 4.);
|
||||
assert_eq!(
|
||||
parse_test_source("status { bar-layout \"islands\"; }")
|
||||
.unwrap()
|
||||
.status
|
||||
.bar_island_padding,
|
||||
4.
|
||||
);
|
||||
for padding in [0., 4., 4.5, 32.] {
|
||||
let source = format!("status {{ bar-island-padding {padding}; }}");
|
||||
let config = parse_test_source(&source).unwrap();
|
||||
assert_eq!(config.status.bar_island_padding, padding);
|
||||
assert_eq!(config.theme.panel_padding, defaults.theme.panel_padding);
|
||||
}
|
||||
fn authored_composition_rejects_invalid_reload_data() {
|
||||
let source = r#"panel "custom" { surface "none"; group-surface "island"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#;
|
||||
let config = parse_test_source(source).unwrap();
|
||||
assert_eq!(config.panels[0].id.0, "custom");
|
||||
assert_eq!(config.panels[0].surface, crate::panel::PanelSurface::None);
|
||||
assert_eq!(config.panels[0].end.groups[0].items.len(), 2);
|
||||
assert!(parse_test_source(&source.replace("item \"two\"", "item \"one\"")).is_err());
|
||||
assert!(parse_test_source("panel one; panel two;").is_err());
|
||||
assert!(parse_test_source("panel one { edge left; }").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_surfaces_do_not_change_modal_opacity() {
|
||||
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
|
||||
let islands = parse_test_source("panel main { surface none; group-surface island; }\ntheme { material { style translucent; opacity 0.7; }; }").unwrap();
|
||||
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
|
||||
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
|
||||
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
|
||||
assert!(parse_test_source("panel main { surface islands; }").is_err());
|
||||
assert!(parse_test_source("status { bar-layout islands; }").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn island_padding_accepts_fractional_values_independently_of_panel_geometry() {
|
||||
for padding in [0., 4., 4.5, 32.] {
|
||||
let source = format!(
|
||||
"panel main {{ geometry {{ inner-padding 12; }}; end {{ group status {{ island-padding {padding}; }}; }}; }}"
|
||||
);
|
||||
let config = parse_test_source(&source).unwrap();
|
||||
assert_eq!(config.panels[0].end.groups[0].island_padding, padding);
|
||||
assert_eq!(config.panels[0].geometry.inner_padding, 12.);
|
||||
}
|
||||
for value in ["-1", "32.5", "\"small\""] {
|
||||
assert!(parse_test_source(&format!("status {{ bar-island-padding {value}; }}")).is_err());
|
||||
assert!(
|
||||
parse_test_source(&format!(
|
||||
"panel main {{ end {{ group status {{ island-padding {value}; }}; }}; }}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,18 +757,44 @@ theme {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_radius_unifies_surfaces_and_preserves_legacy_fallbacks() {
|
||||
fn shell_radius_unifies_surfaces() {
|
||||
for radius in [0.0, 18.0] {
|
||||
let config = parse_test_source(&format!("appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}")).unwrap();
|
||||
let config = parse_test_source(&format!(
|
||||
"theme {{\n geometry {{\n shell-radius {radius}\n window-radius 23\n }}\n}}"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(config.theme.material_radius, radius);
|
||||
assert_eq!(config.theme.bar_radius, radius);
|
||||
}
|
||||
let legacy = parse_test_source("theme {\n geometry {\n top-bar-radius 7\n }\n}").unwrap();
|
||||
assert_eq!(legacy.theme.material_radius, 7.0);
|
||||
assert_eq!(legacy.theme.bar_radius, 7.0);
|
||||
let legacy = parse_test_source("appearance {\n corner-radius 6\n}").unwrap();
|
||||
assert_eq!(legacy.theme.material_radius, 6.0);
|
||||
assert_eq!(legacy.theme.bar_radius, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_rejects_invalid_configuration_without_replacing_it() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let source =
|
||||
"notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let error = super::load_path(&path).unwrap_err();
|
||||
assert!(error.contains("bar_layout"), "{error}");
|
||||
assert!(error.contains(path.to_str().unwrap()), "{error}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
|
||||
std::fs::write(&path, "panel main {").unwrap();
|
||||
assert!(super::load_path(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_distinguishes_absent_configuration_from_read_errors() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
assert!(super::load_path(&directory.path().join("missing.kdl")).is_ok());
|
||||
let error = super::load_path(directory.path()).unwrap_err();
|
||||
assert!(error.contains("cannot read"), "{error}");
|
||||
let path = directory.path().join("config.kdl");
|
||||
std::os::unix::fs::symlink(directory.path().join("missing-target"), &path).unwrap();
|
||||
assert!(
|
||||
super::load_path(&path).is_err(),
|
||||
"a broken config symlink is a read error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -733,9 +808,9 @@ theme {
|
||||
);
|
||||
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
|
||||
let theme = shell_theme(&config.theme);
|
||||
assert_eq!(theme.bar_margin_top, 0);
|
||||
assert_eq!(theme.bar_height, 28.0);
|
||||
assert_eq!(theme.bar_margin_horizontal, 0);
|
||||
assert_eq!(crate::panel::PanelGeometry::default().edge_margin, 0);
|
||||
assert_eq!(crate::panel::PanelGeometry::default().height, 28.0);
|
||||
assert_eq!(crate::panel::PanelGeometry::default().side_margins, 0);
|
||||
assert_eq!(theme.bar_radius, 14.0);
|
||||
assert_eq!(theme.bar_background[3], 255);
|
||||
assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary);
|
||||
|
||||
@@ -385,11 +385,7 @@ impl FereseShell {
|
||||
|
||||
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 material_opacity = self.config.theme.material_opacity();
|
||||
let model = &self.display_mode;
|
||||
let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible);
|
||||
let pending = model.pending();
|
||||
@@ -506,9 +502,10 @@ impl FereseShell {
|
||||
palette,
|
||||
false
|
||||
)
|
||||
.class(ferese_theme::controls::button_style_with_focus(
|
||||
.class(ferese_theme::controls::material_button_style_with_focus(
|
||||
palette,
|
||||
false,
|
||||
material_opacity,
|
||||
focus_visible
|
||||
))
|
||||
.id("ferese-modal-cancel".into())
|
||||
|
||||
+379
-71
@@ -17,11 +17,14 @@ mod motion;
|
||||
mod note_store;
|
||||
mod notification_ui;
|
||||
mod notifications;
|
||||
mod panel;
|
||||
mod panel_layout;
|
||||
mod recording;
|
||||
mod renderer;
|
||||
mod status;
|
||||
mod status_ui;
|
||||
mod system_modal;
|
||||
mod workspace_ui;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -89,9 +92,9 @@ struct BarMetrics {
|
||||
group_item_height: f32,
|
||||
}
|
||||
|
||||
impl From<ShellTheme> for BarMetrics {
|
||||
fn from(theme: ShellTheme) -> Self {
|
||||
let height = theme.bar_height;
|
||||
impl From<panel::PanelGeometry> for BarMetrics {
|
||||
fn from(geometry: panel::PanelGeometry) -> Self {
|
||||
let height = geometry.height;
|
||||
let control_height = (height - 4.0).max(21.0).min(height);
|
||||
// Leave two logical pixels inside the group border on each side,
|
||||
// including compact bars with 20 px icons.
|
||||
@@ -112,6 +115,10 @@ impl From<ShellTheme> for BarMetrics {
|
||||
}
|
||||
|
||||
fn main() -> cosmic::iced::Result {
|
||||
let mut config = config::load().unwrap_or_else(|error| {
|
||||
eprintln!("ferese-shell: {error}; shell was not started");
|
||||
std::process::exit(2);
|
||||
});
|
||||
renderer::configure_shell();
|
||||
|
||||
cosmic::iced::advanced::graphics::text::font_system()
|
||||
@@ -127,7 +134,6 @@ fn main() -> cosmic::iced::Result {
|
||||
"../../../assets/fonts/Cantarell-ExtraBold.otf"
|
||||
)));
|
||||
|
||||
let mut config = config::load();
|
||||
motion::configure(config.animations, config.theme.material_radius);
|
||||
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
||||
if compositor_wallpaper {
|
||||
@@ -164,11 +170,11 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<im
|
||||
|
||||
struct FereseShell {
|
||||
core: Core,
|
||||
bar_surface_id: window::Id,
|
||||
config: ShellConfig,
|
||||
wallpaper: Option<image::Handle>,
|
||||
control: Option<ShellControl>,
|
||||
snapshot: ShellSnapshot,
|
||||
workspace_ui: workspace_ui::WorkspaceUi,
|
||||
overview_active: bool,
|
||||
clock: String,
|
||||
clock_service: clock::Service,
|
||||
@@ -226,12 +232,15 @@ struct OutputSurfaces {
|
||||
size: Option<(i32, i32)>,
|
||||
effects: Option<EffectsBinding>,
|
||||
bar_regions: Vec<[f32; 5]>,
|
||||
panel_resolution: Option<panel_layout::Resolution>,
|
||||
bar_margin_horizontal: i32,
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
BarRegionsChanged(window::Id, Vec<[f32; 5]>),
|
||||
PanelResolved(window::Id, panel_layout::Resolution),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
ThemeMode(ferese_config::theme::Mode),
|
||||
ThemeModeSet(Result<(), String>),
|
||||
@@ -258,6 +267,10 @@ enum Message {
|
||||
MediaArt(media::Artwork),
|
||||
ControlReady,
|
||||
ActivateWorkspace(u64),
|
||||
HoverWorkspace(window::Id, u64, cosmic::iced::advanced::widget::Id, bool),
|
||||
WorkspaceTooltipDelay(u64),
|
||||
WorkspaceTooltipBounds(u64, Option<cosmic::iced::Rectangle>),
|
||||
WorkspaceIconsLoaded(Result<workspace_ui::IconIndex, String>),
|
||||
ToggleOverview,
|
||||
StatusUpdated(status::Update),
|
||||
StartRecording,
|
||||
@@ -273,8 +286,9 @@ enum Message {
|
||||
ToggleNotificationGroup(String),
|
||||
RemoveNotificationGroup(String),
|
||||
AnimateMenu,
|
||||
PanelAction(Box<Message>),
|
||||
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
||||
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
||||
OpenPopover(status_ui::Menu, status_ui::PopoverAnchor),
|
||||
Control(status::Action),
|
||||
ShowGuide,
|
||||
GuideLoaded {
|
||||
@@ -316,11 +330,9 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
|
||||
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
|
||||
let bar_surface_id = window::Id::unique();
|
||||
let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default();
|
||||
let app = Self {
|
||||
core,
|
||||
bar_surface_id,
|
||||
notifications: notifications::Center::new(config.notifications.clone()),
|
||||
notification_surface: None,
|
||||
status_service: status::Service::start(config.status.settings_command.clone()),
|
||||
@@ -353,6 +365,7 @@ impl cosmic::Application for FereseShell {
|
||||
})
|
||||
.ok(),
|
||||
snapshot: ShellSnapshot::default(),
|
||||
workspace_ui: Default::default(),
|
||||
overview_active: false,
|
||||
clock: current_time(),
|
||||
desktop_clock,
|
||||
@@ -389,7 +402,7 @@ impl cosmic::Application for FereseShell {
|
||||
.load(Ordering::Relaxed)
|
||||
.then_some(Message::Event(event, id)),
|
||||
Event::Keyboard(_)
|
||||
| Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_))
|
||||
| Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_) | cosmic::iced::mouse::Event::CursorLeft)
|
||||
| Event::Window(window::Event::Opened { .. } | window::Event::Closed)
|
||||
| Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||
wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..),
|
||||
@@ -429,6 +442,38 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
|
||||
match message {
|
||||
Message::PanelResolved(id, resolution) => {
|
||||
let allocation_changed = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.bar == id)
|
||||
.is_some_and(|output| {
|
||||
output
|
||||
.panel_resolution
|
||||
.as_ref()
|
||||
.is_some_and(|previous| previous != &resolution)
|
||||
});
|
||||
let close_menu = self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.anchor.parent == id
|
||||
&& menu.anchor.item.is_some()
|
||||
&& ((menu.kind == status_ui::Menu::Overflow && resolution.overflow.is_empty())
|
||||
|| allocation_changed)
|
||||
});
|
||||
if let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) {
|
||||
output.panel_resolution = Some(resolution);
|
||||
}
|
||||
let margin = self.update_panel_margin(id, false);
|
||||
let tooltip = if allocation_changed {
|
||||
self.dismiss_workspace_tooltip_for_bar(id)
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
if close_menu {
|
||||
Task::batch([margin, tooltip, self.destroy_menu()])
|
||||
} else {
|
||||
Task::batch([margin, tooltip])
|
||||
}
|
||||
}
|
||||
Message::BarRegionsChanged(id, regions) => {
|
||||
let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else {
|
||||
return Task::none();
|
||||
@@ -437,7 +482,7 @@ impl cosmic::Application for FereseShell {
|
||||
output.bar_regions = regions;
|
||||
set_input_zone(
|
||||
id,
|
||||
bar::input_region(self.config.status.bar_layout, output.hidden, &output.bar_regions),
|
||||
bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -580,7 +625,7 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Message::NativeSurface(id, result) => {
|
||||
match result {
|
||||
Ok((_connection, surface)) if self.outputs.iter().any(|entry| entry.bar == id) => {
|
||||
Ok((_connection, surface)) if self.outputs.iter().any(|output| output.bar == id) => {
|
||||
self.attach_effects(id, &surface)
|
||||
}
|
||||
Ok((_connection, surface)) => {
|
||||
@@ -676,16 +721,15 @@ impl cosmic::Application for FereseShell {
|
||||
self.sync_notification_surface()
|
||||
}
|
||||
Message::AnimateMenu => self.animate_menu(),
|
||||
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
|
||||
Message::OpenMenuOn(id, kind, anchor) => {
|
||||
if self.bar_surface_id != id {
|
||||
let destroy = self.destroy_menu();
|
||||
self.bar_surface_id = id;
|
||||
let open = self.open_menu(kind, anchor);
|
||||
Task::batch([destroy, open])
|
||||
} else {
|
||||
self.open_menu(kind, anchor)
|
||||
}
|
||||
Message::PanelAction(message) => {
|
||||
let close = self.destroy_menu();
|
||||
let action = self.update(*message);
|
||||
close.chain(action)
|
||||
}
|
||||
Message::OpenMenu(..) => Task::none(), // Bar views attach an explicit item/surface anchor.
|
||||
Message::OpenPopover(kind, anchor) => {
|
||||
let tooltip = self.dismiss_workspace_tooltip();
|
||||
Task::batch([tooltip, self.open_menu(kind, anchor)])
|
||||
}
|
||||
Message::ShowGuide => {
|
||||
if self.guide_shown
|
||||
@@ -831,15 +875,18 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
if let Some(active) = poll.overview_active {
|
||||
self.overview_active = active;
|
||||
if active {
|
||||
reload_task = self.dismiss_workspace_tooltip();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(source) = poll.config {
|
||||
reload_task = self.reload_config(source);
|
||||
reload_task = Task::batch([reload_task, self.reload_config(source)]);
|
||||
}
|
||||
|
||||
if let Some(snapshot) = poll.snapshot {
|
||||
self.snapshot = snapshot;
|
||||
let mut tasks = vec![reload_task];
|
||||
let mut tasks = vec![reload_task, self.update_workspace_ui()];
|
||||
let mut visibility_changed = false;
|
||||
|
||||
for entry in &mut self.outputs {
|
||||
@@ -864,13 +911,18 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
tasks.push(set_input_zone(
|
||||
entry.bar,
|
||||
bar::input_region(self.config.status.bar_layout, hidden, &entry.bar_regions),
|
||||
bar::input_region(self.config.panels[0].surface, hidden, &entry.bar_regions),
|
||||
));
|
||||
}
|
||||
if self.bar_hidden(self.bar_surface_id) {
|
||||
if self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| self.bar_hidden(menu.anchor.parent))
|
||||
{
|
||||
tasks.push(self.destroy_menu());
|
||||
}
|
||||
if visibility_changed {
|
||||
tasks.push(self.dismiss_workspace_tooltip());
|
||||
self.refresh_media_art(false);
|
||||
}
|
||||
|
||||
@@ -895,14 +947,20 @@ impl cosmic::Application for FereseShell {
|
||||
if let Some(control) = &self.control {
|
||||
control.activate_workspace(id);
|
||||
}
|
||||
Task::none()
|
||||
self.dismiss_workspace_tooltip()
|
||||
}
|
||||
Message::HoverWorkspace(bar, workspace, target, entered) => {
|
||||
self.hover_workspace(bar, workspace, target, entered)
|
||||
}
|
||||
Message::WorkspaceTooltipDelay(serial) => self.locate_workspace_tooltip(serial),
|
||||
Message::WorkspaceTooltipBounds(serial, bounds) => self.show_workspace_tooltip(serial, bounds),
|
||||
Message::WorkspaceIconsLoaded(result) => self.workspace_icons_loaded(result),
|
||||
Message::ToggleOverview => {
|
||||
self.overview_active = !self.overview_active;
|
||||
if let Some(control) = &self.control {
|
||||
control.set_overview_active(self.overview_active);
|
||||
}
|
||||
Task::none()
|
||||
self.dismiss_workspace_tooltip()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -928,6 +986,8 @@ impl FereseShell {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
|
||||
self.notifications
|
||||
.service_error("Configuration could not be loaded", &error.to_string());
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
@@ -960,7 +1020,10 @@ impl FereseShell {
|
||||
self.status_service
|
||||
.update_settings(config.status.settings_command.clone());
|
||||
|
||||
let composition_changed = !self.config.panels[0].same_composition(&config.panels[0]);
|
||||
let edge_changed = self.config.panels[0].edge != config.panels[0].edge;
|
||||
let old = self.config.theme;
|
||||
let old_geometry = self.config.panels[0].geometry;
|
||||
let old_clock = &self.config.desktop_widgets.clock;
|
||||
let old_notes = &self.config.desktop_widgets.notes;
|
||||
let new_notes = &config.desktop_widgets.notes;
|
||||
@@ -975,24 +1038,38 @@ impl FereseShell {
|
||||
|| old_clock.margin_x != new_clock.margin_x
|
||||
|| old_clock.margin_y != new_clock.margin_y;
|
||||
let theme = config.theme;
|
||||
let geometry_changed = old.bar_height != theme.bar_height
|
||||
|| old.bar_margin_top != theme.bar_margin_top
|
||||
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|
||||
|| old.bar_window_gap != theme.bar_window_gap;
|
||||
let geometry = config.panels[0].geometry;
|
||||
let geometry_changed = edge_changed || old_geometry != geometry;
|
||||
self.notifications.configure(config.notifications.clone());
|
||||
self.clock_service.configure(&config.desktop_widgets.clock);
|
||||
self.config = config;
|
||||
let workspace_task = self.update_workspace_ui();
|
||||
self.desktop_clock = self
|
||||
.config
|
||||
.desktop_widgets
|
||||
.clock
|
||||
.labels(&Zoned::now())
|
||||
.unwrap_or_default();
|
||||
let mut tasks = vec![if clock_changed {
|
||||
self.rebuild_clocks(true)
|
||||
} else {
|
||||
Task::none()
|
||||
}];
|
||||
let mut tasks = vec![
|
||||
workspace_task,
|
||||
if clock_changed {
|
||||
self.rebuild_clocks(true)
|
||||
} else {
|
||||
Task::none()
|
||||
},
|
||||
];
|
||||
|
||||
if composition_changed || geometry_changed {
|
||||
tasks.push(self.destroy_menu());
|
||||
for output in &mut self.outputs {
|
||||
// Keep the measured allocation and margins until the next layout
|
||||
// publishes its replacement through PanelResolved.
|
||||
tasks.push(set_input_zone(
|
||||
output.bar,
|
||||
bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.guide_load.manual
|
||||
&& !self.config.status.keybinding_guide
|
||||
@@ -1025,22 +1102,51 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
for entry in self.outputs.iter().filter(|_| geometry_changed) {
|
||||
tasks.push(set_size(entry.bar, None, Some(theme.bar_height.round() as u32)));
|
||||
tasks.push(set_margin(
|
||||
entry.bar,
|
||||
theme.bar_margin_top,
|
||||
theme.bar_margin_horizontal,
|
||||
0,
|
||||
theme.bar_margin_horizontal,
|
||||
));
|
||||
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||
tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT));
|
||||
tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32)));
|
||||
tasks.push(set_exclusive_zone(
|
||||
entry.bar,
|
||||
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
|
||||
(geometry.height.round() as i32).saturating_add(geometry.window_clearance),
|
||||
));
|
||||
}
|
||||
|
||||
let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect();
|
||||
for bar in bars {
|
||||
tasks.push(self.update_panel_margin(bar, edge_changed || old_geometry.edge_margin != geometry.edge_margin));
|
||||
}
|
||||
if geometry_changed {
|
||||
tasks.push(self.dismiss_workspace_tooltip());
|
||||
if let Some(surface) = self.notification_surface.take() {
|
||||
tasks.push(destroy_layer_surface(surface.id));
|
||||
}
|
||||
tasks.push(self.sync_notification_surface());
|
||||
}
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
fn update_panel_margin(&mut self, bar: window::Id, force: bool) -> Task<Message> {
|
||||
let geometry = self.config.panels[0].geometry;
|
||||
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == bar) else {
|
||||
return Task::none();
|
||||
};
|
||||
let margin = entry.size.zip(entry.panel_resolution.as_ref()).map_or(
|
||||
entry.bar_margin_horizontal,
|
||||
|((width, _), resolution)| {
|
||||
resolution.side_margin(
|
||||
geometry.side_margins,
|
||||
width,
|
||||
f32::from(geometry.inner_padding.round() as u16),
|
||||
)
|
||||
},
|
||||
);
|
||||
if entry.bar_margin_horizontal == margin && !force {
|
||||
return Task::none();
|
||||
}
|
||||
entry.bar_margin_horizontal = margin;
|
||||
let (_, margin) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, margin);
|
||||
set_margin(bar, margin.top, margin.right, margin.bottom, margin.left)
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
|
||||
@@ -1060,6 +1166,231 @@ fn color_with_opacity(mut value: [u8; 4], opacity: f32) -> Color {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::control::{OutputSnapshot, WindowSnapshot};
|
||||
pub(super) fn shell_with_measured_panel() -> FereseShell {
|
||||
let config = ShellConfig::default();
|
||||
// This output is never dispatched; tests use only its stored geometry.
|
||||
let (socket, _peer) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
let connection = Connection::from_socket(socket).unwrap();
|
||||
let output = wl_output::WlOutput::inert(connection.backend().downgrade());
|
||||
FereseShell {
|
||||
core: Core::default(),
|
||||
notifications: notifications::Center::new(config.notifications.clone()),
|
||||
notification_surface: None,
|
||||
status_service: status::Service::start(None),
|
||||
status: Default::default(),
|
||||
recorder: Default::default(),
|
||||
media: Default::default(),
|
||||
calendar_offset: 0,
|
||||
status_error: None,
|
||||
theme_error: None,
|
||||
menu: None,
|
||||
system_modal: None,
|
||||
display_mode: Default::default(),
|
||||
guide_shown: false,
|
||||
guide_load: Default::default(),
|
||||
guide_attempts: 0,
|
||||
pending_power: None,
|
||||
note_editor: None,
|
||||
note_drag: None,
|
||||
note_pointer: Default::default(),
|
||||
note_pending: Vec::new(),
|
||||
note_inflight: Vec::new(),
|
||||
note_saving: false,
|
||||
note_error: None,
|
||||
clock_service: clock::Service::new(&config.desktop_widgets.clock),
|
||||
config,
|
||||
wallpaper: None,
|
||||
control: None,
|
||||
snapshot: Default::default(),
|
||||
workspace_ui: Default::default(),
|
||||
overview_active: false,
|
||||
clock: String::new(),
|
||||
desktop_clock: Default::default(),
|
||||
outputs: vec![OutputSurfaces {
|
||||
output,
|
||||
name: Some("test".into()),
|
||||
bar: window::Id::unique(),
|
||||
wallpaper: None,
|
||||
clock: None,
|
||||
notes: Vec::new(),
|
||||
size: Some((1200, 800)),
|
||||
effects: None,
|
||||
bar_regions: vec![[0., 0., 600., 36., 8.]],
|
||||
panel_resolution: Some(panel_layout::Resolution {
|
||||
minimum_width: 600.,
|
||||
..Default::default()
|
||||
}),
|
||||
bar_margin_horizontal: 0,
|
||||
hidden: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_placement_uses_only_the_selected_edge_for_anchor_and_margin() {
|
||||
use ferese_config::panel::Edge;
|
||||
for gap in [0, 12] {
|
||||
let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32);
|
||||
assert_eq!(anchor, Anchor::TOP);
|
||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (gap, 32, 0, 32));
|
||||
let (anchor, margin) = surfaces::panel_placement(Edge::Bottom, gap, 32);
|
||||
assert_eq!(anchor, Anchor::BOTTOM);
|
||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 32, gap, 32));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() {
|
||||
let mut shell = shell_with_measured_panel();
|
||||
shell.config.panels[0].geometry.side_margins = 240;
|
||||
drop(shell.update_panel_margin(shell.outputs[0].bar, false));
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
|
||||
let popup = window::Id::unique();
|
||||
shell.menu = Some(status_ui::OpenMenu {
|
||||
id: popup,
|
||||
anchor: status_ui::PopoverAnchor {
|
||||
parent: shell.outputs[0].bar,
|
||||
panel: shell.config.panels[0].id.clone(),
|
||||
item: Some(panel::ItemId("clock".into())),
|
||||
rectangle: Default::default(),
|
||||
},
|
||||
kind: status_ui::Menu::Calendar,
|
||||
motion: motion::PopupMotion::new(Default::default()),
|
||||
effects: None,
|
||||
regions: Default::default(),
|
||||
});
|
||||
let resolution = shell.outputs[0].panel_resolution.clone();
|
||||
for (field, value) in [
|
||||
("background_opacity", serde_json::json!(0.5)),
|
||||
("corner_radius", serde_json::json!("4px 8px")),
|
||||
("border", serde_json::json!(true)),
|
||||
("group_surface", serde_json::json!("inset")),
|
||||
] {
|
||||
let edit = field;
|
||||
let mut document = ferese_config::Document::parse("").unwrap();
|
||||
document
|
||||
.set("panels", serde_json::to_value(&shell.config.panels).unwrap())
|
||||
.unwrap();
|
||||
document.set(&format!("panels.0.{field}"), value).unwrap();
|
||||
let source = document.to_string();
|
||||
let mut config = config::parse_source(&source).unwrap();
|
||||
config.theme = shell.config.theme;
|
||||
drop(shell.apply_config(config));
|
||||
assert_eq!(shell.outputs[0].panel_resolution, resolution, "{edit}");
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240, "{edit}");
|
||||
assert_eq!(shell.menu.as_ref().unwrap().id, popup, "{edit}");
|
||||
}
|
||||
// A real membership edit closes the popup, but retains the last allocation
|
||||
// until the adaptive widget measures and publishes its replacement.
|
||||
let mut config = shell.config.clone();
|
||||
config.panels[0].end.groups.clear();
|
||||
drop(shell.apply_config(config));
|
||||
assert!(shell.menu.is_none());
|
||||
assert_eq!(shell.outputs[0].panel_resolution, resolution);
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
|
||||
use cosmic::Application;
|
||||
let replacement = panel_layout::Resolution {
|
||||
minimum_width: 900.,
|
||||
..Default::default()
|
||||
};
|
||||
drop(shell.update(Message::PanelResolved(shell.outputs[0].bar, replacement.clone())));
|
||||
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
|
||||
assert!(shell.outputs[0].bar_margin_horizontal < 240);
|
||||
let before = shell.outputs[0].bar_margin_horizontal;
|
||||
drop(shell.reload_config("panel broken {".into()));
|
||||
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_reallocation_dismisses_popovers_in_both_overflow_directions() {
|
||||
use cosmic::Application;
|
||||
use panel_layout::{Placement, Resolution};
|
||||
let item = panel::ItemId("network".into());
|
||||
let visible = Resolution {
|
||||
items: [(
|
||||
item.clone(),
|
||||
Placement::Visible {
|
||||
representation: panel::Representation::Icon,
|
||||
width: 28.,
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
zone_widths: [0., 0., 40.],
|
||||
..Default::default()
|
||||
};
|
||||
let overflow = Resolution {
|
||||
items: [(item.clone(), Placement::Overflow)].into(),
|
||||
overflow: vec![item.clone()],
|
||||
zone_widths: [0., 0., 32.],
|
||||
overflow_trigger_width: 24.,
|
||||
..Default::default()
|
||||
};
|
||||
let mut moved = visible.clone();
|
||||
moved.zone_widths[2] = 160.;
|
||||
for (before, after, kind) in [
|
||||
(&overflow, &visible, status_ui::Menu::Network),
|
||||
(&visible, &overflow, status_ui::Menu::Network),
|
||||
(&visible, &moved, status_ui::Menu::Network),
|
||||
(&overflow, &visible, status_ui::Menu::Overflow),
|
||||
] {
|
||||
let mut shell = shell_with_measured_panel();
|
||||
let bar = shell.outputs[0].bar;
|
||||
shell.outputs[0].panel_resolution = Some(before.clone());
|
||||
shell.menu = Some(status_ui::OpenMenu {
|
||||
id: window::Id::unique(),
|
||||
anchor: status_ui::PopoverAnchor {
|
||||
parent: bar,
|
||||
panel: shell.config.panels[0].id.clone(),
|
||||
item: Some(if kind == status_ui::Menu::Overflow {
|
||||
panel::ItemId("_overflow".into())
|
||||
} else {
|
||||
item.clone()
|
||||
}),
|
||||
rectangle: cosmic::iced::Rectangle {
|
||||
x: 1100,
|
||||
y: 0,
|
||||
width: 28,
|
||||
height: 36,
|
||||
},
|
||||
},
|
||||
kind,
|
||||
motion: motion::PopupMotion::new(Default::default()),
|
||||
effects: None,
|
||||
regions: Default::default(),
|
||||
});
|
||||
let popup = shell.menu.as_ref().unwrap().id;
|
||||
drop(shell.update(Message::PanelResolved(window::Id::unique(), after.clone())));
|
||||
assert_eq!(
|
||||
shell.menu.as_ref().unwrap().id,
|
||||
popup,
|
||||
"another output must not dismiss this popup"
|
||||
);
|
||||
drop(shell.update(Message::PanelResolved(bar, before.clone())));
|
||||
assert_eq!(
|
||||
shell.menu.as_ref().unwrap().id,
|
||||
popup,
|
||||
"unchanged allocation must preserve the popup"
|
||||
);
|
||||
drop(shell.update(Message::PanelResolved(bar, after.clone())));
|
||||
assert!(shell.menu.is_none(), "stale {kind:?} popup must be destroyed");
|
||||
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(after));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_measured_panel_is_not_an_unmeasured_panel() {
|
||||
let mut shell = shell_with_measured_panel();
|
||||
let bar = shell.outputs[0].bar;
|
||||
shell.config.panels[0].geometry.side_margins = 240;
|
||||
shell.outputs[0].panel_resolution = None;
|
||||
drop(shell.update_panel_margin(bar, false));
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 0);
|
||||
shell.outputs[0].panel_resolution = Some(Default::default());
|
||||
drop(shell.update_panel_margin(bar, false));
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_drag_stops_at_edges_without_tunnelling_and_slides() {
|
||||
use cosmic::iced::{Point, Rectangle};
|
||||
@@ -1150,29 +1481,6 @@ mod tests {
|
||||
assert_eq!(before.border, after.border);
|
||||
assert_eq!(before.background, after.background);
|
||||
assert_eq!(before.border.color, color(theme.border));
|
||||
assert_eq!(
|
||||
workspace_selector_style(false, false, true, theme).background,
|
||||
workspace_selector_style(false, false, true, contrasted).background,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_selector_has_distinct_active_paint() {
|
||||
let theme = ShellTheme::default();
|
||||
let active = workspace_selector_style(true, false, false, theme);
|
||||
let occupied = workspace_selector_style(false, false, true, theme);
|
||||
let inactive = workspace_selector_style(false, false, false, theme);
|
||||
let remote = workspace_selector_style(false, true, false, theme);
|
||||
assert!(active.background.is_some());
|
||||
assert_eq!(
|
||||
active.background,
|
||||
Some(Background::Color(color_with_opacity(theme.accent, 0.16)))
|
||||
);
|
||||
assert!(occupied.background.is_some());
|
||||
assert!(inactive.background.is_none());
|
||||
assert_eq!(inactive.border.width, 0.0);
|
||||
assert_eq!(remote.border.width, 1.0);
|
||||
assert!(remote.background.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1223,9 +1531,9 @@ mod tests {
|
||||
#[test]
|
||||
fn compact_bar_preserves_logical_text_and_icon_sizes() {
|
||||
for height in [24.0, 26.0, 32.0, 38.0, 44.0] {
|
||||
let metrics = BarMetrics::from(ShellTheme {
|
||||
bar_height: height,
|
||||
..ShellTheme::default()
|
||||
let metrics = BarMetrics::from(panel::PanelGeometry {
|
||||
height,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(metrics.text_size, 14);
|
||||
assert_eq!(metrics.icon_size, 20);
|
||||
|
||||
@@ -358,11 +358,26 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
}
|
||||
|
||||
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
|
||||
animated_from_edge(content, progress, regions, radius, ferese_config::panel::Edge::Top)
|
||||
}
|
||||
|
||||
pub fn animated_from_edge<'a, M: 'a>(
|
||||
content: Element<'a, M>,
|
||||
progress: f32,
|
||||
regions: Regions,
|
||||
radius: f32,
|
||||
edge: ferese_config::panel::Edge,
|
||||
) -> Element<'a, M> {
|
||||
Element::new(Motion {
|
||||
content,
|
||||
progress,
|
||||
regions,
|
||||
radius,
|
||||
direction: if edge == ferese_config::panel::Edge::Top {
|
||||
-1.
|
||||
} else {
|
||||
1.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -371,11 +386,12 @@ struct Motion<'a, M> {
|
||||
progress: f32,
|
||||
regions: Regions,
|
||||
radius: f32,
|
||||
direction: f32,
|
||||
}
|
||||
|
||||
impl<M> Motion<'_, M> {
|
||||
fn translation(&self) -> Vector {
|
||||
Vector::new(0.0, -4.0 * (1.0 - self.progress))
|
||||
Vector::new(0.0, self.direction * 4.0 * (1.0 - self.progress))
|
||||
}
|
||||
|
||||
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
|
||||
|
||||
@@ -26,6 +26,21 @@ fn blend(base: Color, foreground: Color, amount: f32) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_well<'a>(
|
||||
content: impl Into<Element<'a, cosmic::Action<Message>>>,
|
||||
foreground: Color,
|
||||
extent: u16,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
container(content)
|
||||
.center_x(extent)
|
||||
.center_y(extent)
|
||||
.class(ferese_theme::controls::surface(
|
||||
foreground.scale_alpha(0.07),
|
||||
motion::radius(f32::from(extent) / 2.0),
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn age_label(age: Duration) -> String {
|
||||
match age.as_secs() {
|
||||
0..60 => "now".into(),
|
||||
@@ -39,7 +54,6 @@ fn card_button<'a>(
|
||||
content: impl Into<Element<'a, cosmic::Action<Message>>>,
|
||||
message: Message,
|
||||
foreground: Color,
|
||||
hover: Color,
|
||||
round: bool,
|
||||
accessible_name: String,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
@@ -50,7 +64,7 @@ fn card_button<'a>(
|
||||
.on_press(cosmic::Action::App(message))
|
||||
.class(ferese_theme::controls::notification_button(
|
||||
foreground,
|
||||
hover,
|
||||
foreground.scale_alpha(0.08),
|
||||
if round { motion::radius(11.) } else { 0. },
|
||||
round,
|
||||
))
|
||||
@@ -88,7 +102,6 @@ fn icon_control<'a>(
|
||||
.center_y(extent),
|
||||
message,
|
||||
foreground,
|
||||
background,
|
||||
true,
|
||||
label.into(),
|
||||
);
|
||||
@@ -122,11 +135,18 @@ impl FereseShell {
|
||||
pub(super) fn notification_history_height_limit(&self) -> f32 {
|
||||
self.outputs
|
||||
.iter()
|
||||
.find(|output| output.bar == self.bar_surface_id)
|
||||
.find(|output| {
|
||||
Some(output.bar)
|
||||
== self
|
||||
.menu
|
||||
.as_ref()
|
||||
.map(|menu| menu.anchor.parent)
|
||||
.or_else(|| self.active_bar())
|
||||
})
|
||||
.and_then(|output| output.size)
|
||||
.map_or(HISTORY_MAX_HEIGHT, |(_, height)| {
|
||||
let theme = self.config.theme;
|
||||
(height as f32 - theme.bar_margin_top as f32 - theme.bar_height - 8.0 - 12.0).max(1.0)
|
||||
let geometry = self.config.panels[0].geometry;
|
||||
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0)
|
||||
})
|
||||
.min(HISTORY_MAX_HEIGHT)
|
||||
}
|
||||
@@ -135,31 +155,29 @@ impl FereseShell {
|
||||
if self.notifications.history_open {
|
||||
return self.close_menu();
|
||||
}
|
||||
// Cards can open history too. Anchor those requests to the active bar;
|
||||
// bell clicks pass their exact anchor through the normal menu path.
|
||||
if let Some(output) = self.outputs.iter().find(|entry| {
|
||||
self.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
|
||||
}) {
|
||||
self.bar_surface_id = output.bar;
|
||||
}
|
||||
// Toast requests use the focused output without changing popup parent selection.
|
||||
let Some(parent) = self.active_bar() else {
|
||||
return Task::none();
|
||||
};
|
||||
let panel = &self.config.panels[0];
|
||||
let width = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.bar == self.bar_surface_id)
|
||||
.find(|entry| entry.bar == parent)
|
||||
.and_then(|entry| entry.size)
|
||||
.map_or(POPUP_WIDTH as i32, |size| size.0);
|
||||
self.open_menu(
|
||||
status_ui::Menu::Notifications,
|
||||
cosmic::iced::Rectangle {
|
||||
let anchor = status_ui::PopoverAnchor {
|
||||
parent,
|
||||
panel: panel.id.clone(),
|
||||
item: None,
|
||||
rectangle: cosmic::iced::Rectangle {
|
||||
x: (width - 48).max(0),
|
||||
y: 0,
|
||||
width: 24,
|
||||
height: self.config.theme.bar_height.round() as i32,
|
||||
height: self.config.panels[0].geometry.height.round() as i32,
|
||||
},
|
||||
)
|
||||
};
|
||||
self.open_menu(status_ui::Menu::Notifications, anchor)
|
||||
}
|
||||
|
||||
pub(super) fn close_notification_history(&mut self) -> Task<Message> {
|
||||
@@ -218,7 +236,7 @@ impl FereseShell {
|
||||
.find(|entry| entry.output == output)
|
||||
.and_then(|entry| entry.size)
|
||||
.map_or(1080, |size| size.1);
|
||||
let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0;
|
||||
let top = self.config.panels[0].geometry.height + self.config.panels[0].geometry.edge_margin as f32 + 12.0;
|
||||
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
|
||||
if let Some(surface) = &mut self.notification_surface {
|
||||
if surface.height != height {
|
||||
@@ -234,20 +252,18 @@ impl FereseShell {
|
||||
effects: None,
|
||||
regions: Default::default(),
|
||||
});
|
||||
let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0;
|
||||
let (edge_anchor, mut margin) =
|
||||
crate::surfaces::panel_placement(self.config.panels[0].edge, top as i32, 12);
|
||||
margin.left = 0;
|
||||
let action = cosmic::surface::action::app_layer_shell::<Self>(
|
||||
|_| Default::default(),
|
||||
move |_| SctkLayerSurfaceSettings {
|
||||
id,
|
||||
layer: Layer::Overlay,
|
||||
keyboard_interactivity: KeyboardInteractivity::None,
|
||||
anchor: Anchor::TOP | Anchor::RIGHT,
|
||||
anchor: edge_anchor | Anchor::RIGHT,
|
||||
output: IcedOutput::Output(output.clone()),
|
||||
margin: IcedMargin {
|
||||
top: top as i32,
|
||||
right: 12,
|
||||
..Default::default()
|
||||
},
|
||||
margin,
|
||||
exclusive_zone: -1,
|
||||
size: Some((Some(POPUP_WIDTH), Some(height))),
|
||||
size_limits: Limits::NONE,
|
||||
@@ -300,18 +316,7 @@ impl FereseShell {
|
||||
icon::from_name(notice.icon.as_str()).size(18).icon()
|
||||
}
|
||||
.opacity(opacity);
|
||||
let icon_well = container(app_icon)
|
||||
.center_x(30)
|
||||
.center_y(30)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(well)),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: motion::radius(15.0).into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}));
|
||||
let icon_well = icon_well(app_icon, foreground, 30);
|
||||
let icon: Element<'_, cosmic::Action<Message>> = if count > 1 {
|
||||
let badge_fill = ferese_theme::composite(
|
||||
color(palette.accent),
|
||||
@@ -350,7 +355,7 @@ impl FereseShell {
|
||||
])
|
||||
.into()
|
||||
} else {
|
||||
icon_well.into()
|
||||
icon_well
|
||||
};
|
||||
let app = if notice.app.is_empty() {
|
||||
"NOTIFICATION".to_owned()
|
||||
@@ -452,7 +457,6 @@ impl FereseShell {
|
||||
.center_y(38),
|
||||
Message::InvokeNotification(id, key.clone()),
|
||||
accent,
|
||||
well,
|
||||
false,
|
||||
label.clone(),
|
||||
));
|
||||
@@ -661,37 +665,17 @@ impl FereseShell {
|
||||
self.notifications.dnd,
|
||||
1.0,
|
||||
));
|
||||
let controls = container(dnd)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(base)),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: palette.material_radius.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}));
|
||||
let controls = ferese_theme::menus::section(dnd.into(), primary, opacity, palette.material_radius);
|
||||
let mut content = column([]).spacing(10).push(header).push(controls);
|
||||
if count == 0 {
|
||||
let empty = column([])
|
||||
.spacing(10)
|
||||
.align_x(alignment::Horizontal::Center)
|
||||
.push(
|
||||
container(accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent))
|
||||
.center_x(56)
|
||||
.center_y(56)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(well)),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: motion::radius(28.0).into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})),
|
||||
)
|
||||
.push(icon_well(
|
||||
accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent),
|
||||
primary,
|
||||
56,
|
||||
))
|
||||
.push(
|
||||
text("You're all caught up")
|
||||
.size(15)
|
||||
@@ -797,11 +781,12 @@ impl FereseShell {
|
||||
..Default::default()
|
||||
}))
|
||||
.into();
|
||||
motion::animated(
|
||||
motion::animated_from_edge(
|
||||
panel,
|
||||
progress,
|
||||
surface.map(|surface| surface.regions.clone()).unwrap_or_default(),
|
||||
palette.material_radius,
|
||||
self.config.panels[0].edge,
|
||||
)
|
||||
} else {
|
||||
let mut cards = column([]).spacing(8);
|
||||
@@ -834,6 +819,58 @@ mod tests {
|
||||
use cosmic::iced::advanced::{Layout, layout, mouse, widget::Tree};
|
||||
use cosmic::iced::{Font, Pixels, Rectangle, Size};
|
||||
|
||||
#[test]
|
||||
fn notification_insets_reveal_the_modal_material() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.0),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
for foreground in [Color::BLACK, Color::WHITE] {
|
||||
for extent in [30, 56] {
|
||||
let inset = icon_well(cosmic::widget::Space::new(), foreground, extent);
|
||||
let controls = ferese_theme::menus::section(
|
||||
cosmic::widget::Space::new().width(100).height(40).into(),
|
||||
foreground,
|
||||
1.0,
|
||||
14.0,
|
||||
);
|
||||
for mut element in [inset, controls] {
|
||||
let bounds = Rectangle::with_size(Size::new(120.0, 60.0));
|
||||
let mut tree = Tree::new(element.as_widget());
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
renderer.reset(bounds);
|
||||
element.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&cosmic::Theme::dark(),
|
||||
&Default::default(),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&bounds,
|
||||
);
|
||||
let pixels = Headless::screenshot(&mut renderer, Size::new(120, 60), 1.0, Color::TRANSPARENT);
|
||||
let center = node.bounds().center();
|
||||
let alpha = pixels[((center.y as usize * 120 + center.x as usize) * 4) + 3];
|
||||
assert!(
|
||||
alpha > 0 && alpha < 32,
|
||||
"an inset must be visible without hiding the material: {alpha}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_action_hover_stays_inside_card_corners() {
|
||||
check_action_hover("tiny-skia");
|
||||
@@ -865,7 +902,6 @@ mod tests {
|
||||
cosmic::widget::Space::new().height(38),
|
||||
Message::InvokeNotification(1, "action".into()),
|
||||
Color::WHITE,
|
||||
Color::WHITE,
|
||||
false,
|
||||
"Action".into(),
|
||||
);
|
||||
@@ -906,9 +942,10 @@ mod tests {
|
||||
0,
|
||||
"{backend}, scale={scale}, history={history}"
|
||||
);
|
||||
let hover_alpha = alpha(size.width / 2, size.height - 4);
|
||||
assert!(
|
||||
alpha(size.width / 2, size.height - 4) > 200,
|
||||
"the action hover must still render"
|
||||
hover_alpha > 0 && hover_alpha < 32,
|
||||
"the action hover must render without hiding the material: {hover_alpha}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) use ferese_config::panel::*;
|
||||
@@ -0,0 +1 @@
|
||||
pub(crate) use ferese_config::panel::layout::*;
|
||||
@@ -30,7 +30,7 @@ fn apply_app_backend(command: &mut Command, backend: Option<&OsStr>) {
|
||||
if let Some(backend) = backend {
|
||||
command.env("ICED_BACKEND", backend);
|
||||
} else {
|
||||
// The shell's automatic software preference must not leak into Settings.
|
||||
// Applications choose their own default rendering backend.
|
||||
command.env_remove("ICED_BACKEND");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,84 +8,96 @@ use crate::status::Snapshot;
|
||||
use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording, text};
|
||||
|
||||
impl FereseShell {
|
||||
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
pub(crate) fn view_media_item(
|
||||
&self,
|
||||
representation: crate::panel::Representation,
|
||||
selected: bool,
|
||||
width: Option<f32>,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let theme = self.config.theme.for_bar();
|
||||
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands;
|
||||
let metrics = BarMetrics::from(theme);
|
||||
let mut controls = row::with_capacity(7).spacing(1).align_y(Alignment::Center);
|
||||
if self.media.snapshot.selected.is_some() {
|
||||
controls = controls.push(super::media::bar(self, theme, metrics));
|
||||
if representation == crate::panel::Representation::Icon {
|
||||
self.view_status_item(Menu::Media, false, selected)
|
||||
} else {
|
||||
super::media::bar(
|
||||
self,
|
||||
theme,
|
||||
BarMetrics::from(self.config.panels[0].geometry),
|
||||
representation,
|
||||
selected,
|
||||
width,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn view_status_item(
|
||||
&self,
|
||||
kind: Menu,
|
||||
battery_percentage: bool,
|
||||
selected: bool,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let theme = self.config.theme.for_bar();
|
||||
let metrics = BarMetrics::from(self.config.panels[0].geometry);
|
||||
let (source, enabled) = if kind == Menu::Recording {
|
||||
let source: &'static [u8] = match self.recorder.state {
|
||||
recording::State::Selecting => ferese_theme::icons::RECORD_CANCEL,
|
||||
recording::State::Recording(_) => ferese_theme::icons::RECORD_STOP,
|
||||
recording::State::Saving => ferese_theme::icons::RECORD_SAVING,
|
||||
_ => ferese_theme::icons::RECORD,
|
||||
};
|
||||
(source, true)
|
||||
} else {
|
||||
status_icon(kind, &self.status)
|
||||
};
|
||||
let selected =
|
||||
selected || (kind == Menu::Recording && matches!(self.recorder.state, recording::State::Recording(_)));
|
||||
let foreground = color(if selected {
|
||||
theme.accent
|
||||
} else if enabled {
|
||||
theme.text_primary
|
||||
} else {
|
||||
theme.text_muted
|
||||
});
|
||||
let mut content = row![accented_icon(
|
||||
source,
|
||||
metrics.icon_size,
|
||||
foreground,
|
||||
color(theme.accent)
|
||||
)]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
if kind == Menu::Recording
|
||||
&& let Some(elapsed) = self.recorder.elapsed()
|
||||
{
|
||||
content = content.push(
|
||||
text(elapsed)
|
||||
.size(metrics.text_size)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
for kind in [
|
||||
Menu::System,
|
||||
Menu::Network,
|
||||
Menu::Audio,
|
||||
Menu::Recording,
|
||||
Menu::Notifications,
|
||||
Menu::Battery,
|
||||
] {
|
||||
if !kind.available(&self.status) {
|
||||
continue;
|
||||
}
|
||||
let (source, enabled) = if kind == Menu::Recording {
|
||||
let source: &'static [u8] = match self.recorder.state {
|
||||
recording::State::Selecting => ferese_theme::icons::RECORD_CANCEL,
|
||||
recording::State::Recording(_) => ferese_theme::icons::RECORD_STOP,
|
||||
recording::State::Saving => ferese_theme::icons::RECORD_SAVING,
|
||||
_ => ferese_theme::icons::RECORD,
|
||||
};
|
||||
(source, true)
|
||||
} else {
|
||||
status_icon(kind, &self.status)
|
||||
};
|
||||
let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind)
|
||||
|| (kind == Menu::Recording && matches!(self.recorder.state, recording::State::Recording(_)));
|
||||
let foreground = color(if selected {
|
||||
theme.accent
|
||||
} else if enabled {
|
||||
theme.text_primary
|
||||
} else {
|
||||
theme.text_muted
|
||||
});
|
||||
let mut content = row![accented_icon(
|
||||
source,
|
||||
metrics.icon_size,
|
||||
foreground,
|
||||
color(theme.accent)
|
||||
)]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
if kind == Menu::Recording
|
||||
&& let Some(elapsed) = self.recorder.elapsed()
|
||||
{
|
||||
content = content.push(
|
||||
text(elapsed)
|
||||
.size(metrics.text_size)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
if kind == Menu::Battery
|
||||
&& self.config.status.battery_percentage
|
||||
&& let Some(battery) = &self.status.battery
|
||||
{
|
||||
content = content.push(
|
||||
text(format!("{}%", battery.percent))
|
||||
.size(metrics.text_size)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
if kind == Menu::Notifications
|
||||
&& self
|
||||
.status
|
||||
.notifications
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.count > 0 && !n.dnd)
|
||||
{
|
||||
content = content.push(container(text("")).width(4).height(4).class(theme::Container::custom(
|
||||
move |_| container::Style {
|
||||
if kind == Menu::Battery
|
||||
&& battery_percentage
|
||||
&& let Some(battery) = &self.status.battery
|
||||
{
|
||||
content = content.push(
|
||||
text(format!("{}%", battery.percent))
|
||||
.size(metrics.text_size)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
if kind == Menu::Notifications
|
||||
&& self
|
||||
.status
|
||||
.notifications
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.count > 0 && !n.dnd)
|
||||
{
|
||||
content = content.push(
|
||||
container(text(""))
|
||||
.width(4)
|
||||
.height(4)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(foreground)),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
@@ -93,50 +105,40 @@ impl FereseShell {
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})),
|
||||
);
|
||||
}
|
||||
let content = bar_content(content, metrics.group_item_height);
|
||||
let recording_busy = kind == Menu::Recording && self.recorder.busy();
|
||||
let control = button::custom(content)
|
||||
.name(if recording_busy {
|
||||
self.recorder.label().into()
|
||||
} else if kind == Menu::Recording {
|
||||
"Record a display".into()
|
||||
} else {
|
||||
status_label(kind, &self.status)
|
||||
})
|
||||
.padding([0.0, ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0)])
|
||||
.height(metrics.group_item_height)
|
||||
.on_press_with_rectangle(move |offset, bounds| {
|
||||
if kind == Menu::Recording {
|
||||
return cosmic::Action::App(if recording_busy {
|
||||
Message::StopRecording
|
||||
} else {
|
||||
Message::StartRecording
|
||||
});
|
||||
}
|
||||
cosmic::Action::App(Message::OpenMenu(
|
||||
kind,
|
||||
Rectangle {
|
||||
x: (bounds.x - offset.x).round() as i32,
|
||||
y: (bounds.y - offset.y).round() as i32,
|
||||
width: bounds.width.round() as i32,
|
||||
height: bounds.height.round() as i32,
|
||||
},
|
||||
)));
|
||||
}
|
||||
let content = bar_content(content, metrics.group_item_height);
|
||||
let recording_busy = kind == Menu::Recording && self.recorder.busy();
|
||||
let control = button::custom(content)
|
||||
.name(if recording_busy {
|
||||
self.recorder.label().into()
|
||||
} else if kind == Menu::Recording {
|
||||
"Record a display".into()
|
||||
} else {
|
||||
status_label(kind, &self.status)
|
||||
})
|
||||
.padding([0.0, ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0)])
|
||||
.height(metrics.group_item_height)
|
||||
.on_press_with_rectangle(move |offset, bounds| {
|
||||
if kind == Menu::Recording {
|
||||
return cosmic::Action::App(if recording_busy {
|
||||
Message::StopRecording
|
||||
} else {
|
||||
Message::StartRecording
|
||||
});
|
||||
}
|
||||
cosmic::Action::App(Message::OpenMenu(
|
||||
kind,
|
||||
Rectangle {
|
||||
x: (bounds.x - offset.x).round() as i32,
|
||||
y: (bounds.y - offset.y).round() as i32,
|
||||
width: bounds.width.round() as i32,
|
||||
height: bounds.height.round() as i32,
|
||||
},
|
||||
))
|
||||
});
|
||||
controls = controls.push(motion::button(control, foreground, selected, 1.0));
|
||||
}
|
||||
if islands {
|
||||
return controls.into();
|
||||
}
|
||||
|
||||
container(controls)
|
||||
.padding([2, 3])
|
||||
.height(metrics.group_height)
|
||||
.class(theme::Container::custom(move |_| crate::bar::bar_group_style(theme)))
|
||||
.into()
|
||||
))
|
||||
});
|
||||
motion::button(control, foreground, selected, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +162,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
|
||||
_ => "Notifications: unavailable".into(),
|
||||
},
|
||||
Menu::Calendar => "Calendar".into(),
|
||||
Menu::Overflow => "More panel items".into(),
|
||||
Menu::Recording => "Screen recording".into(),
|
||||
Menu::Media => "Now playing".into(),
|
||||
Menu::Battery => s.battery.as_ref().map_or_else(
|
||||
@@ -207,6 +210,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
||||
(audio_icon(a.volume, a.muted), !a.muted && a.volume > 0)
|
||||
}),
|
||||
Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
|
||||
Menu::Overflow => (ferese_theme::icons::CHEVRON_DOWN, true),
|
||||
Menu::Recording => (ferese_theme::icons::RECORD, true),
|
||||
Menu::Media => (ferese_theme::icons::MEDIA, true),
|
||||
Menu::Battery => {
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use cosmic::app::Task;
|
||||
use cosmic::iced::Rectangle;
|
||||
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
|
||||
use cosmic::iced::{Limits, Rectangle, window};
|
||||
use cosmic::iced::{Limits, window};
|
||||
use wayland_client::Proxy;
|
||||
|
||||
use super::{Menu, OpenMenu};
|
||||
use super::{Menu, OpenMenu, PopoverAnchor};
|
||||
use crate::status::Action;
|
||||
use crate::{FereseShell, Message};
|
||||
|
||||
fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_config::panel::Edge) -> SctkPositioner {
|
||||
// xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars.
|
||||
let (anchor, gravity, gap) = match edge {
|
||||
ferese_config::panel::Edge::Top => (2u32, 6u32, 8),
|
||||
ferese_config::panel::Edge::Bottom => (1u32, 5u32, -8),
|
||||
};
|
||||
SctkPositioner {
|
||||
anchor_rect: rectangle,
|
||||
anchor: anchor.try_into().unwrap(),
|
||||
gravity: gravity.try_into().unwrap(),
|
||||
offset: (rectangle.width / 2, gap),
|
||||
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
|
||||
constraint_adjustment: 3,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenMenu {
|
||||
fn switch_panel(&mut self, kind: Menu, settings: crate::motion::Settings, now: Instant) {
|
||||
self.kind = kind;
|
||||
@@ -20,8 +38,26 @@ impl OpenMenu {
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> {
|
||||
if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) {
|
||||
pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> {
|
||||
if !anchor.belongs_to(&self.config.panels[0])
|
||||
|| !self.outputs.iter().any(|output| output.bar == anchor.parent)
|
||||
|| self.bar_hidden(anchor.parent)
|
||||
|| !kind.available(&self.status)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.anchor.parent != anchor.parent)
|
||||
{
|
||||
let close = self.destroy_menu();
|
||||
let open = self.open_menu(kind, anchor);
|
||||
return close.chain(open);
|
||||
}
|
||||
if self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.kind == kind && menu.anchor.same_item(&anchor) && menu.anchor.rectangle == anchor.rectangle
|
||||
}) {
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.motion.closing()
|
||||
{
|
||||
@@ -66,42 +102,16 @@ impl FereseShell {
|
||||
let notifications = self.sync_notification_surface();
|
||||
crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
self.status_error = None;
|
||||
let parent = self.bar_surface_id;
|
||||
let anchor = if kind == Menu::System {
|
||||
self.outputs
|
||||
.iter()
|
||||
.find(|output| output.bar == parent)
|
||||
.and_then(|output| output.size)
|
||||
.map(|(width, _)| {
|
||||
let theme = self.config.theme;
|
||||
Rectangle {
|
||||
x: (width - theme.bar_margin_horizontal * 2 - theme.panel_padding.round() as i32 - 1).max(0),
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: theme.bar_height.round() as i32,
|
||||
}
|
||||
})
|
||||
.unwrap_or(anchor)
|
||||
} else {
|
||||
anchor
|
||||
};
|
||||
let parent = anchor.parent;
|
||||
let rectangle = anchor.rectangle;
|
||||
let height_limit = if kind == Menu::Notifications {
|
||||
self.notification_history_height_limit()
|
||||
} else {
|
||||
kind.height_limit()
|
||||
};
|
||||
let positioner = SctkPositioner {
|
||||
anchor_rect: anchor,
|
||||
anchor: 2u32.try_into().unwrap(),
|
||||
gravity: 6u32.try_into().unwrap(),
|
||||
offset: (anchor.width / 2, 8),
|
||||
// The view's autosize widget supplies the active panel's bounds.
|
||||
// Keep initial popup limits broad enough for later panel changes.
|
||||
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
|
||||
constraint_adjustment: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let positioner = menu_positioner(rectangle, height_limit, self.config.panels[0].edge);
|
||||
if let Some(menu) = &mut self.menu {
|
||||
menu.anchor = anchor;
|
||||
menu.switch_panel(kind, self.config.animations, Instant::now());
|
||||
return Task::batch([
|
||||
notifications,
|
||||
@@ -111,6 +121,7 @@ impl FereseShell {
|
||||
let id = window::Id::unique();
|
||||
self.menu = Some(OpenMenu {
|
||||
id,
|
||||
anchor,
|
||||
kind,
|
||||
motion: crate::motion::PopupMotion::new(self.config.animations),
|
||||
effects: None,
|
||||
@@ -133,9 +144,10 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub fn destroy_menu(&mut self) -> Task<Message> {
|
||||
let tooltip = self.dismiss_workspace_tooltip();
|
||||
crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some(menu) = self.menu.take() else {
|
||||
return Task::none();
|
||||
return tooltip;
|
||||
};
|
||||
self.refresh_media_art(false);
|
||||
if menu.kind == Menu::Notifications {
|
||||
@@ -143,6 +155,7 @@ impl FereseShell {
|
||||
self.notifications.hovered = None;
|
||||
}
|
||||
Task::batch([
|
||||
tooltip,
|
||||
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(menu.id))),
|
||||
self.sync_notification_surface(),
|
||||
])
|
||||
@@ -218,12 +231,88 @@ mod tests {
|
||||
use crate::motion::{PopupMotion, Settings};
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_anchor() -> PopoverAnchor {
|
||||
PopoverAnchor {
|
||||
parent: window::Id::unique(),
|
||||
panel: crate::panel::PanelId("main".into()),
|
||||
item: Some(crate::panel::ItemId("battery".into())),
|
||||
rectangle: Rectangle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_same_control_at_new_bounds_repositions_instead_of_toggling() {
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
shell.status.network = Some(crate::status::Network {
|
||||
enabled: true,
|
||||
connection: Some("Test network".into()),
|
||||
signal: 90,
|
||||
});
|
||||
let bar = shell.outputs[0].bar;
|
||||
let mut anchor = PopoverAnchor {
|
||||
parent: bar,
|
||||
panel: shell.config.panels[0].id.clone(),
|
||||
item: Some(crate::panel::ItemId("network".into())),
|
||||
rectangle: Rectangle {
|
||||
x: 1000,
|
||||
y: 0,
|
||||
width: 60,
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
drop(shell.open_menu(Menu::Network, anchor.clone()));
|
||||
let popup = shell.menu.as_ref().unwrap().id;
|
||||
anchor.rectangle.x = 800;
|
||||
drop(shell.open_menu(Menu::Network, anchor.clone()));
|
||||
let menu = shell.menu.as_ref().unwrap();
|
||||
assert_eq!(menu.id, popup);
|
||||
assert_eq!(menu.anchor, anchor);
|
||||
assert!(!menu.motion.closing());
|
||||
drop(shell.open_menu(Menu::Network, anchor));
|
||||
assert!(shell.menu.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_open_toward_the_desktop_from_either_panel_edge() {
|
||||
use ferese_config::panel::Edge;
|
||||
let rectangle = Rectangle {
|
||||
x: 400,
|
||||
y: 0,
|
||||
width: 32,
|
||||
height: 30,
|
||||
};
|
||||
for (edge, anchor, gravity, gap) in [(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)] {
|
||||
let positioner = menu_positioner(rectangle, 620., edge);
|
||||
assert_eq!(positioner.anchor_rect, rectangle);
|
||||
assert_eq!(positioner.anchor, anchor.try_into().unwrap());
|
||||
assert_eq!(positioner.gravity, gravity.try_into().unwrap());
|
||||
assert_eq!(positioner.offset, (16, gap));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_identity_includes_parent_panel_and_instance_but_not_rectangle() {
|
||||
let first = test_anchor();
|
||||
let mut second = first.clone();
|
||||
second.rectangle.x = 100;
|
||||
assert!(first.same_item(&second));
|
||||
second.item = Some(crate::panel::ItemId("other-battery".into()));
|
||||
assert!(!first.same_item(&second));
|
||||
second = first.clone();
|
||||
second.parent = window::Id::unique();
|
||||
assert!(!first.same_item(&second));
|
||||
second = first.clone();
|
||||
second.panel = crate::panel::PanelId("other".into());
|
||||
assert!(!first.same_item(&second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_panels_replays_entrance_on_the_same_surface() {
|
||||
let now = Instant::now();
|
||||
let settings = Settings::default();
|
||||
let mut menu = OpenMenu {
|
||||
id: window::Id::unique(),
|
||||
anchor: test_anchor(),
|
||||
kind: Menu::Battery,
|
||||
motion: PopupMotion::new(settings),
|
||||
effects: None,
|
||||
@@ -268,6 +357,7 @@ mod tests {
|
||||
let now = Instant::now();
|
||||
let mut menu = OpenMenu {
|
||||
id: window::Id::unique(),
|
||||
anchor: test_anchor(),
|
||||
kind: Menu::Battery,
|
||||
motion: PopupMotion::new(Settings::default()),
|
||||
effects: None,
|
||||
@@ -299,4 +389,35 @@ mod tests {
|
||||
);
|
||||
assert_eq!(menu.motion.progress_at(now + Duration::from_millis(60)), normal);
|
||||
}
|
||||
#[test]
|
||||
fn popup_anchors_reject_removed_hidden_items_and_other_panel_definitions() {
|
||||
let mut panel = crate::panel::Panel::default();
|
||||
let anchor = PopoverAnchor {
|
||||
parent: window::Id::unique(),
|
||||
panel: panel.id.clone(),
|
||||
item: Some(crate::panel::ItemId("network".into())),
|
||||
rectangle: Rectangle {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
};
|
||||
assert!(anchor.belongs_to(&panel));
|
||||
panel.end.groups[0].items[2].visible = false;
|
||||
assert!(!anchor.belongs_to(&panel));
|
||||
panel.end.groups[0].items.remove(2);
|
||||
assert!(!anchor.belongs_to(&panel));
|
||||
panel.id = crate::panel::PanelId("other".into());
|
||||
let fallback = PopoverAnchor {
|
||||
item: None,
|
||||
..anchor.clone()
|
||||
};
|
||||
assert!(!fallback.belongs_to(&panel));
|
||||
let fallback = PopoverAnchor {
|
||||
panel: panel.id.clone(),
|
||||
..fallback
|
||||
};
|
||||
assert!(fallback.belongs_to(&panel));
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,13 @@ pub(super) fn bar<'a>(
|
||||
shell: &'a FereseShell,
|
||||
palette: ShellTheme,
|
||||
metrics: BarMetrics,
|
||||
representation: crate::panel::Representation,
|
||||
selected: bool,
|
||||
width: Option<f32>,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let Some(player) = &shell.media.snapshot.selected else {
|
||||
return text("").into();
|
||||
};
|
||||
let selected = shell.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Media);
|
||||
let foreground = color(if selected { palette.accent } else { palette.text_primary });
|
||||
let height = (metrics.group_item_height - 4.0).max(0.0);
|
||||
let side = height.min(24.0);
|
||||
@@ -111,9 +113,21 @@ pub(super) fn bar<'a>(
|
||||
.height(height)
|
||||
.align_y(cosmic::iced::alignment::Vertical::Center)
|
||||
.class(theme::Text::Color(foreground));
|
||||
let label = container(label).max_width(112);
|
||||
let compact = representation == crate::panel::Representation::Compact;
|
||||
let preferred_label_width: f32 = if compact { 112. } else { 320. };
|
||||
// Outer padding, the transport button, details padding, and cover spacing
|
||||
// share the allocation with the label.
|
||||
let chrome = 4. + 2. + f32::from(metrics.icon_size) + 12. + 8. + if compact { 0. } else { side + 8. };
|
||||
let label_width = width.map_or(preferred_label_width, |width| {
|
||||
(width - chrome).max(0.).min(preferred_label_width)
|
||||
});
|
||||
let label = container(label).max_width(label_width);
|
||||
let details = button::custom(bar_content(
|
||||
row![cover, label].spacing(8).align_y(Alignment::Center),
|
||||
if representation == crate::panel::Representation::Compact {
|
||||
row![label].align_y(Alignment::Center)
|
||||
} else {
|
||||
row![cover, label].spacing(8).align_y(Alignment::Center)
|
||||
},
|
||||
height,
|
||||
))
|
||||
.name(format!("Now playing: {}", player.label()))
|
||||
@@ -149,9 +163,10 @@ pub(super) fn bar<'a>(
|
||||
)));
|
||||
}
|
||||
|
||||
let mut bar_style = crate::bar::bar_group_style(palette);
|
||||
bar_style.border = bar_style.border.width(0.0);
|
||||
bar_style.background = Some(super::Background::Color(color_with_opacity(palette.border, 0.30)));
|
||||
let bar_style = ferese_theme::controls::surface_appearance(
|
||||
color_with_opacity(palette.border, 0.30),
|
||||
palette.material_radius.min(16.),
|
||||
);
|
||||
|
||||
let controls = container(
|
||||
row![
|
||||
|
||||
@@ -41,6 +41,7 @@ pub enum Menu {
|
||||
Media,
|
||||
Notifications,
|
||||
System,
|
||||
Overflow,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
@@ -57,7 +58,7 @@ impl Menu {
|
||||
relevant.then_some(error.message.as_str())
|
||||
}
|
||||
|
||||
fn width(self) -> f32 {
|
||||
pub(crate) fn width(self) -> f32 {
|
||||
match self {
|
||||
Self::System => 360.0,
|
||||
Self::Battery => 328.0,
|
||||
@@ -74,6 +75,7 @@ impl Menu {
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Control Center",
|
||||
Self::Overflow => "Panel items",
|
||||
Self::Network => "Wi-Fi",
|
||||
Self::Bluetooth => "Bluetooth",
|
||||
Self::Audio => "Sound",
|
||||
@@ -93,19 +95,43 @@ impl Menu {
|
||||
Self::Battery => status.battery.is_some(),
|
||||
Self::Calendar | Self::Recording | Self::Media => true,
|
||||
Self::Notifications => status.notifications.is_some(),
|
||||
Self::System => true,
|
||||
Self::System | Self::Overflow => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenMenu {
|
||||
pub id: window::Id,
|
||||
pub anchor: PopoverAnchor,
|
||||
pub kind: Menu,
|
||||
pub motion: super::motion::PopupMotion,
|
||||
pub effects: Option<EffectsBinding>,
|
||||
pub regions: super::motion::Regions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct PopoverAnchor {
|
||||
pub parent: window::Id,
|
||||
pub panel: crate::panel::PanelId,
|
||||
// Notifications can be opened from a toast without a panel item.
|
||||
pub item: Option<crate::panel::ItemId>,
|
||||
pub rectangle: cosmic::iced::Rectangle<i32>,
|
||||
}
|
||||
|
||||
impl PopoverAnchor {
|
||||
pub(crate) fn belongs_to(&self, panel: &crate::panel::Panel) -> bool {
|
||||
self.panel == panel.id
|
||||
&& self
|
||||
.item
|
||||
.as_ref()
|
||||
.is_none_or(|id| id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible))
|
||||
}
|
||||
|
||||
pub fn same_item(&self, other: &Self) -> bool {
|
||||
self.parent == other.parent && self.panel == other.panel && self.item == other.item
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenMenu {
|
||||
pub(super) fn prepare_surface(&mut self, surface: &wl_surface::WlSurface) {
|
||||
if self.effects.is_none() {
|
||||
@@ -219,6 +245,7 @@ impl FereseShell {
|
||||
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
|
||||
Menu::Notifications => notification_controls(self, rows, style, false),
|
||||
Menu::Recording => rows,
|
||||
Menu::Overflow => self.view_panel_overflow(rows),
|
||||
Menu::Media => media::view(self, rows, style),
|
||||
};
|
||||
if kind != Menu::Calendar && !kind.available(&self.status) {
|
||||
@@ -237,7 +264,12 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
let compositor_material = menu.effects.is_some();
|
||||
let panel = container(rows)
|
||||
let body: Element<'_, cosmic::Action<Message>> = if kind == Menu::Overflow {
|
||||
cosmic::widget::scrollable(rows).height(Length::Shrink).into()
|
||||
} else {
|
||||
rows.into()
|
||||
};
|
||||
let panel = container(body)
|
||||
.id("ferese-blur-card")
|
||||
.width(kind.width())
|
||||
.padding(16)
|
||||
@@ -258,11 +290,12 @@ impl FereseShell {
|
||||
}));
|
||||
|
||||
cosmic::widget::autosize::autosize(
|
||||
super::motion::animated(
|
||||
super::motion::animated_from_edge(
|
||||
panel.into(),
|
||||
menu.motion.progress_at(now),
|
||||
menu.regions.clone(),
|
||||
theme.material_radius,
|
||||
self.config.panels[0].edge,
|
||||
),
|
||||
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
|
||||
)
|
||||
@@ -378,6 +411,12 @@ mod tests {
|
||||
motion.begin(past);
|
||||
let menu = OpenMenu {
|
||||
id: window::Id::unique(),
|
||||
anchor: PopoverAnchor {
|
||||
parent: window::Id::unique(),
|
||||
panel: crate::panel::PanelId("main".into()),
|
||||
item: None,
|
||||
rectangle: Default::default(),
|
||||
},
|
||||
kind: Menu::Network,
|
||||
motion,
|
||||
effects: None,
|
||||
|
||||
@@ -7,19 +7,38 @@ use super::{
|
||||
registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface,
|
||||
};
|
||||
|
||||
pub(super) fn panel_placement(edge: ferese_config::panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
|
||||
let (anchor, top, bottom) = match edge {
|
||||
ferese_config::panel::Edge::Top => (Anchor::TOP, margin, 0),
|
||||
ferese_config::panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin),
|
||||
};
|
||||
(
|
||||
anchor,
|
||||
IcedMargin {
|
||||
top,
|
||||
right: side,
|
||||
bottom,
|
||||
left: side,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task<Message> {
|
||||
if matches!(event, wayland::OutputEvent::Removed) {
|
||||
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
|
||||
let entry = self.outputs.remove(index);
|
||||
self.refresh_media_art(false);
|
||||
let menu = if self.bar_surface_id == entry.bar {
|
||||
let menu = if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == entry.bar) {
|
||||
self.destroy_menu()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
|
||||
let mut tasks = vec![destroy_layer_surface(entry.bar), menu, self.rebuild_system_modal()];
|
||||
let mut tasks = vec![
|
||||
self.dismiss_workspace_tooltip(),
|
||||
menu.chain(destroy_layer_surface(entry.bar)),
|
||||
self.rebuild_system_modal(),
|
||||
];
|
||||
if self.note_drag.as_ref().is_some_and(|drag| {
|
||||
entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source)
|
||||
}) {
|
||||
@@ -57,6 +76,7 @@ impl FereseShell {
|
||||
};
|
||||
|
||||
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) {
|
||||
let resized = size.is_some() && entry.size != size;
|
||||
if size.is_some() {
|
||||
entry.size = size;
|
||||
}
|
||||
@@ -66,18 +86,37 @@ impl FereseShell {
|
||||
entry.name = name;
|
||||
}
|
||||
|
||||
return if changed {
|
||||
Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)])
|
||||
let bar = entry.bar;
|
||||
let margin = self.update_panel_margin(bar, false);
|
||||
let popup = if resized && self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) {
|
||||
self.destroy_menu()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let tooltip = if resized {
|
||||
self.dismiss_workspace_tooltip_for_bar(bar)
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
return if changed {
|
||||
Task::batch([
|
||||
margin,
|
||||
popup,
|
||||
tooltip,
|
||||
self.rebuild_clocks(true),
|
||||
self.rebuild_notes(true),
|
||||
])
|
||||
} else {
|
||||
Task::batch([margin, popup, tooltip])
|
||||
};
|
||||
}
|
||||
|
||||
let bar_surface_id = window::Id::unique();
|
||||
let wallpaper_surface_id = window::Id::unique();
|
||||
let shell_theme = self.config.theme;
|
||||
let bar_layout = self.config.status.bar_layout;
|
||||
let bar = BarMetrics::from(shell_theme);
|
||||
let geometry = self.config.panels[0].geometry;
|
||||
let panel_surface = self.config.panels[0].surface;
|
||||
let (edge_anchor, margin) = panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||
let bar = BarMetrics::from(geometry);
|
||||
let wallpaper_output = output.clone();
|
||||
let bar_output = output.clone();
|
||||
let hidden = output_bar_hidden(
|
||||
@@ -97,6 +136,8 @@ impl FereseShell {
|
||||
.then_some(wallpaper_surface_id),
|
||||
effects: None,
|
||||
bar_regions: Vec::new(),
|
||||
panel_resolution: Default::default(),
|
||||
bar_margin_horizontal: 0,
|
||||
clock: None,
|
||||
notes: Vec::new(),
|
||||
size,
|
||||
@@ -126,23 +167,18 @@ impl FereseShell {
|
||||
|_| Default::default(),
|
||||
move |_| SctkLayerSurfaceSettings {
|
||||
id: bar_surface_id,
|
||||
input_zone: super::bar::input_region(bar_layout, hidden, &[]),
|
||||
input_zone: super::bar::input_region(panel_surface, hidden, &[]),
|
||||
layer: Layer::Top,
|
||||
keyboard_interactivity: KeyboardInteractivity::None,
|
||||
anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT,
|
||||
anchor: edge_anchor | Anchor::LEFT | Anchor::RIGHT,
|
||||
output: IcedOutput::Output(bar_output.clone()),
|
||||
namespace: "ferese-shell-top-bar".to_owned(),
|
||||
margin: IcedMargin {
|
||||
top: shell_theme.bar_margin_top,
|
||||
right: shell_theme.bar_margin_horizontal,
|
||||
bottom: 0,
|
||||
left: shell_theme.bar_margin_horizontal,
|
||||
},
|
||||
// Measure at full width before applying the requested side inset.
|
||||
margin,
|
||||
size: Some((None, Some(bar.height.round() as u32))),
|
||||
size_limits: Limits::NONE,
|
||||
// Reserve breathing room below the visible bar; layer-shell
|
||||
// accounts for the top margin separately.
|
||||
exclusive_zone: (bar.height.round() as i32).saturating_add(shell_theme.bar_window_gap),
|
||||
// Reserve space toward the desktop; layer-shell accounts for the edge margin.
|
||||
exclusive_zone: (bar.height.round() as i32).saturating_add(geometry.window_clearance),
|
||||
},
|
||||
Some(Box::new(move |app| app.view_layer(bar_surface_id))),
|
||||
);
|
||||
@@ -168,6 +204,9 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
|
||||
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorLeft)) {
|
||||
return self.dismiss_workspace_tooltip_for_bar(id);
|
||||
}
|
||||
if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event
|
||||
&& let Some(modal) = &mut self.system_modal
|
||||
&& modal.contains(id)
|
||||
@@ -319,6 +358,10 @@ impl FereseShell {
|
||||
..
|
||||
}) if self.menu.is_some() => self.close_menu(),
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => {
|
||||
if self.workspace_ui.tooltip == Some(popup_id) && matches!(event, wayland::PopupEvent::Done) {
|
||||
self.workspace_ui.tooltip = None;
|
||||
return self.dismiss_workspace_tooltip();
|
||||
}
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.id == popup_id
|
||||
{
|
||||
@@ -345,6 +388,10 @@ impl FereseShell {
|
||||
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||
Task::none()
|
||||
}
|
||||
Event::Window(window::Event::Closed) if self.workspace_ui.tooltip == Some(id) => {
|
||||
self.workspace_ui.tooltip = None;
|
||||
self.dismiss_workspace_tooltip()
|
||||
}
|
||||
Event::Window(window::Event::Closed)
|
||||
if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) =>
|
||||
{
|
||||
@@ -398,7 +445,7 @@ impl FereseShell {
|
||||
|
||||
pub(super) fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
|
||||
let hidden = self.bar_hidden(id);
|
||||
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands;
|
||||
let islands = self.config.panels[0].surface == ferese_config::panel::PanelSurface::None;
|
||||
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else {
|
||||
return;
|
||||
};
|
||||
@@ -490,6 +537,17 @@ fn encode_regions(regions: &[[f32; 5]]) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_region_counts(regions: usize, opacities: usize) -> Result<(), std::io::Error> {
|
||||
let limit = ferese_protocols::effects::v1::MAX_REGIONS;
|
||||
if regions > limit || opacities > limit {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("effects presentation exceeds the {limit}-region limit"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
|
||||
|
||||
pub(super) struct EffectsBinding {
|
||||
@@ -556,9 +614,9 @@ impl EffectsBinding {
|
||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
let values: Vec<u32> = region_opacities
|
||||
.into_iter()
|
||||
.take(32)
|
||||
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
|
||||
.collect();
|
||||
validate_region_counts(regions.len(), values.len())?;
|
||||
let next = (regions.to_vec(), opacity, values, role);
|
||||
if self.presentation.borrow().as_ref() != Some(&next) {
|
||||
self.surface.set_presentation(
|
||||
@@ -582,6 +640,7 @@ impl EffectsBinding {
|
||||
regions: &[[f32; 5]],
|
||||
role: ferese_surface_effects_v1::Role,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
validate_region_counts(regions.len(), 0)?;
|
||||
if self.regions.borrow().as_deref() == Some(regions) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -638,6 +697,15 @@ delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
|
||||
|
||||
#[cfg(test)]
|
||||
mod region_encoding_tests {
|
||||
#[test]
|
||||
fn oversized_presentations_are_rejected_without_truncation() {
|
||||
let limit = ferese_protocols::effects::v1::MAX_REGIONS;
|
||||
assert!(super::validate_region_counts(0, 0).is_ok());
|
||||
assert!(super::validate_region_counts(limit, limit).is_ok());
|
||||
assert!(super::validate_region_counts(limit + 1, limit).is_err());
|
||||
assert!(super::validate_region_counts(limit, limit + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_region_encoding_preserves_values() {
|
||||
let regions = [[0.25, -0.75, 100.5, 40.25, 14.0]];
|
||||
|
||||
@@ -170,7 +170,15 @@ impl FereseShell {
|
||||
.iter()
|
||||
.position(|output| output.name.as_deref() == Some(name))
|
||||
})
|
||||
.or_else(|| self.outputs.iter().position(|output| output.bar == self.bar_surface_id))
|
||||
.or_else(|| {
|
||||
self.menu
|
||||
.as_ref()
|
||||
.and_then(|menu| self.outputs.iter().position(|output| output.bar == menu.anchor.parent))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.active_bar()
|
||||
.and_then(|bar| self.outputs.iter().position(|output| output.bar == bar))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut tasks = vec![
|
||||
self.destroy_system_modal(true),
|
||||
@@ -536,9 +544,10 @@ impl FereseShell {
|
||||
row![
|
||||
Space::new().width(Length::Fill),
|
||||
ferese_theme::controls::text_button("Cancel", shell_font(), palette, false)
|
||||
.class(ferese_theme::controls::button_style_with_focus(
|
||||
.class(ferese_theme::controls::material_button_style_with_focus(
|
||||
palette,
|
||||
false,
|
||||
theme.material_opacity(),
|
||||
modal.focus_visible,
|
||||
))
|
||||
.id("ferese-modal-cancel".into())
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{Element, Message, container, text};
|
||||
use cosmic::app::Task;
|
||||
use cosmic::iced::advanced::widget::{Id, Operation, operation};
|
||||
use cosmic::iced::{Rectangle, window};
|
||||
use cosmic::widget::icon;
|
||||
use freedesktop_desktop_entry as fde;
|
||||
|
||||
use crate::{FereseShell, ShellSnapshot, motion};
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn update_workspace_ui(&mut self) -> Task<Message> {
|
||||
let needs_icons = [
|
||||
&self.config.panels[0].start,
|
||||
&self.config.panels[0].center,
|
||||
&self.config.panels[0].end,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.any(|item| {
|
||||
item.visible
|
||||
&& matches!(
|
||||
item.kind,
|
||||
crate::panel::ItemKind::Workspaces {
|
||||
style: ferese_config::panel::WorkspaceStyle::AppIcons
|
||||
}
|
||||
)
|
||||
});
|
||||
self.workspace_ui.update(&self.snapshot, self.config.animations);
|
||||
let icons = self.workspace_ui.load_icons(&self.snapshot, needs_icons);
|
||||
let tooltip = if self.workspace_ui.hovered.as_ref().is_some_and(|hover| {
|
||||
!self
|
||||
.snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| workspace.id == hover.workspace)
|
||||
}) {
|
||||
self.dismiss_workspace_tooltip()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
Task::batch([icons, tooltip])
|
||||
}
|
||||
|
||||
pub(super) fn workspace_icons_loaded(&mut self, result: Result<IconIndex, String>) -> Task<Message> {
|
||||
self.workspace_ui.icons_loading = false;
|
||||
match result {
|
||||
Ok(index) => {
|
||||
self.workspace_ui.entries = Some(index.entries);
|
||||
self.workspace_ui.icons.extend(index.icons);
|
||||
// Windows may have changed while discovery was in flight.
|
||||
self.update_workspace_ui()
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: could not load workspace icons: {error}");
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct IconIndex {
|
||||
entries: Arc<Vec<fde::DesktopEntry>>,
|
||||
icons: HashMap<String, icon::Handle>,
|
||||
}
|
||||
|
||||
fn discover_icons(entries: Option<Arc<Vec<fde::DesktopEntry>>>, app_ids: Vec<String>) -> IconIndex {
|
||||
let entries = entries.unwrap_or_else(|| {
|
||||
let mut seen = HashSet::new();
|
||||
Arc::new(
|
||||
fde::Iter::new(fde::default_paths())
|
||||
.entries::<&str>(None)
|
||||
.filter(|entry| seen.insert(entry.appid.clone()))
|
||||
.filter(|entry| !entry.hidden())
|
||||
.collect(),
|
||||
)
|
||||
});
|
||||
let icons = app_ids
|
||||
.into_iter()
|
||||
.map(|app_id| {
|
||||
let entry = fde::find_app_by_id(&entries, fde::unicase::Ascii::new(app_id.as_str()));
|
||||
let source = entry
|
||||
.and_then(fde::DesktopEntry::icon)
|
||||
.unwrap_or("application-x-executable");
|
||||
let handle = if source.starts_with('/') {
|
||||
icon::from_path(source.into())
|
||||
} else {
|
||||
icon::from_name(source)
|
||||
.prefer_svg(true)
|
||||
.size(32)
|
||||
.fallback(Some(icon::IconFallback::Names(vec!["application-x-executable".into()])))
|
||||
.handle()
|
||||
};
|
||||
(app_id, handle)
|
||||
})
|
||||
.collect();
|
||||
IconIndex { entries, icons }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct WorkspaceUi {
|
||||
entries: Option<Arc<Vec<fde::DesktopEntry>>>,
|
||||
icons: HashMap<String, icon::Handle>,
|
||||
icons_loading: bool,
|
||||
transitions: HashMap<u64, Transition>,
|
||||
hovered: Option<Hover>,
|
||||
pub(super) tooltip: Option<window::Id>,
|
||||
serial: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Hover {
|
||||
bar: window::Id,
|
||||
workspace: u64,
|
||||
target: Id,
|
||||
serial: u64,
|
||||
}
|
||||
|
||||
impl WorkspaceUi {
|
||||
pub(super) fn hover_target(&self, bar: window::Id) -> Option<(u64, Id)> {
|
||||
self.hovered
|
||||
.as_ref()
|
||||
.filter(|hover| hover.bar == bar)
|
||||
.map(|hover| (hover.workspace, hover.target.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn hovering(&self, bar: window::Id, target: &Id) -> bool {
|
||||
self.hovered
|
||||
.as_ref()
|
||||
.is_some_and(|hover| hover.bar == bar && &hover.target == target)
|
||||
}
|
||||
}
|
||||
|
||||
struct FindBounds {
|
||||
target: Id,
|
||||
bar: window::Id,
|
||||
window: Option<window::Id>,
|
||||
found: Option<Rectangle>,
|
||||
}
|
||||
|
||||
impl Operation<Option<Rectangle>> for FindBounds {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<Option<Rectangle>>)) {
|
||||
operate(self);
|
||||
}
|
||||
fn set_window_id(&mut self, id: window::Id) {
|
||||
self.window = Some(id);
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
|
||||
if id == Some(&self.target) && self.window == Some(self.bar) {
|
||||
self.found = Some(bounds);
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<Option<Rectangle>> {
|
||||
operation::Outcome::Some(self.found)
|
||||
}
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn dismiss_workspace_tooltip_for_bar(&mut self, bar: window::Id) -> Task<Message> {
|
||||
if self.workspace_ui.hovered.as_ref().is_some_and(|hover| hover.bar == bar) {
|
||||
self.dismiss_workspace_tooltip()
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dismiss_workspace_tooltip(&mut self) -> Task<Message> {
|
||||
self.workspace_ui.serial = self.workspace_ui.serial.wrapping_add(1);
|
||||
self.workspace_ui.hovered = None;
|
||||
self.workspace_ui.tooltip.take().map_or_else(Task::none, |id| {
|
||||
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(id)))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn hover_workspace(
|
||||
&mut self,
|
||||
bar: window::Id,
|
||||
workspace: u64,
|
||||
target: Id,
|
||||
entered: bool,
|
||||
) -> Task<Message> {
|
||||
if !entered {
|
||||
return if self.workspace_ui.hovering(bar, &target) {
|
||||
self.dismiss_workspace_tooltip()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
}
|
||||
if self.workspace_ui.hovering(bar, &target) {
|
||||
return Task::none();
|
||||
}
|
||||
let close = self.dismiss_workspace_tooltip();
|
||||
if self.menu.is_some() || self.system_modal.is_some() || self.overview_active {
|
||||
return close;
|
||||
}
|
||||
let serial = self.workspace_ui.serial;
|
||||
self.workspace_ui.hovered = Some(Hover {
|
||||
bar,
|
||||
workspace,
|
||||
target,
|
||||
serial,
|
||||
});
|
||||
close.chain(Task::perform(
|
||||
async { tokio::time::sleep(Duration::from_millis(300)).await },
|
||||
move |_| cosmic::Action::App(Message::WorkspaceTooltipDelay(serial)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn locate_workspace_tooltip(&self, serial: u64) -> Task<Message> {
|
||||
let Some(hover) = self
|
||||
.workspace_ui
|
||||
.hovered
|
||||
.as_ref()
|
||||
.filter(|hover| hover.serial == serial)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
cosmic::iced::advanced::widget::operate(FindBounds {
|
||||
target: hover.target.clone(),
|
||||
bar: hover.bar,
|
||||
window: None,
|
||||
found: None,
|
||||
})
|
||||
.map(move |bounds| cosmic::Action::App(Message::WorkspaceTooltipBounds(serial, bounds)))
|
||||
}
|
||||
|
||||
pub(super) fn show_workspace_tooltip(&mut self, serial: u64, bounds: Option<Rectangle>) -> Task<Message> {
|
||||
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
|
||||
let Some(hover) = self
|
||||
.workspace_ui
|
||||
.hovered
|
||||
.as_ref()
|
||||
.filter(|hover| hover.serial == serial)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(bounds) = bounds.filter(|bounds| bounds.width > 0. && bounds.height > 0.) else {
|
||||
return Task::none();
|
||||
};
|
||||
if self.workspace_ui.tooltip.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
if self.bar_hidden(hover.bar) || self.menu.is_some() || self.system_modal.is_some() || self.overview_active {
|
||||
return self.dismiss_workspace_tooltip();
|
||||
}
|
||||
if !self
|
||||
.snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| workspace.id == hover.workspace)
|
||||
{
|
||||
return self.dismiss_workspace_tooltip();
|
||||
}
|
||||
let id = window::Id::unique();
|
||||
let parent = hover.bar;
|
||||
let (anchor, gravity, gap) = match self.config.panels[0].edge {
|
||||
ferese_config::panel::Edge::Top => (2u32, 2u32, 6),
|
||||
ferese_config::panel::Edge::Bottom => (1u32, 1u32, -6),
|
||||
};
|
||||
self.workspace_ui.tooltip = Some(id);
|
||||
let action = cosmic::surface::action::app_popup::<Self>(
|
||||
|_| Default::default(),
|
||||
move |_| SctkPopupSettings {
|
||||
id,
|
||||
parent,
|
||||
parent_size: None,
|
||||
grab: false,
|
||||
close_with_children: false,
|
||||
input_zone: Some(Vec::new()),
|
||||
positioner: SctkPositioner {
|
||||
anchor_rect: Rectangle {
|
||||
x: bounds.x.round() as i32,
|
||||
y: bounds.y.round() as i32,
|
||||
width: bounds.width.round() as i32,
|
||||
height: bounds.height.round() as i32,
|
||||
},
|
||||
anchor: anchor.try_into().unwrap(),
|
||||
gravity: gravity.try_into().unwrap(),
|
||||
offset: (0, gap),
|
||||
constraint_adjustment: 3,
|
||||
size_limits: cosmic::iced::Limits::NONE.max_width(320.).max_height(64.),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
Some(Box::new(Self::view_workspace_tooltip)),
|
||||
);
|
||||
cosmic::task::message(cosmic::Action::Surface(action))
|
||||
}
|
||||
|
||||
fn view_workspace_tooltip(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(workspace) = self.workspace_ui.hovered.as_ref().and_then(|hover| {
|
||||
self.snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.id == hover.workspace)
|
||||
}) else {
|
||||
return text("").into();
|
||||
};
|
||||
let active = self
|
||||
.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| output.active_workspace == workspace.id);
|
||||
let label = ferese_theme::workspaces::tooltip_label(&ferese_theme::workspaces::Workspace {
|
||||
name: (&workspace.name).into(),
|
||||
index: workspace.index,
|
||||
window_count: workspace.window_count,
|
||||
active,
|
||||
visible_elsewhere: false,
|
||||
});
|
||||
cosmic::widget::autosize::autosize(
|
||||
container(
|
||||
text(label)
|
||||
.size(11)
|
||||
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
||||
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
|
||||
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
|
||||
))
|
||||
.class(cosmic::theme::Text::Color(crate::color(self.config.theme.text_primary))),
|
||||
)
|
||||
.padding([8, 10])
|
||||
.class(ferese_theme::controls::surface(
|
||||
crate::color(self.config.theme.surface_popover),
|
||||
self.config.theme.material_radius.min(10.),
|
||||
)),
|
||||
Id::new("ferese-workspace-tooltip"),
|
||||
)
|
||||
.limits(cosmic::iced::Limits::NONE.max_width(320.).max_height(64.))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Transition {
|
||||
from: f32,
|
||||
to: f32,
|
||||
started: Instant,
|
||||
duration: Duration,
|
||||
}
|
||||
|
||||
impl Transition {
|
||||
pub(super) fn progress(self, now: Instant) -> f32 {
|
||||
if self.duration.is_zero() {
|
||||
return self.to;
|
||||
}
|
||||
|
||||
let t = (now.saturating_duration_since(self.started).as_secs_f32() / self.duration.as_secs_f32()).min(1.);
|
||||
self.from + (self.to - self.from) * t * t * (3. - 2. * t)
|
||||
}
|
||||
|
||||
pub(super) fn active(self, now: Instant) -> bool {
|
||||
self.from != self.to && now.saturating_duration_since(self.started) < self.duration
|
||||
}
|
||||
|
||||
pub(super) fn revision(self) -> Option<Instant> {
|
||||
Some(self.started)
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceUi {
|
||||
pub(super) fn update(&mut self, snapshot: &ShellSnapshot, settings: motion::Settings) {
|
||||
self.update_at(snapshot, settings, Instant::now());
|
||||
}
|
||||
|
||||
fn update_at(&mut self, snapshot: &ShellSnapshot, settings: motion::Settings, now: Instant) {
|
||||
let duration = if settings.motion_enabled() {
|
||||
Duration::from_secs_f64(0.16 / settings.speed)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
|
||||
self.transitions
|
||||
.retain(|id, _| snapshot.workspaces.iter().any(|workspace| workspace.id == *id));
|
||||
|
||||
for workspace in &snapshot.workspaces {
|
||||
let active = snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| Some(output.id) == workspace.output && output.active_workspace == workspace.id);
|
||||
let target = if active { 1. } else { 0. };
|
||||
let transition = self.transitions.entry(workspace.id).or_insert(Transition {
|
||||
from: target,
|
||||
to: target,
|
||||
started: now,
|
||||
duration,
|
||||
});
|
||||
|
||||
if transition.to != target || transition.duration != duration {
|
||||
*transition = Transition {
|
||||
from: transition.progress(now),
|
||||
to: target,
|
||||
started: now,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_icons(&mut self, snapshot: &ShellSnapshot, needed: bool) -> Task<Message> {
|
||||
if !needed || self.icons_loading {
|
||||
return Task::none();
|
||||
}
|
||||
let app_ids: Vec<_> = snapshot
|
||||
.windows
|
||||
.iter()
|
||||
.map(|window| &window.app_id)
|
||||
.filter(|id| !id.is_empty() && !self.icons.contains_key(*id))
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
if app_ids.is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
self.icons_loading = true;
|
||||
let entries = self.entries.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || discover_icons(entries, app_ids))
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
|result| cosmic::Action::App(Message::WorkspaceIconsLoaded(result)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn transition(&self, workspace: u64, active: bool) -> Transition {
|
||||
self.transitions.get(&workspace).copied().unwrap_or(Transition {
|
||||
from: if active { 1. } else { 0. },
|
||||
to: if active { 1. } else { 0. },
|
||||
started: Instant::now(),
|
||||
duration: Duration::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn apps(&self, workspace: u64, snapshot: &ShellSnapshot) -> Vec<icon::Handle> {
|
||||
snapshot
|
||||
.windows
|
||||
.iter()
|
||||
.filter(|window| window.workspace == workspace && !window.app_id.is_empty())
|
||||
.map(|window| &window.app_id)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.take(2)
|
||||
.map(|id| {
|
||||
self.icons
|
||||
.get(id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::control::{OutputSnapshot, WorkspaceSnapshot};
|
||||
|
||||
fn snapshot(active: u64) -> ShellSnapshot {
|
||||
ShellSnapshot {
|
||||
outputs: vec![OutputSnapshot {
|
||||
id: 4,
|
||||
name: "test".into(),
|
||||
active_workspace: active,
|
||||
focused: true,
|
||||
}],
|
||||
workspaces: [1, 2]
|
||||
.into_iter()
|
||||
.map(|id| WorkspaceSnapshot {
|
||||
id,
|
||||
name: id.to_string(),
|
||||
output: Some(4),
|
||||
index: id as u32,
|
||||
window_count: 0,
|
||||
visible: id == active,
|
||||
focused: id == active,
|
||||
})
|
||||
.collect(),
|
||||
windows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_window(snapshot: &mut ShellSnapshot, app_id: &str) {
|
||||
snapshot.windows.push(crate::control::WindowSnapshot {
|
||||
id: snapshot.windows.len() as u64,
|
||||
workspace: 1,
|
||||
app_id: app_id.into(),
|
||||
title: String::new(),
|
||||
focused: false,
|
||||
urgent: false,
|
||||
fullscreen: false,
|
||||
floating: false,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_is_deferred_and_only_one_request_runs_at_a_time() {
|
||||
let mut ui = WorkspaceUi::default();
|
||||
let mut snapshot = snapshot(1);
|
||||
add_window(&mut snapshot, "new-app");
|
||||
ui.update(&snapshot, motion::Settings::default());
|
||||
assert!(ui.entries.is_none());
|
||||
assert_eq!(ui.load_icons(&snapshot, false).units(), 0);
|
||||
assert!(!ui.icons_loading);
|
||||
assert_eq!(ui.load_icons(&snapshot, true).units(), 1);
|
||||
assert!(ui.icons_loading);
|
||||
assert!(ui.entries.is_none(), "scheduling must not perform filesystem discovery");
|
||||
assert_eq!(ui.apps(1, &snapshot).len(), 1, "new apps have a bundled placeholder");
|
||||
assert_eq!(ui.load_icons(&snapshot, true).units(), 0);
|
||||
assert!(ui.entries.is_none());
|
||||
assert!(ui.icons.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_publication_preserves_existing_icons_and_queues_new_windows() {
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
for group in &mut shell.config.panels[0].start.groups {
|
||||
for item in &mut group.items {
|
||||
if matches!(item.kind, crate::panel::ItemKind::Workspaces { .. }) {
|
||||
item.kind = crate::panel::ItemKind::Workspaces {
|
||||
style: ferese_config::panel::WorkspaceStyle::AppIcons,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
shell.snapshot = snapshot(1);
|
||||
add_window(&mut shell.snapshot, "existing");
|
||||
add_window(&mut shell.snapshot, "discovered");
|
||||
shell
|
||||
.workspace_ui
|
||||
.icons
|
||||
.insert("existing".into(), icon::from_svg_bytes(ferese_theme::icons::SETTINGS));
|
||||
use std::hash::{Hash, Hasher};
|
||||
let identity = |handle: &icon::Handle| {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
handle.hash(&mut hash);
|
||||
hash.finish()
|
||||
};
|
||||
let existing = identity(&shell.workspace_ui.icons["existing"]);
|
||||
drop(shell.update_workspace_ui());
|
||||
assert!(shell.workspace_ui.icons_loading);
|
||||
assert_eq!(identity(&shell.workspace_ui.icons["existing"]), existing);
|
||||
add_window(&mut shell.snapshot, "arrived-during-discovery");
|
||||
drop(shell.workspace_icons_loaded(Ok(IconIndex {
|
||||
entries: Arc::new(Vec::new()),
|
||||
icons: HashMap::from([("discovered".into(), icon::from_svg_bytes(ferese_theme::icons::OVERVIEW))]),
|
||||
})));
|
||||
assert_eq!(identity(&shell.workspace_ui.icons["existing"]), existing);
|
||||
assert!(shell.workspace_ui.icons.contains_key("discovered"));
|
||||
assert!(
|
||||
shell.workspace_ui.icons_loading,
|
||||
"newly arrived windows need a follow-up request"
|
||||
);
|
||||
drop(shell.workspace_icons_loaded(Err("discovery failed".into())));
|
||||
assert!(!shell.workspace_ui.icons_loading);
|
||||
assert_eq!(identity(&shell.workspace_ui.icons["existing"]), existing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_during_a_transition_preserves_position_and_reduced_motion_snaps() {
|
||||
let mut ui = WorkspaceUi::default();
|
||||
let now = Instant::now();
|
||||
let settings = motion::Settings::default();
|
||||
ui.update_at(&snapshot(1), settings, now);
|
||||
assert_eq!(ui.transition(1, true).progress(now), 1.);
|
||||
ui.update_at(&snapshot(2), settings, now);
|
||||
let midpoint = now + Duration::from_millis(80);
|
||||
assert_eq!(ui.transition(1, false).progress(midpoint), 0.5);
|
||||
ui.update_at(&snapshot(1), settings, midpoint);
|
||||
assert_eq!(ui.transition(1, true).progress(midpoint), 0.5);
|
||||
assert_eq!(ui.transition(2, false).progress(midpoint), 0.5);
|
||||
assert_eq!(
|
||||
ui.transition(1, true).progress(midpoint + Duration::from_millis(160)),
|
||||
1.
|
||||
);
|
||||
ui.update_at(
|
||||
&snapshot(2),
|
||||
motion::Settings {
|
||||
reduced_motion: true,
|
||||
..settings
|
||||
},
|
||||
midpoint,
|
||||
);
|
||||
assert_eq!(ui.transition(1, false).progress(midpoint), 0.);
|
||||
assert_eq!(ui.transition(2, true).progress(midpoint), 1.);
|
||||
assert!(!ui.transition(2, true).active(midpoint));
|
||||
assert!(ui.entries.is_none(), "other styles must not scan desktop applications");
|
||||
ui.update_at(&ShellSnapshot::default(), settings, midpoint);
|
||||
assert!(ui.transitions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_state_belongs_to_the_workspace_output() {
|
||||
let mut ui = WorkspaceUi::default();
|
||||
let mut snapshot = snapshot(1);
|
||||
snapshot.workspaces[0].output = Some(5);
|
||||
ui.update(&snapshot, motion::Settings::default());
|
||||
assert_eq!(ui.transition(1, false).progress(Instant::now()), 0.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_slots_are_distinct_and_do_not_jump_when_windows_reorder() {
|
||||
use crate::control::WindowSnapshot;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut ui = WorkspaceUi::default();
|
||||
for (id, svg) in [
|
||||
("a", ferese_theme::icons::SETTINGS),
|
||||
("b", ferese_theme::icons::FERESE),
|
||||
("c", ferese_theme::icons::OVERVIEW),
|
||||
] {
|
||||
ui.icons.insert(id.into(), icon::from_svg_bytes(svg));
|
||||
}
|
||||
let mut snapshot = snapshot(1);
|
||||
snapshot.windows = [(1, "b"), (1, "a"), (1, "b"), (1, "c"), (2, "a"), (1, "")]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(id, (workspace, app_id))| WindowSnapshot {
|
||||
id: id as u64,
|
||||
workspace,
|
||||
app_id: app_id.into(),
|
||||
title: String::new(),
|
||||
focused: false,
|
||||
urgent: false,
|
||||
fullscreen: false,
|
||||
floating: false,
|
||||
})
|
||||
.collect();
|
||||
let fingerprint = |handles: Vec<icon::Handle>| {
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
handle.hash(&mut hash);
|
||||
hash.finish()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let original = fingerprint(ui.apps(1, &snapshot));
|
||||
assert_eq!(original.len(), 2);
|
||||
assert_ne!(original[0], original[1]);
|
||||
snapshot.windows.reverse();
|
||||
snapshot.windows[0].focused = true;
|
||||
assert_eq!(fingerprint(ui.apps(1, &snapshot)), original);
|
||||
assert_eq!(ui.apps(2, &snapshot).len(), 1);
|
||||
assert!(ui.apps(3, &snapshot).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_lookup_ignores_matching_widgets_on_other_outputs() {
|
||||
let target = Id::new("workspace:test");
|
||||
let bar = window::Id::unique();
|
||||
let mut operation = FindBounds {
|
||||
target: target.clone(),
|
||||
bar,
|
||||
window: None,
|
||||
found: None,
|
||||
};
|
||||
let bounds = Rectangle::with_size(cosmic::iced::Size::new(24., 28.));
|
||||
operation.set_window_id(window::Id::unique());
|
||||
operation.container(Some(&target), bounds);
|
||||
assert_eq!(operation.found, None);
|
||||
operation.set_window_id(bar);
|
||||
operation.container(Some(&target), bounds);
|
||||
assert_eq!(operation.found, Some(bounds));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_widget_exit_cancels_pending_and_visible_tooltips() {
|
||||
use cosmic::Application;
|
||||
use cosmic::iced::advanced::renderer::Headless;
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget};
|
||||
use cosmic::iced::{Event, Font, Pixels, Point, Size};
|
||||
use ferese_config::panel::WorkspaceStyle;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Pointer {
|
||||
Inside,
|
||||
Beside,
|
||||
LeaveAvailable,
|
||||
LeaveUnavailable,
|
||||
}
|
||||
|
||||
fn send_pointer(
|
||||
app: &mut FereseShell,
|
||||
renderer: &cosmic::Renderer,
|
||||
tree: &mut widget::Tree,
|
||||
pointer: Pointer,
|
||||
captured: bool,
|
||||
) -> (Rectangle, usize) {
|
||||
let bar = app.outputs[0].bar;
|
||||
let target = Id::new(format!("workspace:{bar:?}:workspaces:1"));
|
||||
let viewport = Rectangle::with_size(Size::new(1200., 36.));
|
||||
let mut view = app.view_layer(bar);
|
||||
tree.diff(view.as_widget_mut());
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let mut find = FindBounds {
|
||||
target,
|
||||
bar,
|
||||
window: Some(bar),
|
||||
found: None,
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), renderer, &mut operation::black_box(&mut find));
|
||||
let bounds = find.found.expect("the real panel must contain the workspace target");
|
||||
let inside = Point::new(bounds.center_x(), bounds.center_y());
|
||||
let (event, cursor) = match pointer {
|
||||
Pointer::Inside | Pointer::Beside => {
|
||||
let position = if matches!(pointer, Pointer::Inside) {
|
||||
inside
|
||||
} else {
|
||||
Point::new(bounds.x - 2., bounds.center_y())
|
||||
};
|
||||
(
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }),
|
||||
mouse::Cursor::Available(position),
|
||||
)
|
||||
}
|
||||
Pointer::LeaveAvailable => (Event::Mouse(mouse::Event::CursorLeft), mouse::Cursor::Available(inside)),
|
||||
Pointer::LeaveUnavailable => (Event::Mouse(mouse::Event::CursorLeft), mouse::Cursor::Unavailable),
|
||||
};
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if captured {
|
||||
shell.capture_event();
|
||||
}
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
drop(view);
|
||||
let mut hover_messages = 0;
|
||||
for message in messages {
|
||||
if let cosmic::Action::App(message @ Message::HoverWorkspace(..)) = message {
|
||||
hover_messages += 1;
|
||||
drop(app.update(message));
|
||||
}
|
||||
}
|
||||
(bounds, hover_messages)
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
|
||||
let renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
for style in [
|
||||
WorkspaceStyle::Numbers,
|
||||
WorkspaceStyle::Dots,
|
||||
WorkspaceStyle::Tabs,
|
||||
WorkspaceStyle::WindowStacks,
|
||||
WorkspaceStyle::AppIcons,
|
||||
] {
|
||||
for visible in [false, true] {
|
||||
for (exit, captured) in [
|
||||
(Pointer::Beside, true),
|
||||
(Pointer::Beside, false),
|
||||
(Pointer::LeaveAvailable, false),
|
||||
(Pointer::LeaveAvailable, true),
|
||||
(Pointer::LeaveUnavailable, false),
|
||||
(Pointer::LeaveUnavailable, true),
|
||||
] {
|
||||
let mut app = crate::tests::shell_with_measured_panel();
|
||||
app.snapshot = snapshot(1);
|
||||
for group in &mut app.config.panels[0].start.groups {
|
||||
for item in &mut group.items {
|
||||
if matches!(item.kind, crate::panel::ItemKind::Workspaces { .. }) {
|
||||
item.kind = crate::panel::ItemKind::Workspaces { style };
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut tree = widget::Tree::empty();
|
||||
let (bounds, messages) = send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
|
||||
assert!(messages > 0, "{style:?} must enter its actual workspace widget");
|
||||
assert!(app.workspace_ui.hovered.is_some());
|
||||
let serial = app.workspace_ui.serial;
|
||||
if visible {
|
||||
drop(app.show_workspace_tooltip(serial, Some(bounds)));
|
||||
assert!(app.workspace_ui.tooltip.is_some());
|
||||
}
|
||||
send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
|
||||
assert_eq!(
|
||||
app.workspace_ui.serial, serial,
|
||||
"movement inside {style:?} must not restart the delay"
|
||||
);
|
||||
send_pointer(&mut app, &renderer, &mut tree, exit, captured);
|
||||
assert!(
|
||||
app.workspace_ui.hovered.is_none() && app.workspace_ui.tooltip.is_none(),
|
||||
"{style:?}: {exit:?}, captured={captured}, visible={visible} must cancel the hover"
|
||||
);
|
||||
drop(app.show_workspace_tooltip(serial, Some(bounds)));
|
||||
assert!(
|
||||
app.workspace_ui.tooltip.is_none(),
|
||||
"an expired delay must stay cancelled"
|
||||
);
|
||||
send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
|
||||
assert!(
|
||||
app.workspace_ui.hovered.is_some(),
|
||||
"reentry must restore {style:?} hover"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_the_bar_cancels_pending_and_visible_tooltips_and_allows_reentry() {
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
shell.snapshot = snapshot(1);
|
||||
let bar = shell.outputs[0].bar;
|
||||
let target = Id::new("workspace:hover");
|
||||
let bounds = Some(Rectangle::with_size(cosmic::iced::Size::new(24., 28.)));
|
||||
for visible in [false, true] {
|
||||
drop(shell.hover_workspace(bar, 1, target.clone(), true));
|
||||
let serial = shell.workspace_ui.serial;
|
||||
drop(shell.hover_workspace(bar, 1, target.clone(), true));
|
||||
assert_eq!(
|
||||
shell.workspace_ui.serial, serial,
|
||||
"pointer movement inside a workspace must preserve its delay"
|
||||
);
|
||||
if visible {
|
||||
drop(shell.show_workspace_tooltip(serial, bounds));
|
||||
assert!(shell.workspace_ui.tooltip.is_some());
|
||||
}
|
||||
drop(shell.handle_event(
|
||||
cosmic::iced::Event::Mouse(cosmic::iced::mouse::Event::CursorLeft),
|
||||
window::Id::unique(),
|
||||
));
|
||||
assert!(
|
||||
shell.workspace_ui.hovered.is_some(),
|
||||
"leaving another surface must not cancel this hover"
|
||||
);
|
||||
drop(shell.handle_event(cosmic::iced::Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), bar));
|
||||
assert!(shell.workspace_ui.hovered.is_none());
|
||||
assert!(shell.workspace_ui.tooltip.is_none());
|
||||
drop(shell.show_workspace_tooltip(serial, bounds));
|
||||
assert!(
|
||||
shell.workspace_ui.tooltip.is_none(),
|
||||
"a delayed response after pointer exit must stay cancelled"
|
||||
);
|
||||
drop(shell.hover_workspace(bar, 1, target.clone(), true));
|
||||
drop(shell.show_workspace_tooltip(shell.workspace_ui.serial, bounds));
|
||||
assert!(
|
||||
shell.workspace_ui.tooltip.is_some(),
|
||||
"the same workspace must show its tooltip on reentry"
|
||||
);
|
||||
drop(shell.dismiss_workspace_tooltip());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_reallocation_cancels_workspace_tooltips_without_a_pointer_exit() {
|
||||
use cosmic::Application;
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
shell.snapshot = snapshot(1);
|
||||
let bar = shell.outputs[0].bar;
|
||||
drop(shell.hover_workspace(bar, 1, Id::new("workspace:hover"), true));
|
||||
let serial = shell.workspace_ui.serial;
|
||||
let bounds = Some(Rectangle::with_size(cosmic::iced::Size::new(24., 28.)));
|
||||
drop(shell.show_workspace_tooltip(serial, bounds));
|
||||
let previous = shell.outputs[0].panel_resolution.clone().unwrap();
|
||||
drop(shell.update(Message::PanelResolved(bar, previous.clone())));
|
||||
assert!(shell.workspace_ui.tooltip.is_some());
|
||||
let mut moved = previous;
|
||||
moved.zone_widths[0] = 200.;
|
||||
drop(shell.update(Message::PanelResolved(bar, moved)));
|
||||
assert!(shell.workspace_ui.tooltip.is_none());
|
||||
assert!(shell.workspace_ui.hovered.is_none());
|
||||
drop(shell.show_workspace_tooltip(serial, bounds));
|
||||
assert!(shell.workspace_ui.tooltip.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_hover_messages_cannot_replace_or_reopen_tooltips() {
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
shell.snapshot = snapshot(1);
|
||||
let bar = shell.outputs[0].bar;
|
||||
let first = Id::new("workspace:first");
|
||||
let second = Id::new("workspace:second");
|
||||
let _ = shell.hover_workspace(bar, 1, first.clone(), true);
|
||||
let old_serial = shell.workspace_ui.serial;
|
||||
let _ = shell.hover_workspace(bar, 2, second.clone(), true);
|
||||
let _ = shell.hover_workspace(bar, 1, first, false);
|
||||
assert_eq!(shell.workspace_ui.hovered.as_ref().unwrap().target, second);
|
||||
let bounds = Some(Rectangle::with_size(cosmic::iced::Size::new(24., 28.)));
|
||||
let _ = shell.show_workspace_tooltip(old_serial, bounds);
|
||||
assert!(shell.workspace_ui.tooltip.is_none());
|
||||
let current = shell.workspace_ui.serial;
|
||||
let _ = shell.show_workspace_tooltip(current, bounds);
|
||||
assert!(shell.workspace_ui.tooltip.is_some());
|
||||
let _ = shell.dismiss_workspace_tooltip();
|
||||
let _ = shell.show_workspace_tooltip(current, bounds);
|
||||
assert!(shell.workspace_ui.tooltip.is_none());
|
||||
let _ = shell.hover_workspace(bar, 2, second, true);
|
||||
let current = shell.workspace_ui.serial;
|
||||
let _ = shell.show_workspace_tooltip(current, bounds);
|
||||
shell.snapshot.workspaces.retain(|workspace| workspace.id != 2);
|
||||
let _ = shell.update_workspace_ui();
|
||||
assert!(shell.workspace_ui.tooltip.is_none());
|
||||
assert!(shell.workspace_ui.hovered.is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user