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:
Samuel Onoja authored and GitHub committed 2026-10-10 18:50:37 +01:00
1 parent d4c8c10b39
commit 65fcaef13f
84 files changed
+12809 -1258

No files matched your search

+11 -2
View File
@@ -422,6 +422,7 @@ impl cosmic::Application for Prompt {
fn view(&self) -> Element<'_, Message> {
let palette = &self.appearance;
let material_opacity = if palette.high_contrast { 1.0 } else { palette.surface.a };
let mut input = widget::text_input(&self.question, self.answer.as_str())
.id(widget::Id::new("auth-response"))
.font(palette.font)
@@ -487,7 +488,11 @@ impl cosmic::Application for Prompt {
)
.size(12),
)
.class(ferese_theme::controls::button_style(palette.palette(), false))
.class(ferese_theme::controls::material_button_style(
palette.palette(),
false,
material_opacity,
))
.padding([6, 10])
.on_press(Message::ToggleDetails),
);
@@ -551,7 +556,11 @@ impl cosmic::Application for Prompt {
body = body.push(
row![
button::custom(ferese_theme::text("Cancel", palette.font).size(13))
.class(ferese_theme::controls::button_style(palette.palette(), false))
.class(ferese_theme::controls::material_button_style(
palette.palette(),
false,
material_opacity,
))
.padding([8, 14])
.on_press(Message::Cancel),
action
+1
View File
@@ -10,6 +10,7 @@ ferese-theme-client = { path = "../../crates/ferese-theme-client" }
ferese-theme = { path = "../../crates/ferese-theme" }
ferese-config = { path = "../../crates/ferese-config" }
libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] }
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" }
ferese-ipc = { path = "../../crates/ferese-ipc" }
serde_json.workspace = true
tempfile.workspace = true
+15
View File
@@ -36,6 +36,8 @@ pub struct Display {
pub connector: String,
pub identity: String,
pub profile: Option<String>,
pub logical_width: Option<i32>,
pub focused: bool,
pub current: Option<Mode>,
pub modes: Vec<Mode>,
}
@@ -51,6 +53,11 @@ pub fn load() -> Result<Vec<Display>, String> {
connector: value["connector"].as_str()?.to_owned(),
identity: value["identity"].as_str().unwrap_or("").to_owned(),
profile: value["profile"].as_str().map(str::to_owned),
logical_width: value["width"]
.as_i64()
.and_then(|width| width.try_into().ok())
.filter(|width| *width > 0),
focused: value["focused"].as_bool().unwrap_or(false),
current: Mode::from_value(&value["current_mode"]),
modes: value["available_modes"]
.as_array()
@@ -264,6 +271,8 @@ mod tests {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
logical_width: Some(1440),
focused: true,
current: Some(Mode {
width: 2880,
height: 1800,
@@ -344,6 +353,8 @@ mod tests {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: Some("laptop".into()),
logical_width: Some(1440),
focused: true,
current: None,
modes: vec![],
}];
@@ -369,6 +380,8 @@ mod tests {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
logical_width: Some(1440),
focused: true,
current: None,
modes: vec![],
}];
@@ -384,6 +397,8 @@ mod tests {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
logical_width: Some(1440),
focused: true,
current: Some(mode(2880, 1800, 60000)),
modes: vec![
mode(2880, 1800, 60000),
+22 -6
View File
@@ -21,15 +21,31 @@ pub fn families() -> &'static [String] {
.map(str::to_owned)
.collect();
families.extend([
"Comfortaa".into(),
"sans-serif".into(),
"serif".into(),
"monospace".into(),
]);
families.extend(["sans-serif".into(), "serif".into(), "monospace".into()]);
families.sort_unstable_by_key(|family| family.to_lowercase());
families.dedup();
families.insert(0, "Default font".into());
families
})
}
/// Missing theme fonts must fall back to a proportional interface font.
/// Iced does not apply fontconfig's named-family substitutions.
pub fn interface_font(family: &str) -> cosmic::iced::Font {
match family {
"sans-serif" => cosmic::iced::Font::DEFAULT,
"serif" => cosmic::iced::Font {
family: cosmic::iced::font::Family::Serif,
..cosmic::iced::Font::DEFAULT
},
"monospace" => cosmic::iced::Font::MONOSPACE,
_ if families()
.iter()
.skip(1)
.any(|installed| installed.eq_ignore_ascii_case(family)) =>
{
ferese_theme::font(Some(family))
}
_ => cosmic::iced::Font::DEFAULT,
}
}
+69 -26
View File
@@ -14,11 +14,37 @@ impl App {
ferese_theme::text(text, self.font).size(size)
}
pub(super) fn panel_heading(
&self,
text: impl Into<std::borrow::Cow<'static, str>> + 'static,
size: f32,
) -> widget::Text<'static, cosmic::Theme, cosmic::Renderer> {
self.label(text, size).font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Semibold,
..self.font
})
}
pub(super) fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(text.to_owned(), 11.)
.class(cosmic::theme::Text::Color(palette.muted))
.into()
self.label(
text.to_owned(),
if self.page == crate::schema::Page::Bar {
12.
} else {
11.
},
)
.class(cosmic::theme::Text::Color(palette.muted))
.into()
}
fn control_button_style(&self, palette: visuals::Palette, selected: bool) -> cosmic::theme::Button {
if self.page == crate::schema::Page::Bar {
visuals::panel_button(palette, selected)
} else {
visuals::button_style(palette, selected)
}
}
pub(super) fn settings_button(
@@ -33,12 +59,15 @@ impl App {
row([])
.spacing(5)
.align_y(Alignment::Center)
.push(visuals::action_icon(icon, palette.text))
.push(visuals::action_icon(
icon,
if message.is_some() { palette.text } else { palette.muted },
))
.push(self.label(label.to_owned(), 12.)),
)
.name(label.to_owned())
.padding([4, 8])
.class(visuals::button_style(palette, selected))
.class(self.control_button_style(palette, selected))
.on_press_maybe(message)
.into()
}
@@ -51,11 +80,14 @@ impl App {
) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
widget::tooltip(
button::custom(visuals::action_icon(icon, palette.text))
.name(label.to_owned())
.padding(4)
.class(visuals::button_style(palette, false))
.on_press_maybe(message),
button::custom(visuals::action_icon(
icon,
if message.is_some() { palette.text } else { palette.muted },
))
.name(label.to_owned())
.padding(4)
.class(self.control_button_style(palette, false))
.on_press_maybe(message),
self.label(label.to_owned(), 11.),
widget::tooltip::Position::Top,
)
@@ -146,34 +178,45 @@ impl App {
Kind::Range {
default,
min,
max,
mut max,
step,
suffix,
integer,
} => {
let margin_limit = (path == "panels.0.geometry.side_margins").then(|| self.panel_side_margin_limit());
if let Some(limit) = margin_limit {
max = f64::from(limit.unwrap_or(0));
}
let value = self
.ranges
.get(&path)
.copied()
.unwrap_or_else(|| self.draft.number(&path, default));
.unwrap_or_else(|| self.draft.number(&path, default))
.clamp(min, max);
let display = if integer || step >= 1. {
format!("{value:.0}{suffix}")
} else {
format!("{value:.2}{suffix}")
};
let release = field.clone();
row([])
.align_y(Alignment::Center)
.spacing(8)
.push(
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
.step(step)
.class(ferese_theme::menus::slider(palette.accent, 1.))
.on_release(Message::Release(release))
.width(145),
)
.push(self.label(display, 12.).width(65))
.into()
if margin_limit == Some(None) {
self.label("—", 12.).into()
} else if max <= min {
self.label(display, 12.).into()
} else {
let release = field.clone();
row([])
.align_y(Alignment::Center)
.spacing(8)
.push(
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
.step(step)
.class(ferese_theme::menus::slider(palette.accent, 1.))
.on_release(Message::Release(release))
.width(145),
)
.push(self.label(display, 12.).width(65))
.into()
}
}
Kind::Choice { default, choices } => {
let value = self.wallpaper_field_value(&path, default);
@@ -182,7 +225,7 @@ impl App {
options = options.push(
button::custom(self.label(*label, 12.))
.padding([4, 8])
.class(visuals::button_style(palette, value == *key))
.class(self.control_button_style(palette, value == *key))
.on_press(Message::Change(set(&path, *key))),
);
}
+413 -14
View File
@@ -2,6 +2,11 @@ mod connections;
mod form_controls;
mod navigation;
mod pages;
mod panel_controls;
mod panel_drag;
mod panel_edit;
mod panel_inspector;
mod panel_preview;
mod theme_controls;
use theme_controls::*;
@@ -30,6 +35,11 @@ use schema::{Field, Kind, Page};
use store::{Edit, Snapshot, set};
fn main() -> cosmic::iced::Result {
if std::env::var_os("ICED_BACKEND").is_none() {
// Settings repaints large scrolling surfaces; prefer GPU rendering.
// SAFETY: process entry, before the toolkit or font workers start threads.
unsafe { std::env::set_var("ICED_BACKEND", "wgpu,tiny-skia") };
}
let mut args = std::env::args_os().skip(1);
let mut path = store::config_path();
let mut initial_page = None;
@@ -47,14 +57,15 @@ fn main() -> cosmic::iced::Result {
Some("connections" | "wifi") => Some(InitialPage::Connections(connections::Tab::Wifi)),
Some("bluetooth") => Some(InitialPage::Connections(connections::Tab::Bluetooth)),
Some("wallpaper") => Some(InitialPage::Wallpaper),
Some("bar") => Some(InitialPage::Bar),
_ => {
eprintln!("--page accepts connections, wifi, bluetooth, or wallpaper");
eprintln!("--page accepts connections, wifi, bluetooth, wallpaper, or bar");
return Ok(());
}
};
}
_ => {
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper]");
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper|bar]");
return Ok(());
}
}
@@ -78,6 +89,7 @@ fn main() -> cosmic::iced::Result {
enum InitialPage {
Connections(connections::Tab),
Wallpaper,
Bar,
}
#[derive(Clone, Debug)]
@@ -98,7 +110,15 @@ enum Message {
PagePresented(Page),
RowVisibility(&'static str, usize, bool),
Search(String),
Sidebar(bool),
Change(Edit),
PanelClearSelection,
PanelPage(panel_controls::PanelPage),
PanelSelect(ferese_config::panel::ItemId),
PanelGroupSelect(ferese_config::panel::GroupId),
PanelPreviewResolved(ferese_config::panel::layout::Resolution),
PanelPreviewOverflow,
PanelEdit(panel_edit::Action),
SelectFont(String, String),
Draft(String, String),
Commit(Field),
@@ -159,6 +179,7 @@ struct App {
visible_rows: std::collections::HashSet<(&'static str, usize)>,
hidden_binding_rows: std::collections::HashSet<usize>,
search: String,
sidebar_open: bool,
inputs: HashMap<String, String>,
ranges: HashMap<String, f64>,
new_command: String,
@@ -171,6 +192,11 @@ struct App {
undo_revision: u64,
auto_details: bool,
advanced_theme: bool,
panel_selection: Option<ferese_config::panel::ItemId>,
panel_page: panel_controls::PanelPage,
panel_group_selection: Option<ferese_config::panel::GroupId>,
panel_preview_resolution: ferese_config::panel::layout::Resolution,
panel_preview_overflow: bool,
theme_file_target: usize,
wallpaper: wallpaper_controls::State,
}
@@ -233,7 +259,7 @@ impl cosmic::Application for App {
let error = initial.as_ref().err().cloned();
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
let resolved = ferese_theme_client::service::current();
let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family));
let font = fonts::interface_font(&resolved.presented.tokens.typography.font_family);
let native_palette = visuals::Palette::from_resolved(&resolved.presented);
let family_sections = theme_controls::FamilySections::new(&resolved.families, &current);
let mut app = Self {
@@ -261,11 +287,13 @@ impl cosmic::Application for App {
page: match initial_page {
Some(InitialPage::Connections(_)) => Page::Connections,
Some(InitialPage::Wallpaper) => Page::Wallpaper,
Some(InitialPage::Bar) => Page::Bar,
None => Page::Appearance,
},
profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(),
page_transition: None,
search: String::new(),
sidebar_open: false,
visible_rows: initial_visible_rows(),
hidden_binding_rows: Default::default(),
inputs: HashMap::new(),
@@ -280,6 +308,11 @@ impl cosmic::Application for App {
undo_revision: 0,
auto_details: false,
advanced_theme: false,
panel_selection: None,
panel_page: Default::default(),
panel_group_selection: None,
panel_preview_resolution: Default::default(),
panel_preview_overflow: false,
theme_file_target: 0,
wallpaper: Default::default(),
};
@@ -296,7 +329,12 @@ impl cosmic::Application for App {
Task::none()
};
let thumbnails = app.load_thumbnail();
(app, Task::batch([task, connections, thumbnails]))
let displays = if app.page == Page::Bar {
app.load_displays()
} else {
Task::none()
};
(app, Task::batch([task, connections, thumbnails, displays]))
}
fn update(&mut self, message: Message) -> Task<Message> {
@@ -345,7 +383,7 @@ impl cosmic::Application for App {
}
self.resolved = *snapshot;
if font_changed {
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
}
let theme = self.update_theme();
@@ -426,7 +464,7 @@ impl cosmic::Application for App {
self.family_sections =
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
self.sync_notes();
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.undo = None;
self.error = None;
self.status = "Updated from your config".into();
@@ -463,7 +501,14 @@ impl cosmic::Application for App {
Message::LockPreviewStarted(Err(error)) => {
self.error = Some(error);
}
Message::Sidebar(open) => {
self.sidebar_open = open;
if open {
return cosmic::iced::widget::operation::focus(widget::Id::new("settings-search"));
}
}
Message::Page(page) => {
self.sidebar_open = false;
if self.page == page && self.search.is_empty() {
return Task::none();
}
@@ -482,7 +527,7 @@ impl cosmic::Application for App {
if leaving {
let cleanup = self.leave_connections();
let load = match page {
Page::Displays => self.load_displays(),
Page::Displays | Page::Bar => self.load_displays(),
Page::Wallpaper => self.load_thumbnail(),
_ => Task::none(),
};
@@ -491,7 +536,7 @@ impl cosmic::Application for App {
if page == Page::Connections {
return Task::batch([reset, self.refresh_connections()]);
}
if page == Page::Displays {
if matches!(page, Page::Displays | Page::Bar) {
return Task::batch([reset, self.load_displays()]);
}
if page == Page::Wallpaper {
@@ -548,7 +593,23 @@ impl cosmic::Application for App {
}
Message::Release(field) => {
if let Some(value) = self.ranges.remove(&field.path) {
if field.path == "panels.0.background_opacity" {
return self.update(Message::PanelEdit(panel_edit::Action::SetOpacity(value as f32 / 100.)));
}
if let Some(index) = field.path.strip_prefix("panels.0.corner_radius.")
&& let Ok(index) = index.parse()
{
return self.update(Message::PanelEdit(panel_edit::Action::SetCorner(index, value as f32)));
}
let integer = matches!(field.kind, Kind::Range { integer: true, .. });
if let Some(key) = field.path.strip_prefix("panels.0.geometry.") {
let value = if integer {
serde_json::Value::from(value.round() as i64)
} else {
value.into()
};
return self.update(Message::PanelEdit(panel_edit::Action::SetGeometry(key.into(), value)));
}
return self.change(if integer {
set(&field.path, value.round() as i64)
} else {
@@ -589,14 +650,80 @@ impl cosmic::Application for App {
return self.change(edit);
}
}
Message::Change(edit) => return self.change(edit),
Message::PanelSelect(id) => {
let changed = self.panel_selection.as_ref() != Some(&id);
self.panel_page = panel_controls::PanelPage::Arrange;
self.panel_group_selection = None;
self.panel_selection = Some(id);
self.panel_preview_overflow = false;
if changed {
return cosmic::iced::widget::scrollable::snap_to(
widget::Id::new("panel-inspector-scroll"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
}
}
Message::PanelGroupSelect(id) => {
let changed = self.panel_group_selection.as_ref() != Some(&id);
self.panel_page = panel_controls::PanelPage::Arrange;
self.panel_selection = None;
self.panel_group_selection = Some(id);
if changed {
return cosmic::iced::widget::scrollable::snap_to(
widget::Id::new("panel-inspector-scroll"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
}
}
Message::PanelClearSelection => {
self.panel_selection = None;
self.panel_group_selection = None;
}
Message::PanelPreviewResolved(resolution) => self.panel_preview_resolution = resolution,
Message::PanelPreviewOverflow => self.panel_preview_overflow = !self.panel_preview_overflow,
Message::PanelPage(tab) => {
self.panel_page = tab;
return cosmic::iced::widget::scrollable::snap_to(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
}
Message::PanelEdit(action) => match panel_edit::plan(&self.draft, action) {
Ok(edits) => {
self.inputs.retain(|path, _| !path.starts_with("panels."));
self.ranges.retain(|path, _| !path.starts_with("panels."));
let task = self.edit_many(edits);
if let Ok(panel) = self.preview_panel() {
if self.panel_selection.as_ref().is_some_and(|id| panel.item(id).is_none()) {
self.panel_selection = None;
}
if self.panel_group_selection.as_ref().is_some_and(|id| {
!panel_edit::Zone::ALL
.into_iter()
.any(|zone| zone.definition(&panel).groups.iter().any(|group| &group.id == id))
}) {
self.panel_group_selection = None;
}
}
return task;
}
Err(error) => self.error = Some(error),
},
Message::Change(edit) => {
if matches!(&edit, Edit::Unset(path) if path == "panels") {
self.inputs.retain(|path, _| !path.starts_with("panels."));
self.panel_selection = None;
self.panel_group_selection = None;
}
return self.change(edit);
}
Message::Saved(result) => {
self.saving = false;
match result {
Ok((snapshot, live)) => {
self.undo = Some(std::mem::take(&mut self.saving_previous));
self.current = snapshot;
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.draft = self.current.clone();
for edit in &self.pending {
@@ -640,7 +767,7 @@ impl cosmic::Application for App {
self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
self.note_editors.clear();
self.sync_notes();
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.pending.clear();
self.inputs.clear();
self.ranges.clear();
@@ -840,9 +967,25 @@ impl cosmic::Application for App {
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([
if self.sidebar_open {
cosmic::iced::event::listen_with(|event, _, _| match event {
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
..
}) => Some(Message::Sidebar(false)),
cosmic::iced::Event::Window(cosmic::iced::window::Event::Resized(size))
if size.width >= pages::SIDEBAR_BREAKPOINT =>
{
Some(Message::Sidebar(false))
}
_ => None,
})
} else {
cosmic::iced::Subscription::none()
},
cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)),
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
if self.page == Page::Displays {
if matches!(self.page, Page::Displays | Page::Bar) {
cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays)
} else {
cosmic::iced::Subscription::none()
@@ -1038,6 +1181,214 @@ mod tests {
1 + tree.children.iter().map(tree_nodes).sum::<usize>()
}
#[test]
fn corner_sliders_hold_changes_until_release_and_preserve_other_corners() {
let mut app = app();
for edit in panel_edit::plan(&app.draft, panel_edit::Action::SetRadius("4px 8px 12px 16px".into())).unwrap() {
app.draft.edit(&edit).unwrap();
}
app.draft = Snapshot::parse(app.draft.source.clone()).unwrap();
app.current = app.draft.clone();
app.saving = true; // Keep this test independent of disk and the live compositor.
let before = app.draft.source.clone();
let field = Field::new(
"panels.0.corner_radius.2",
"Bottom right",
"",
Kind::Range {
default: 12.,
min: 0.,
max: 64.,
step: 1.,
suffix: " px",
integer: false,
},
);
let _ = app.update(Message::Range(field.clone(), 23.));
assert_eq!(app.draft.source, before);
let _ = app.update(Message::Release(field));
let radii: ferese_config::panel::CornerRadii =
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()).unwrap();
assert_eq!(radii.0, [4., 8., 23., 16.]);
assert!(app.ranges.is_empty());
assert_eq!(app.current.source, before);
}
#[test]
fn pending_panel_moves_save_both_groups_together_and_keep_the_selected_instance() {
use ferese_config::panel::{GroupId, ItemId};
let mut app = app();
let mut initial = Snapshot::parse(String::new()).unwrap();
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
app.current = initial.clone();
app.draft = initial.clone();
app.saving = true; // An earlier save has not completed yet.
let id = ItemId("network".into());
let _ = app.update(Message::PanelSelect(id.clone()));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Move(
id.clone(),
panel_edit::Destination {
zone: "start",
group: GroupId("workspaces".into()),
},
)));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Set(
id.clone(),
"overflow".into(),
"always".into(),
)));
assert_eq!(app.pending.len(), 3);
assert_eq!(app.panel_selection, Some(id.clone()));
let expected = app.draft.doc.value().clone();
let _ = app.update(Message::Saved(Ok((initial, false))));
assert!(app.saving, "the complete queued draft is saved once");
assert!(app.pending.is_empty());
assert_eq!(app.draft.doc.value(), &expected);
let panels: Vec<ferese_config::panel::Panel> =
serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap();
ferese_config::panel::validate(&panels).unwrap();
assert_eq!(panels[0].start.groups[1].items[1].id, id);
assert_eq!(
panels[0].start.groups[1].items[1].overflow,
ferese_config::panel::OverflowPolicy::Always
);
assert!(!panels[0].end.groups[0].items.iter().any(|item| item.id == id));
}
#[test]
fn panel_inspector_builds_only_for_the_selected_instance_and_survives_removal() {
let mut app = app();
app.page = Page::Bar;
let edit = panel_controls::initialize(&app.draft).unwrap();
app.draft.edit(&edit).unwrap();
use cosmic::iced::advanced::{layout, renderer::Headless, widget::Tree};
use cosmic::iced::{Font, Pixels, Size};
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 nodes = |app: &App| {
let mut view = app.page_view();
let mut tree = Tree::new(&view);
view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1120., 840.)),
);
tree_nodes(&tree)
};
app.panel_page = panel_controls::PanelPage::Arrange;
let collapsed = nodes(&app);
let id = ferese_config::panel::ItemId("clock".into());
let _ = app.update(Message::PanelSelect(id.clone()));
let expanded = nodes(&app);
assert!(expanded > collapsed);
let _ = app.update(Message::PanelEdit(panel_edit::Action::Remove(id)));
let _ = app.page_view();
assert!(app.error.is_none());
assert_eq!(app.draft.string("panels.0.end.groups.1.items.0.id", ""), "");
}
#[test]
fn pending_group_moves_and_style_changes_save_one_coherent_draft() {
use ferese_config::panel::GroupId;
let mut app = app();
let mut initial = Snapshot::parse(String::new()).unwrap();
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
app.current = initial.clone();
app.draft = initial.clone();
app.saving = true;
let id = GroupId("status".into());
let _ = app.update(Message::PanelGroupSelect(id.clone()));
let _ = app.update(Message::PanelEdit(panel_edit::Action::MoveGroup(
id.clone(),
panel_edit::Zone::Start,
)));
let _ = app.update(Message::PanelEdit(panel_edit::Action::SetGroup(
id.clone(),
"padding_horizontal".into(),
12.into(),
)));
let expected = app.draft.doc.value().clone();
let _ = app.update(Message::Saved(Ok((initial, false))));
assert!(app.saving);
assert!(app.pending.is_empty());
assert_eq!(app.draft.doc.value(), &expected);
assert_eq!(app.panel_group_selection, Some(id));
assert_eq!(app.draft.number("panels.0.start.groups.2.padding.1", 0.), 12.);
let _ = app.page_view();
}
#[test]
fn selecting_a_generated_preview_item_does_not_save_the_composition() {
let mut app = app();
app.saving = true;
let id = ferese_config::panel::ItemId("clock".into());
let _ = app.update(Message::PanelSelect(id.clone()));
assert_eq!(app.panel_selection, Some(id));
assert!(app.draft.item("panels").is_none());
assert!(app.pending.is_empty());
}
#[test]
fn panel_pages_do_not_save_and_selection_opens_the_items_inspector() {
let mut app = app();
app.page = Page::Bar;
for tab in [panel_controls::PanelPage::Arrange, panel_controls::PanelPage::Panels] {
let _ = app.update(Message::PanelPage(tab));
let _ = app.page_view();
assert_eq!(app.panel_page, tab);
assert!(app.pending.is_empty());
assert!(app.draft.item("panels").is_none());
}
app.saving = true;
let _ = app.update(Message::PanelSelect(ferese_config::panel::ItemId("clock".into())));
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
assert!(app.panel_group_selection.is_none());
let _ = app.update(Message::PanelGroupSelect(ferese_config::panel::GroupId(
"status".into(),
)));
assert!(app.panel_selection.is_none());
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
}
#[test]
fn dropped_items_follow_queued_group_moves_and_are_saved_once() {
use ferese_config::panel::{GroupId, ItemId};
let mut app = app();
let mut initial = Snapshot::parse(String::new()).unwrap();
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
app.current = initial.clone();
app.draft = initial.clone();
app.saving = true;
let _ = app.update(Message::PanelEdit(panel_edit::Action::MoveGroup(
GroupId("time".into()),
panel_edit::Zone::Center,
)));
let _ = app.update(Message::PanelEdit(panel_edit::Action::Place(
ItemId("network".into()),
panel_edit::Destination {
zone: "end",
group: GroupId("time".into()),
},
Some(ItemId("clock".into())),
)));
assert_eq!(app.pending.len(), 4);
let expected = app.draft.doc.value().clone();
let _ = app.update(Message::Saved(Ok((initial, false))));
assert_eq!(app.draft.doc.value(), &expected);
assert!(app.saving && app.pending.is_empty());
assert_eq!(app.draft.string("panels.0.center.groups.1.items.0.id", ""), "network");
assert_eq!(app.draft.string("panels.0.center.groups.1.items.1.id", ""), "clock");
}
#[test]
fn wallpaper_preview_uses_presented_state_and_tracks_mode_changes() {
let mut app = app();
@@ -1102,9 +1453,32 @@ mod tests {
fn shortcuts_build_controls_only_for_mounted_rows() {
let mut app = shortcuts(100);
app.visible_rows.extend((0..4).map(|index| ("bindings", index)));
let visible = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget()));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
use cosmic::iced::advanced::{layout, renderer::Headless, widget::Tree};
use cosmic::iced::{Font, Pixels, Size};
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let nodes = |app: &App| {
let mut view = app.page_view();
let mut tree = Tree::new(&view);
view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1100., 860.)),
);
tree_nodes(&tree)
};
let visible = nodes(&app);
app.visible_rows.extend((0..100).map(|index| ("bindings", index)));
let eager = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget()));
let eager = nodes(&app);
assert!(eager > visible + 96 * 10, "visible={visible}, eager={eager}");
}
@@ -1121,6 +1495,31 @@ mod tests {
assert_eq!(app.draft.string("bindings.5.argument", ""), "my command");
}
#[test]
fn merged_navigation_finds_subpages_and_browsing_does_not_save() {
for (parent, query, destination) in [
(Page::Windows, "motion", Page::Motion),
(Page::Windows, "animation speed", Page::Motion),
(Page::Keyboard, "shortcuts", Page::Shortcuts),
(Page::Keyboard, "swipe actions", Page::Shortcuts),
(Page::Keyboard, "touchpad", Page::Keyboard),
] {
assert!(parent.matches(query));
assert_eq!(parent.search_destination(query), destination);
assert_eq!(destination.navigation_page(), parent);
let mut app = app();
let before = app.draft.source.clone();
let _ = app.update(Message::Page(parent));
let _ = app.update(Message::Search(query.into()));
let _ = app.update(Message::Page(destination));
assert_eq!(app.page, destination);
assert!(app.search.is_empty());
assert_eq!(app.draft.source, before);
assert!(app.pending.is_empty());
assert!(!app.saving);
}
}
#[test]
fn navigation_resets_scroll_but_reselecting_the_same_page_does_not() {
let mut app = app();
+5 -4
View File
@@ -24,10 +24,11 @@ pub(super) struct RevealRow {
impl RevealRow {
pub(super) fn new(list: &str, row: usize) -> Self {
Self {
target: gallery_row_id(list, row),
bounds: None,
}
Self::for_target(gallery_row_id(list, row))
}
pub(super) fn for_target(target: Id) -> Self {
Self { target, bounds: None }
}
}
+194 -71
View File
@@ -3,10 +3,19 @@ use super::{
text_input, visuals, widget,
};
pub(super) const SIDEBAR_BREAKPOINT: f32 = 960.;
impl App {
pub(super) fn page_view(&self) -> Element<'_, Message> {
cosmic::iced::widget::responsive(move |size| self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn page_layout(&self, compact: bool, height: f32) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let sidebar = column([])
let mut sidebar = column([])
.spacing(3)
.push(
widget::mouse_area(
@@ -14,7 +23,10 @@ impl App {
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::brand_icon())
.push(self.label("Ferese", 16.))
.push(
self.label("Ferese", 16.)
.class(cosmic::theme::Text::Color(palette.text)),
)
.width(Length::Fill),
)
.on_press(Message::DragWindow)
@@ -23,37 +35,51 @@ impl App {
.push(widget::Space::new().height(14))
.push(
text_input("Search settings", self.search.clone())
.id(widget::Id::new("settings-search"))
.on_input(Message::Search)
.font(self.font)
.style(visuals::input_style(palette))
.size(12),
)
.push(widget::Space::new().height(8));
let mut navigation = column([]).spacing(3);
for page in Page::ALL {
navigation = navigation.push(
button::custom(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::icon(
page,
if self.page == page {
palette.accent
} else {
palette.muted
},
))
.push(self.label(page.title(), 13.)),
)
.width(Length::Fill)
.padding([8, 8])
.class(visuals::navigation_style(palette, self.page == page))
.on_press(Message::Page(page)),
if compact {
sidebar = sidebar.push(self.settings_button(
"Close navigation",
"M6 6l12 12 M6 18L18 6",
Some(Message::Sidebar(false)),
false,
));
}
let mut navigation = column([]).spacing(16);
for (title, pages) in Page::NAVIGATION {
let mut section = column([]).spacing(3).push(
container(self.label(title, 11.).class(cosmic::theme::Text::Color(palette.muted))).padding([0, 8]),
);
for &page in pages {
let selected = self.page.navigation_page() == page;
section = section.push(
button::custom(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::icon(
page,
if selected { palette.accent } else { palette.muted },
))
.push(self.label(page.title(), 13.)),
)
.width(Length::Fill)
.padding([8, 8])
.class(visuals::navigation_style(palette, selected))
.name(page.title())
.on_press(Message::Page(if selected { self.page } else { page })),
);
}
navigation = navigation.push(section);
}
let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill)))
.width(204)
.id("settings-sidebar")
.width(if compact { 244 } else { 204 })
.height(Length::Fill)
.padding([20, 10])
.class(visuals::surface(palette.sidebar, 0.));
@@ -62,7 +88,7 @@ impl App {
.spacing(3)
.push(self.label(
if self.search.is_empty() {
self.page.title()
self.page.navigation_page().title()
} else {
"Search"
},
@@ -137,25 +163,31 @@ impl App {
let mut body = column([]).spacing(6);
if !self.search.is_empty() {
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) {
let pages = || {
Page::NAVIGATION
.into_iter()
.flat_map(|(_, pages)| pages.iter().copied())
};
if !pages().any(|p| p.matches(&self.search)) {
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
}
for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) {
for page in pages().filter(|p| p.matches(&self.search)) {
let destination = page.search_destination(&self.search);
body = body.push(
button::custom(
column([])
.spacing(3)
.push(self.label(page.title(), 16.))
.push(self.label(page.subtitle(), 12.)),
.push(self.label(destination.subtitle(), 12.)),
)
.width(Length::Fill)
.padding(10)
.class(visuals::button_style(palette, false))
.on_press(Message::Page(page)),
.on_press(Message::Page(destination)),
);
}
} else {
if matches!(self.page, Page::Bar | Page::Windows) {
if self.page == Page::Windows {
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
}
if self.page == Page::Appearance {
@@ -181,7 +213,12 @@ impl App {
if self.page == Page::Wallpaper {
body = body.push(self.wallpaper_controls());
}
let fields = if self.page == Page::Wallpaper {
if self.page == Page::Bar && self.panel_page != crate::panel_controls::PanelPage::Arrange {
body = body.push(self.panel_controls());
}
let fields = if self.page == Page::Bar {
Vec::new()
} else if self.page == Page::Wallpaper {
self.wallpaper_fields()
} else {
schema::fields(self.page)
@@ -427,11 +464,48 @@ impl App {
}
}
let mut content = column([]).spacing(8).push(
let mut page_heading = row([]).spacing(12).align_y(Alignment::Center);
if compact {
page_heading = page_heading.push(
container(self.settings_icon_button(
"Show settings navigation",
"M3 5h18v14H3z M9 5v14",
Some(Message::Sidebar(true)),
))
.id("settings-navigation-toggle"),
);
}
page_heading = page_heading.push(
widget::mouse_area(heading.width(Length::Fill))
.on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab),
);
let mut content = column([])
.spacing(if self.page == Page::Bar { 16 } else { 8 })
.push(page_heading);
if self.search.is_empty() && !self.page.subpages().is_empty() {
content = content.push(
container(
row(self.page.subpages().iter().map(|&(page, title)| {
button::custom(self.label(title, 13.))
.name(format!("{}: {title}", self.page.navigation_page().title()))
.padding([7, 12])
.class(visuals::panel_button(palette, self.page == page))
.on_press(Message::Page(page))
.into()
}))
.spacing(2),
)
.padding(3)
.class(visuals::surface(palette.sidebar, 9.)),
);
}
if self.page == Page::Bar && self.search.is_empty() {
if self.panel_page == crate::panel_controls::PanelPage::Arrange {
content = content.push(self.panel_navigation());
}
content = content.push(self.panel_preview());
}
if let Some(error) = &self.error {
content = content.push(
@@ -442,25 +516,32 @@ impl App {
);
}
content = content.push(
scrollable(
container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())]))
.padding(cosmic::iced::Padding {
top: 0.,
right: 12.,
bottom: 0.,
left: 2.,
})
.width(Length::Fill),
)
.id(widget::Id::new("settings-content"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(10)
.scroller_width(3),
))
.height(Length::Fill),
);
if self.page == Page::Bar
&& self.search.is_empty()
&& self.panel_page == crate::panel_controls::PanelPage::Arrange
{
content = content.push(self.panel_workspace());
} else {
content = content.push(
scrollable(
container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())]))
.padding(cosmic::iced::Padding {
top: 0.,
right: 12.,
bottom: 0.,
left: 2.,
})
.width(Length::Fill),
)
.id(widget::Id::new("settings-content"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(10)
.scroller_width(3),
))
.height(Length::Fill),
);
}
let footer = row([])
.spacing(4)
.align_y(Alignment::Center)
@@ -503,30 +584,72 @@ impl App {
));
let footer: Element<'_, Message> = if self.page == Page::Connections {
self.note("Connections are managed by the system. Passwords are not saved in your Ferese config.")
} else if self.page == Page::Bar {
container(
row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.note(if self.saving { "Saving…" } else { &self.status }))
.push(widget::Space::new().width(Length::Fill))
.push(self.settings_button(
"Undo",
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
(self.undo.is_some() && !self.saving).then_some(Message::Undo),
false,
))
.push(self.settings_button(
"Reload configuration",
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
(!self.saving).then_some(Message::Reload),
false,
)),
)
.padding([10, 0])
.into()
} else {
footer.into()
};
let view: Element<'_, Message> = row([])
.push(sidebar)
.push(
container(
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
.padding(cosmic::iced::Padding {
top: 20.,
right: 20.,
bottom: 8.,
left: 20.,
})
.max_width(840)
.width(Length::Fill)
.height(Length::Fill),
)
.width(Length::Fill)
.center_x(Length::Fill)
.height(Length::Fill)
.class(visuals::surface(palette.background, 0.)),
let mut drawer = None;
let sidebar: Element<'_, Message> = if compact {
if self.sidebar_open {
drawer = Some(sidebar);
}
widget::Space::new().width(0).into()
} else {
sidebar.into()
};
let view = row([]).push(sidebar).push(
container(
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
.padding(cosmic::iced::Padding {
top: 20.,
right: 20.,
bottom: 8.,
left: 20.,
})
.max_width(if self.page == Page::Bar { 1120 } else { 840 })
.width(Length::Fill)
.height(Length::Fill),
)
.into();
.width(Length::Fill)
.center_x(Length::Fill)
.height(Length::Fill)
.id("settings-page")
.class(visuals::surface(palette.background, 0.)),
);
// Keep the page mounted and full-width while compact navigation is open.
let mut view = widget::popover(view).modal(true);
if let Some(sidebar) = drawer {
let dismiss = widget::mouse_area(
container(widget::Space::new().width(Length::Fill).height(Length::Fill))
.class(visuals::surface(cosmic::iced::Color::BLACK.scale_alpha(0.20), 0.)),
)
.on_press(Message::Sidebar(false));
view = view
.position(widget::popover::Position::Point(cosmic::iced::Point::ORIGIN))
.popup(row([]).push(sidebar).push(dismiss).width(Length::Fill).height(height));
}
let view: Element<'_, Message> = view.into();
if self.profile_pages {
let page = self.page;
cosmic::iced::widget::sensor(view)
+886
View File
@@ -0,0 +1,886 @@
use ferese_config::panel::{Edge, ItemKind, Panel};
use crate::panel_edit::{Action, Destination, Zone};
use crate::{App, Element, Message, schema::Page, store, widget};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum PanelPage {
#[default]
Panels,
Arrange,
}
pub(super) fn initialize(_snapshot: &store::Snapshot) -> Result<store::Edit, String> {
let panel = Panel::default();
ferese_config::panel::validate(std::slice::from_ref(&panel))?;
Ok(store::set(
"panels",
serde_json::to_value(vec![panel]).map_err(|error| error.to_string())?,
))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CatalogItem(ItemKind);
impl std::fmt::Display for CatalogItem {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.0.label())
}
}
const CATALOG: [CatalogItem; 12] = [
CatalogItem(ItemKind::Overview),
CatalogItem(ItemKind::Workspaces {
style: ferese_config::panel::WorkspaceStyle::Dots,
}),
CatalogItem(ItemKind::FocusedWindow),
CatalogItem(ItemKind::Media),
CatalogItem(ItemKind::QuickSettings),
CatalogItem(ItemKind::Network),
CatalogItem(ItemKind::Audio),
CatalogItem(ItemKind::Recording),
CatalogItem(ItemKind::Notifications),
CatalogItem(ItemKind::Battery { percentage: true }),
CatalogItem(ItemKind::Clock),
CatalogItem(ItemKind::DisplayMode),
];
impl App {
pub(super) fn panel_navigation(&self) -> Element<'static, Message> {
self.settings_button(
"Back to Panels",
"M15 18l-6-6 6-6",
Some(Message::PanelPage(PanelPage::Panels)),
false,
)
}
pub(super) fn panel_controls(&self) -> Element<'_, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let edge = self.preview_panel().map_or(Edge::Top, |panel| panel.edge);
let mut rows = widget::column([]).spacing(12);
match self.panel_page {
PanelPage::Panels => {
rows = rows.push(
widget::container(
widget::row([])
.spacing(12)
.align_y(cosmic::iced::Alignment::Center)
.push(crate::visuals::action_icon(
if edge == Edge::Top {
"M3 5h18v14H3z M3 9h18"
} else {
"M3 5h18v14H3z M3 15h18"
},
palette.accent,
))
.push(
widget::column([])
.spacing(3)
.push(self.panel_heading(
if edge == Edge::Top { "Top panel" } else { "Bottom panel" },
14.,
))
.push(self.note("Shown on all displays"))
.width(cosmic::iced::Length::Fill),
)
.push(self.settings_button(
"Edit panel",
"M4 20l4-1 12-12-3-3L5 16z",
Some(Message::PanelPage(PanelPage::Arrange)),
false,
))
.push(
self.settings_icon_button(
"Remove custom panel",
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v5 M14 11v5",
self.draft
.item("panels")
.is_some()
.then(|| Message::Change(store::Edit::Unset("panels".into()))),
),
),
)
.padding(14)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)),
);
let preset = self.preview_panel().ok().and_then(|panel| panel.preset());
let preset_key = match preset {
Some(ferese_config::PanelPreset::Continuous) => "continuous",
Some(ferese_config::PanelPreset::Islands) => "islands",
None => "custom",
};
let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| {
let label = if position == Edge::Top { "Top" } else { "Bottom" };
widget::button::custom(self.label(label, 12.))
.name(format!("Panel position: {label}"))
.padding([8, 10])
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::panel_button(palette, edge == position))
.on_press_maybe((edge != position).then_some(Message::PanelEdit(Action::SetEdge(position))))
.into()
}))
.spacing(2);
rows = rows.push(
widget::container(
self.panel_appearance_row(
"Position",
"Choose the screen edge for the panel.",
widget::container(position)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.))
.into(),
),
)
.padding(16)
.class(crate::visuals::surface(palette.card, 14.)),
);
let appearance = widget::column([])
.spacing(16)
.push(self.panel_heading("Appearance", 15.))
.push(self.panel_appearance_row(
"Background",
"Apply a preset, then customize each group.",
self.panel_surface_choices(
preset_key,
&[("continuous", "Continuous"), ("islands", "Islands")],
move |key| {
Message::PanelEdit(Action::ApplyPreset(if key == "islands" {
ferese_config::PanelPreset::Islands
} else {
ferese_config::PanelPreset::Continuous
}))
},
),
))
.push(
self.panel_appearance_row(
"Default group surface",
"Groups can override this in their inspector.",
self.panel_surface_choices(
self.preview_panel()
.map_or(ferese_config::panel::GroupSurface::None, |panel| panel.group_surface)
.key(),
&[("none", "None"), ("inset", "Inset"), ("island", "Island")],
|key| {
Message::PanelEdit(Action::SetDefaultSurface(match key {
"inset" => ferese_config::panel::GroupSurface::Inset,
"island" => ferese_config::panel::GroupSurface::Island,
_ => ferese_config::panel::GroupSurface::None,
}))
},
),
),
)
.push(self.panel_divider())
.push(self.panel_opacity_controls())
.push(self.panel_divider())
.push(
self.panel_appearance_row(
"Group borders",
"Outline each group of items.",
widget::container(
ferese_theme::controls::switch(self.draft.boolean("panels.0.border", false), palette)
.name("Group borders")
.on_press(Message::PanelEdit(Action::SetBorder(
!self.draft.boolean("panels.0.border", false),
))),
)
.align_right(cosmic::iced::Length::Fill)
.into(),
),
)
.push(self.panel_divider())
.push(self.panel_radius_controls());
rows = rows.push(
widget::container(appearance)
.id("panel-appearance-settings")
.padding(16)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)),
);
let mut settings = widget::column([])
.spacing(4)
.push(widget::container(self.panel_heading("Size & spacing", 15.)).padding([4, 10]));
for field in crate::schema::fields(Page::Bar)
.into_iter()
.filter(|field| field.path.starts_with("panels.0.geometry."))
{
let mut field = field;
if let crate::schema::Kind::Range { default, .. } = &mut field.kind {
let geometry =
serde_json::to_value(self.preview_panel().map(|panel| panel.geometry).unwrap_or_default())
.unwrap();
*default = geometry[field.path.rsplit('.').next().unwrap()].as_f64().unwrap();
}
settings = settings.push(self.field(field));
}
rows = rows.push(
widget::container(settings)
.padding(12)
.class(crate::visuals::surface(palette.card, 14.)),
);
}
PanelPage::Arrange => return self.panel_workspace(),
}
rows.into()
}
pub(super) fn panel_side_margin_limit(&self) -> Option<i32> {
let display = self
.displays
.iter()
.filter(|display| display.logical_width.is_some())
.find(|display| display.focused)
.or_else(|| {
self.displays
.iter()
.filter(|display| display.logical_width.is_some())
.min_by_key(|display| display.logical_width)
})?;
if self.panel_preview_resolution.items.is_empty() {
return None;
}
let panel = self.preview_panel().ok()?;
Some(
self.panel_preview_resolution
.side_margin(4096, display.logical_width?, panel.geometry.inner_padding),
)
}
fn panel_appearance_row(
&self,
label: &str,
description: &str,
control: Element<'static, Message>,
) -> Element<'static, Message> {
widget::row([])
.spacing(24)
.align_y(cosmic::iced::Alignment::Center)
.push(
widget::column([])
.spacing(4)
.push(self.label(label.to_owned(), 13.))
.push(self.note(description))
.width(cosmic::iced::Length::Fill),
)
.push(widget::container(control).width(280))
.width(cosmic::iced::Length::Fill)
.into()
}
fn panel_opacity_controls(&self) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let tokens = &self.resolved.presented.tokens;
let inherited = if tokens.material.style == "translucent" {
tokens.material.opacity
} else {
1.
};
let value = self
.ranges
.get("panels.0.background_opacity")
.copied()
.unwrap_or_else(|| self.draft.number("panels.0.background_opacity", inherited) * 100.);
let custom = self.draft.item("panels.0.background_opacity").is_some();
let field = crate::Field::new(
"panels.0.background_opacity",
"Background opacity",
"",
crate::Kind::Range {
default: inherited * 100.,
min: 0.,
max: 100.,
step: 1.,
suffix: "%",
integer: false,
},
);
let release = field.clone();
let control = widget::row([])
.spacing(10)
.align_y(Alignment::Center)
.push(
crate::slider(0. ..=100., value, move |value| Message::Range(field.clone(), value))
.step(1.)
.class(ferese_theme::menus::slider(palette.accent, 1.))
.on_release(Message::Release(release))
.width(Length::Fill),
)
.push(
widget::container(self.label(format!("{value:.0}%"), 12.).align_x(Alignment::Center))
.padding([6, 0])
.width(48),
)
.push(self.settings_icon_button(
"Use shell opacity",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
custom.then_some(Message::PanelEdit(Action::ClearOpacity)),
));
self.panel_appearance_row(
"Background opacity",
if custom {
"Panel only. Text and icons stay opaque."
} else {
"Using shell opacity. Text and icons stay opaque."
},
control.into(),
)
}
fn panel_radius_controls(&self) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let shell_radius = self.resolved.presented.tokens.geometry.shell_radius as f32;
let custom = self
.draft
.item("panels.0.corner_radius")
.and_then(|value| serde_json::from_value::<ferese_config::panel::CornerRadii>(value.clone()).ok());
let radii = custom.unwrap_or_else(|| ferese_config::panel::CornerRadii::uniform(shell_radius));
let modes = widget::row([("Shell default", false), ("Custom", true)].map(|(label, selected)| {
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
.name(format!("Corner radius: {label}"))
.padding([8, 12])
.width(Length::Fill)
.class(crate::visuals::panel_button(palette, custom.is_some() == selected))
.on_press_maybe((custom.is_some() != selected).then(|| {
Message::PanelEdit(if selected {
Action::SetRadius(format!("{shell_radius}px"))
} else {
Action::ClearRadius
})
}))
.into()
}))
.spacing(2);
let modes = widget::container(modes)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.));
let mut corners = Vec::new();
// Match the physical corners: top row first, then bottom-left / bottom-right.
for (index, label, icon) in [
(0, "Top left", "M5 19V9a4 4 0 0 1 4-4h10"),
(1, "Top right", "M5 5h10a4 4 0 0 1 4 4v10"),
(3, "Bottom left", "M5 5v10a4 4 0 0 0 4 4h10"),
(2, "Bottom right", "M5 19h10a4 4 0 0 0 4-4V5"),
] {
let value = radii.0[index];
let tint = if custom.is_some() { palette.text } else { palette.muted };
corners.push(
widget::container(
widget::row([])
.spacing(8)
.align_y(Alignment::Center)
.push(crate::visuals::action_icon(icon, palette.muted))
.push(self.label(label, 12.).width(Length::Fill))
.push(
self.settings_icon_button(
&format!("Decrease {label} radius"),
"M5 12h14",
(custom.is_some() && value > 0.)
.then_some(Message::PanelEdit(Action::SetCorner(index, (value - 1.).max(0.)))),
),
)
.push(
self.label(format!("{value} px"), 12.)
.width(52)
.align_x(Alignment::Center)
.class(cosmic::theme::Text::Color(tint)),
)
.push(
self.settings_icon_button(
&format!("Increase {label} radius"),
"M12 5v14 M5 12h14",
custom
.is_some()
.then_some(Message::PanelEdit(Action::SetCorner(index, value + 1.))),
),
),
)
.padding([8, 10])
.width(Length::Fill)
.class(crate::visuals::surface(
ferese_theme::mix(palette.card, palette.sidebar, 0.5),
8.,
))
.into(),
);
}
let bottom_corners = corners.split_off(2);
let mut content = widget::column([])
.spacing(12)
.push(self.panel_appearance_row("Corner radius", "Set the rounding of each corner.", modes.into()))
.push(
widget::column([])
.spacing(8)
.push(
widget::flex_row(corners)
.min_item_width(220.)
.spacing(8)
.width(Length::Fill),
)
.push(
widget::flex_row(bottom_corners)
.min_item_width(220.)
.spacing(8)
.width(Length::Fill),
),
);
if self.preview_panel().is_ok_and(|panel| panel.geometry.edge_margin == 0) {
let bottom = self.preview_panel().is_ok_and(|panel| panel.edge == Edge::Bottom);
content = content.push(self.note(if bottom {
"Bottom corners stay square while the panel touches the bottom edge."
} else {
"Top corners stay square while the panel touches the top edge."
}));
}
content.into()
}
pub(super) fn panel_workspace(&self) -> Element<'_, Message> {
let panel = match self.preview_panel() {
Ok(panel) => panel,
Err(error) => return self.note(&error),
};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
cosmic::iced::widget::responsive(move |size| {
let editor = widget::column([])
.spacing(12)
.push(self.panel_heading("Arrangement", 15.))
.push(self.note("Drag items to reorder. Select an item or group to edit."))
.push(
widget::scrollable(self.panel_editor(&panel))
.id(widget::Id::new("settings-content"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(8)
.scroller_width(3),
))
.height(cosmic::iced::Length::Fill),
);
let inspector = widget::scrollable(self.panel_inspector(&panel))
.id(widget::Id::new("panel-inspector-scroll"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(8)
.scroller_width(3),
))
.height(cosmic::iced::Length::Fill);
widget::row([])
.spacing(16)
.push(
widget::container(editor)
.width(cosmic::iced::Length::Fill)
.height(cosmic::iced::Length::Fill),
)
.push(
widget::container(inspector)
.width((size.width * 0.48).clamp(220., 320.))
.height(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 12.)),
)
.height(cosmic::iced::Length::Fill)
.into()
})
.height(cosmic::iced::Length::Fill)
.into()
}
fn panel_inspector(&self, panel: &Panel) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let mut inspector = None;
for placement in Zone::ALL {
for (group_index, group) in placement.definition(panel).groups.iter().enumerate() {
if self.panel_group_selection.as_ref() == Some(&group.id) {
inspector = Some(self.group_inspector(panel, group, placement));
}
if let Some((item_index, item)) = group
.items
.iter()
.enumerate()
.find(|(_, item)| self.panel_selection.as_ref() == Some(&item.id))
{
inspector = Some(self.item_inspector(
panel,
item,
Destination {
zone: placement.key(),
group: group.id.clone(),
},
format!("panels.0.{}.groups.{group_index}.items.{item_index}", placement.key()),
));
}
}
}
widget::container(inspector.unwrap_or_else(|| {
widget::container(
widget::column([])
.spacing(12)
.align_x(cosmic::iced::Alignment::Center)
.push(crate::visuals::action_icon(
"M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4",
palette.muted,
))
.push(self.panel_heading("Make it your own", 15.))
.push(self.note(
"Select an item to adjust how it appears, or a group to edit its placement and spacing.",
)),
)
.padding([40, 8])
.into()
}))
.id("panel-inspector")
.padding(16)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.))
.into()
}
fn panel_surface_choices(
&self,
selected: &str,
options: &'static [(&'static str, &'static str)],
message: impl Fn(&'static str) -> Message,
) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let options = widget::row(options.iter().map(|&(key, label)| {
widget::button::custom(
widget::row([])
.spacing(8)
.align_y(Alignment::Center)
.push(crate::visuals::action_icon(
match key {
"continuous" => "M3 7h18v10H3z",
"none" => "M4 4l16 16 M4 20L20 4",
"inset" => "M3 5h18v14H3z M7 9h10v6H7z",
_ => "M2 7h5v10H2z M10 7h4v10h-4z M17 7h5v10h-5z",
},
palette.muted,
))
.push(self.label(label, 12.)),
)
.name(format!("Background: {label}"))
.padding([8, 10])
.width(Length::Fill)
.class(crate::visuals::panel_button(palette, selected == key))
.on_press(message(key))
.into()
}))
.spacing(2);
widget::container(options)
.padding(3)
.width(Length::Fill)
.class(crate::visuals::surface(palette.sidebar, 9.))
.into()
}
fn panel_editor(&self, panel: &Panel) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length};
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let mut zones = widget::column([]).spacing(18);
if panel.group_count() == ferese_config::panel::MAX_GROUPS {
zones = zones.push(self.label(format!("{}-group limit reached", ferese_config::panel::MAX_GROUPS), 12.));
}
for placement in Zone::ALL {
let zone = placement.definition(panel);
let mut groups = widget::column([]).spacing(8).push(
widget::row([])
.align_y(Alignment::Center)
.push(self.panel_heading(placement.to_string(), 13.).width(Length::Fill))
.push(
self.settings_button(
"Add group",
"M12 5v14 M5 12h14",
(panel.group_count() < ferese_config::panel::MAX_GROUPS)
.then_some(Message::PanelEdit(Action::AddGroup(placement))),
false,
),
),
);
let mut small_groups = Vec::new();
for group in &zone.groups {
let destination = Destination {
zone: placement.key(),
group: group.id.clone(),
};
let heading = widget::row([]).spacing(6).align_y(Alignment::Center).push(
widget::button::custom(
widget::row([])
.spacing(6)
.align_y(Alignment::Center)
.push(crate::visuals::action_icon("M3 7h18v13H3z M3 7V4h7l3 3", palette.muted))
.push(self.label(group_name(&group.id.0), 13.)),
)
.name(format!("Edit group {}", group.id.0))
.padding([7, 6])
.width(Length::Fill)
.class(crate::visuals::panel_button(
palette,
self.panel_group_selection.as_ref() == Some(&group.id),
))
.on_press(Message::PanelGroupSelect(group.id.clone())),
);
let add_item = crate::visuals::panel_select(
cosmic::iced::widget::pick_list(CATALOG.to_vec(), None::<CatalogItem>, move |kind| {
Message::PanelEdit(Action::Add(kind.0, destination.clone()))
})
.placeholder("Add item")
.font(self.font)
.text_size(12)
.padding([6, 8])
.width(Length::Shrink),
palette,
);
let mut items = Vec::new();
for item in &group.items {
let grip = widget::container(crate::visuals::action_icon(
"M9 5h.01 M15 5h.01 M9 12h.01 M15 12h.01 M9 19h.01 M15 19h.01",
palette.muted,
))
.padding([8, 0])
.id(crate::panel_drag::grip_id(&item.id));
let select = widget::button::custom(
widget::row([])
.spacing(6)
.align_y(Alignment::Center)
.push(item_icon(
item.kind,
if item.visible {
palette.text
} else {
palette.muted.scale_alpha(0.6)
},
))
.push(self.label(item.kind.label(), 13.).width(Length::Fill).class(
cosmic::theme::Text::Color(if item.visible {
palette.text
} else {
palette.muted.scale_alpha(0.6)
}),
)),
)
.name(format!("Edit {} · {}", item.kind.label(), item.id.0))
.padding([8, 6])
.width(Length::Fill)
.class(crate::visuals::panel_button(
palette,
self.panel_selection.as_ref() == Some(&item.id),
))
.on_press(Message::PanelSelect(item.id.clone()));
items.push(
widget::container(
widget::row([])
.align_y(Alignment::Center)
.push(widget::mouse_area(grip).interaction(cosmic::iced::mouse::Interaction::Grab))
.push(select),
)
.id(crate::panel_drag::item_id(&item.id))
.width(Length::Fill)
.into(),
);
}
let mut contents = widget::column([]).spacing(4).push(heading);
if items.is_empty() {
contents = contents.push(widget::container(self.note("Drop items here")).padding([8, 6]));
} else {
contents = contents.push(
widget::flex_row(items)
.min_item_width(148.)
.spacing(4)
.width(Length::Fill),
);
}
contents = contents.push(widget::container(add_item).padding([4, 0]));
let card: Element<'static, Message> = widget::container(contents)
.id(crate::panel_drag::group_id(&group.id))
.padding(6)
.width(Length::Fill)
.class(crate::visuals::surface(palette.card, 10.))
.into();
if group.items.len() > 2 {
if !small_groups.is_empty() {
groups = groups.push(
widget::flex_row(std::mem::take(&mut small_groups))
.min_item_width(210.)
.spacing(8)
.width(Length::Fill),
);
}
groups = groups.push(card);
} else {
small_groups.push(card);
}
}
if !small_groups.is_empty() {
groups = groups.push(
widget::flex_row(small_groups)
.min_item_width(210.)
.spacing(8)
.width(Length::Fill),
);
}
if zone.groups.is_empty() {
groups = groups.push(
widget::container(self.note("Drop an item here, or add a group."))
.id(crate::panel_drag::empty_zone_id(placement))
.width(Length::Fill)
.height(64)
.padding(12)
.class(crate::visuals::surface(palette.card, 10.)),
);
}
zones = zones.push(widget::container(groups).padding([0, 4]).width(Length::Fill));
}
crate::panel_drag::frame(zones.into(), panel, palette.accent)
}
}
pub(super) fn group_name(id: &str) -> String {
let mut name = id.replace(['-', '_'], " ");
if let Some(first) = name.get_mut(..1) {
first.make_ascii_uppercase();
}
name
}
pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::icon::Icon {
use ferese_theme::icons;
let source = match kind {
ItemKind::Overview => icons::FERESE,
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH,
ItemKind::Recording => icons::RECORD,
ItemKind::Notifications => icons::NOTIFICATIONS,
ItemKind::Battery { .. } => icons::BATTERY_75,
ItemKind::Clock => icons::CALENDAR,
ItemKind::DisplayMode => icons::DISPLAY_EXTEND,
ItemKind::Overflow => icons::CHEVRON_DOWN,
};
icons::tinted(source, 16, tint)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn side_margin_range_tracks_logical_display_width_and_measured_content() {
use cosmic::Application;
let mut app = App::init(
cosmic::app::Core::default(),
("/unused/range.kdl".into(), store::Snapshot::parse(String::new()), None),
)
.0;
assert_eq!(app.panel_side_margin_limit(), None);
app.displays = vec![crate::displays::Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
logical_width: Some(1440),
focused: true,
current: None,
modes: Vec::new(),
}];
app.panel_preview_resolution.items.insert(
ferese_config::panel::ItemId("clock".into()),
ferese_config::panel::layout::Placement::Overflow,
);
app.panel_preview_resolution.minimum_width = 600.;
assert_eq!(app.panel_side_margin_limit(), Some(408));
app.displays[0].logical_width = Some(960);
assert_eq!(app.panel_side_margin_limit(), Some(168));
app.panel_preview_resolution.minimum_width = 1000.;
assert_eq!(app.panel_side_margin_limit(), Some(0));
app.displays[0].logical_width = None;
assert_eq!(app.panel_side_margin_limit(), None);
}
#[test]
fn customization_uses_canonical_defaults_and_retains_other_fields() {
let mut snapshot = store::Snapshot::parse("// keep\nanimations { speed 0.8; }\n".into()).unwrap();
snapshot.edit(&initialize(&snapshot).unwrap()).unwrap();
let snapshot = store::Snapshot::parse(snapshot.source).unwrap();
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
assert_eq!(panels[0].preset(), Some(ferese_config::PanelPreset::Continuous));
assert_eq!(panels[0].geometry, ferese_config::panel::PanelGeometry::default());
assert!(panels[0].center.groups[0].items[0].visible);
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
assert!(snapshot.source.contains("// keep"));
let id = snapshot.string("panels.0.end.groups.0.items.2.id", "");
let mut changed = snapshot;
changed
.edit(&store::set("panels.0.end.groups.0.items.2.overflow", "always"))
.unwrap();
let changed = store::Snapshot::parse(changed.source).unwrap();
assert_eq!(changed.string("panels.0.end.groups.0.items.2.id", ""), id);
}
#[test]
fn editor_zones_wrap_without_putting_groups_outside_the_available_width() {
use cosmic::Application;
use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget as advanced};
use cosmic::iced::{Font, Pixels, Rectangle, Size};
struct Bounds {
ids: Vec<advanced::Id>,
groups: Vec<Rectangle>,
}
impl advanced::Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn advanced::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&advanced::Id>, bounds: Rectangle) {
if id.is_some_and(|id| self.ids.contains(id)) {
self.groups.push(bounds);
}
}
}
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 app = App::init(
crate::Core::default(),
("/unused/editor.kdl".into(), store::Snapshot::parse(String::new()), None),
)
.0;
let panel = Panel::default();
for width in [280., 560., 900.] {
let mut view = app.panel_editor(&panel);
let mut tree = advanced::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 2000.)),
);
let mut bounds = Bounds {
ids: Zone::ALL
.into_iter()
.flat_map(|zone| &zone.definition(&panel).groups)
.map(|group| crate::panel_drag::group_id(&group.id))
.collect(),
groups: Vec::new(),
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
assert_eq!(bounds.groups.len(), bounds.ids.len());
for group in bounds.groups {
assert!(
group.x >= -0.1 && group.x + group.width <= width + 0.1,
"{group:?} outside {width}"
);
}
}
}
}
+738
View File
@@ -0,0 +1,738 @@
//! Local item dragging. Pointer motion changes only the drop hint; release saves once.
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Border, Color, Event, Length, Point, Rectangle, Size, Vector, keyboard};
use cosmic::{Element, Renderer, Theme};
use ferese_config::panel::{GroupId, ItemId, Panel};
use crate::Message;
use crate::panel_edit::{Action, Destination, Zone};
pub(super) fn item_id(id: &ItemId) -> widget::Id {
widget::Id::new(format!("panel-editor:item:{}", id.0))
}
pub(super) fn grip_id(id: &ItemId) -> widget::Id {
widget::Id::new(format!("panel-editor:grip:{}", id.0))
}
pub(super) fn group_id(id: &GroupId) -> widget::Id {
widget::Id::new(format!("panel-editor:group:{}", id.0))
}
pub(super) fn empty_zone_id(zone: Zone) -> widget::Id {
widget::Id::new(format!("panel-editor:empty:{}", zone.key()))
}
#[derive(Clone)]
enum Region {
Grip(ItemId),
Item(ItemId, Destination),
Group(Destination),
Empty(Zone),
}
pub(super) fn frame<'a>(content: Element<'a, Message>, panel: &Panel, accent: Color) -> Element<'a, Message> {
let mut regions = Vec::new();
for zone in Zone::ALL {
if zone.definition(panel).groups.is_empty() {
regions.push((empty_zone_id(zone), Region::Empty(zone)));
}
for group in &zone.definition(panel).groups {
let destination = Destination {
zone: zone.key(),
group: group.id.clone(),
};
regions.push((group_id(&group.id), Region::Group(destination.clone())));
for item in &group.items {
regions.push((item_id(&item.id), Region::Item(item.id.clone(), destination.clone())));
regions.push((grip_id(&item.id), Region::Grip(item.id.clone())));
}
}
}
Element::new(Editor {
content,
regions,
accent,
})
}
struct Editor<'a> {
content: Element<'a, Message>,
regions: Vec<(widget::Id, Region)>,
accent: Color,
}
#[derive(Default)]
struct State {
drag: Option<Drag>,
}
struct Drag {
item: ItemId,
origin: Point,
cursor: Point,
source: Rectangle,
moved: bool,
target: Option<Target>,
}
#[derive(Clone)]
enum Target {
Group {
destination: Destination,
before: Option<ItemId>,
marker: Rectangle,
},
Empty(Zone, Rectangle),
}
impl Target {
fn action(self, item: ItemId) -> Action {
match self {
Self::Group {
destination, before, ..
} => Action::Place(item, destination, before),
Self::Empty(zone, _) => Action::PlaceInZone(item, zone),
}
}
fn marker(&self) -> Rectangle {
match self {
Self::Group { marker, .. } | Self::Empty(_, marker) => *marker,
}
}
}
struct Bounds<'a> {
regions: &'a [(widget::Id, Region)],
found: Vec<(Region, Rectangle)>,
}
impl widget::Operation for Bounds<'_> {
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 let Some((_, region)) = self.regions.iter().find(|(target, _)| Some(target) == id) {
self.found.push((region.clone(), bounds));
}
}
}
fn target_at(regions: &[(Region, Rectangle)], cursor: Point, viewport: &Rectangle) -> Option<Target> {
if !viewport.contains(cursor) {
return None;
}
for (region, bounds) in regions {
if !bounds.contains(cursor) {
continue;
}
match region {
Region::Empty(zone) => return Some(Target::Empty(*zone, *bounds)),
Region::Group(destination) => {
let items: Vec<_> = regions
.iter()
.filter_map(|(region, bounds)| match region {
Region::Item(id, group) if group.group == destination.group => Some((id, *bounds)),
_ => None,
})
.collect();
let next = items.iter().find(|(_, item)| {
cursor.y < item.y || cursor.y <= item.y + item.height && cursor.x < item.center_x()
});
let (before, marker) = next.map_or((None, *bounds), |(id, item)| {
(
Some((*id).clone()),
Rectangle {
x: item.x - 2.,
y: item.y,
width: 3.,
height: item.height,
},
)
});
return Some(Target::Group {
destination: destination.clone(),
before,
marker,
});
}
_ => {}
}
}
None
}
impl Widget<Message, Theme, Renderer> for Editor<'_> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut widget::Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
if tree.state.downcast_ref::<State>().drag.as_ref().is_some_and(|drag| {
!self
.regions
.iter()
.any(|(_, region)| matches!(region, Region::Grip(id) if id == &drag.item))
}) {
tree.state.downcast_mut::<State>().drag = None;
}
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let mut bounds = Bounds {
regions: &self.regions,
found: Vec::new(),
};
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, &mut bounds);
let state = tree.state.downcast_mut::<State>();
if (matches!(event, Event::Window(cosmic::iced::window::Event::Unfocused))
|| matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
))
&& state.drag.take().is_some()
{
shell.capture_event();
shell.request_redraw();
return;
}
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(cursor) = cursor.position().filter(|point| viewport.contains(*point))
&& let Some((Region::Grip(item), _)) = bounds
.found
.iter()
.find(|(region, bounds)| matches!(region, Region::Grip(_)) && bounds.contains(cursor))
{
let source = bounds
.found
.iter()
.find_map(|(region, bounds)| {
matches!(region, Region::Item(id, _) if id == item).then_some(*bounds)
})
.unwrap();
state.drag = Some(Drag {
item: item.clone(),
origin: cursor,
cursor,
source,
moved: false,
target: None,
});
shell.capture_event();
return;
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.drag.is_some() => {
let drag = state.drag.as_mut().unwrap();
if let Some(cursor) = cursor.position() {
drag.cursor = cursor;
drag.moved |= (cursor.x - drag.origin.x).hypot(cursor.y - drag.origin.y) >= 6.;
drag.target = if drag.moved {
target_at(&bounds.found, cursor, viewport)
} else {
None
};
} else {
drag.target = None;
}
shell.capture_event();
shell.request_redraw();
return;
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.drag.is_some() => {
let drag = state.drag.take().unwrap();
if drag.moved {
if let Some(target) = cursor
.position()
.and_then(|cursor| target_at(&bounds.found, cursor, viewport))
{
shell.publish(Message::PanelEdit(target.action(drag.item)));
}
} else if cursor
.position()
.is_some_and(|point| drag.source.contains(point) && viewport.contains(point))
{
shell.publish(Message::PanelSelect(drag.item));
}
shell.capture_event();
shell.request_redraw();
return;
}
_ => {}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
use cosmic::iced::advanced::Renderer as _;
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
if let Some(drag) = tree
.state
.downcast_ref::<State>()
.drag
.as_ref()
.filter(|drag| drag.moved)
{
renderer.with_layer(*viewport, |renderer| {
if let Some(target) = &drag.target {
renderer.fill_quad(
renderer::Quad {
bounds: target.marker(),
border: Border {
color: self.accent,
width: 2.,
radius: 6.into(),
..Border::default()
},
..renderer::Quad::default()
},
Color { a: 0.12, ..self.accent },
);
}
let ghost = Rectangle {
x: drag.source.x + drag.cursor.x - drag.origin.x,
y: drag.source.y + drag.cursor.y - drag.origin.y,
..drag.source
};
renderer.fill_quad(
renderer::Quad {
bounds: ghost,
border: Border {
color: self.accent,
width: 1.,
radius: 8.into(),
..Border::default()
},
..renderer::Quad::default()
},
Color { a: 0.12, ..self.accent },
);
});
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
if tree.state.downcast_ref::<State>().drag.is_some() {
mouse::Interaction::Grabbing
} else {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
}
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Message, Theme, Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{Snapshot, set};
use cosmic::iced::advanced::{clipboard, renderer::Headless};
use cosmic::iced::{Font, Pixels};
use cosmic::widget::{button, column, container, row, text};
fn panel() -> Panel {
serde_json::from_value(serde_json::json!({"id":"main", "start":{"groups":[{"id":"one", "items":[{"id":"a", "kind":"clock"},{"id":"b", "kind":"clock"}]}]}, "end":{"groups":[{"id":"two", "items":[{"id":"c", "kind":"clock"}]}]}})).unwrap()
}
fn view(panel: &Panel) -> Element<'static, Message> {
let zones = Zone::ALL.into_iter().map(|zone| {
let mut groups = column([]).spacing(8);
for group in &zone.definition(panel).groups {
let items = group.items.iter().map(|item| {
container(
row([])
.push(container(text("::")).width(20).height(32).id(grip_id(&item.id)))
.push(
button::custom(text(item.id.0.clone()))
.width(60)
.height(32)
.on_press(Message::PanelSelect(item.id.clone())),
),
)
.id(item_id(&item.id))
.into()
});
groups = groups.push(
container(column(items).spacing(8))
.width(140)
.padding(8)
.id(group_id(&group.id)),
);
}
if zone.definition(panel).groups.is_empty() {
groups = groups.push(
container(text("Drop here"))
.width(140)
.height(100)
.id(empty_zone_id(zone)),
);
}
groups.into()
});
frame(row(zones).spacing(20).into(), panel, Color::WHITE)
}
fn renderer() -> Renderer {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime
.block_on(<Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap()
}
fn send(
view: &mut Element<'_, Message>,
tree: &mut widget::Tree,
node: &layout::Node,
renderer: &Renderer,
event: Event,
point: Point,
messages: &mut Vec<Message>,
) {
let viewport = Rectangle::with_size(Size::new(500., 200.));
view.as_widget_mut().update(
tree,
&event,
Layout::new(node),
mouse::Cursor::Available(point),
renderer,
&mut clipboard::Null,
&mut Shell::new(messages),
&viewport,
);
}
#[test]
fn dragging_moves_across_groups_only_on_release_and_empty_zones_are_targets() {
let panel = panel();
let renderer = renderer();
let mut view = view(&panel);
let mut tree = widget::Tree::new(&view);
let viewport = Rectangle::with_size(Size::new(500., 200.));
let node = view
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut snapshot = Snapshot::parse(String::new()).unwrap();
snapshot.edit(&set("panels", serde_json::json!([panel]))).unwrap();
for (destination, empty) in [(Point::new(332., 20.), false), (Point::new(180., 20.), true)] {
let mut messages = Vec::new();
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::new(12., 20.),
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
destination,
&mut messages,
);
assert!(messages.is_empty(), "motion must not save");
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
destination,
&mut messages,
);
assert_eq!(messages.len(), 1);
let Message::PanelEdit(action) = messages.remove(0) else {
panic!("expected drop action")
};
if empty {
assert!(matches!(&action, Action::PlaceInZone(id, Zone::Center) if id.0 == "a"));
} else {
assert!(
matches!(&action, Action::Place(id, destination, Some(before)) if id.0 == "a" && destination.group.0 == "two" && before.0 == "c")
);
}
let mut draft = snapshot.clone();
for edit in crate::panel_edit::plan(&draft, action).unwrap() {
draft.edit(&edit).unwrap();
}
Snapshot::parse(draft.source).unwrap();
}
}
#[test]
fn cancellation_outside_viewport_and_focus_loss_never_save_and_clicks_select() {
let panel = panel();
let renderer = renderer();
let mut view = view(&panel);
let mut tree = widget::Tree::new(&view);
let viewport = Rectangle::with_size(Size::new(500., 200.));
let node = view
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
for cancel in [true, false] {
let mut messages = Vec::new();
let outside = Point::new(600., 20.);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::new(12., 20.),
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::CursorMoved { position: outside }),
outside,
&mut messages,
);
if cancel {
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Window(cosmic::iced::window::Event::Unfocused),
outside,
&mut messages,
);
}
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
outside,
&mut messages,
);
assert!(messages.is_empty());
}
for cancel in [
Event::Window(cosmic::iced::window::Event::Unfocused),
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
] {
let mut messages = Vec::new();
let destination = Point::new(332., 20.);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::new(12., 20.),
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
destination,
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
cancel,
destination,
&mut messages,
);
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
destination,
&mut messages,
);
assert!(messages.is_empty(), "cancellation must not save a valid drop");
}
for point in [Point::new(12., 20.), Point::new(60., 20.)] {
let mut messages = Vec::new();
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
send(
&mut view,
&mut tree,
&node,
&renderer,
Event::Mouse(event),
point,
&mut messages,
);
}
assert_eq!(messages.len(), 1);
assert!(matches!(&messages[0], Message::PanelSelect(id) if id.0 == "a"));
}
}
#[test]
fn drop_hint_follows_wrapped_reading_order_and_ignores_clipped_targets() {
let destination = Destination {
zone: "start",
group: GroupId("one".into()),
};
let regions = vec![
(
Region::Group(destination.clone()),
Rectangle {
x: 0.,
y: 0.,
width: 140.,
height: 100.,
},
),
(
Region::Item(ItemId("a".into()), destination.clone()),
Rectangle {
x: 8.,
y: 8.,
width: 60.,
height: 32.,
},
),
(
Region::Item(ItemId("b".into()), destination.clone()),
Rectangle {
x: 76.,
y: 8.,
width: 60.,
height: 32.,
},
),
(
Region::Item(ItemId("c".into()), destination),
Rectangle {
x: 8.,
y: 48.,
width: 60.,
height: 32.,
},
),
];
let viewport = Rectangle::with_size(Size::new(140., 100.));
for (point, expected) in [
(Point::new(20., 20.), Some("a")),
(Point::new(70., 20.), Some("b")),
(Point::new(130., 20.), Some("c")),
(Point::new(80., 70.), None),
] {
let Some(Target::Group { before, .. }) = target_at(&regions, point, &viewport) else {
panic!("expected group")
};
assert_eq!(before.as_ref().map(|id| id.0.as_str()), expected);
}
assert!(
target_at(
&regions,
Point::new(20., 70.),
&Rectangle::with_size(Size::new(140., 40.))
)
.is_none()
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,390 @@
use crate::panel_edit::{Action, Destination, Zone};
use crate::{App, Element, Field, Kind, Message, widget};
use cosmic::iced::{Alignment, Length};
use ferese_config::panel::{Group, Item, ItemKind, Panel, Representation};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Choice(&'static str, &'static str);
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.1)
}
}
impl App {
fn inspector_heading(&self, title: &str, subtitle: &str) -> Element<'static, Message> {
widget::column([])
.spacing(5)
.push(
widget::row([])
.align_y(Alignment::Center)
.push(self.panel_heading(title.to_owned(), 16.).width(Length::Fill))
.push(self.settings_icon_button(
"Deselect",
"M6 6l12 12 M6 18L18 6",
Some(Message::PanelClearSelection),
)),
)
.push(self.note(subtitle))
.push(self.panel_divider())
.into()
}
pub(super) fn panel_divider(&self) -> Element<'static, Message> {
let p = crate::visuals::Palette::from_resolved(&self.resolved.presented);
widget::container(widget::Space::new().height(1).width(Length::Fill))
.class(crate::visuals::surface(ferese_theme::mix(p.card, p.text, 0.09), 0.))
.into()
}
pub(super) fn item_inspector(
&self,
panel: &Panel,
item: &Item,
destination: Destination,
path: String,
) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let mut rows = widget::column([])
.spacing(12)
.push(self.inspector_heading(item.kind.label(), "Item settings"));
let item_id = item.id.clone();
rows = rows.push(
widget::column([])
.spacing(8)
.push(self.label("Placement", 13.))
.push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(
crate::panel_edit::destinations(panel),
Some(destination.clone()),
move |destination| Message::PanelEdit(Action::Move(item_id.clone(), destination)),
)
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
palette,
)),
);
rows = rows.push(self.panel_field(
item,
Field::new(format!("{path}.visible"), "Show item", "", Kind::Toggle(true)),
));
rows = rows.push(self.panel_divider());
if matches!(item.kind, ItemKind::Workspaces { .. }) {
rows = rows.push(self.workspace_style_picker(item));
rows = rows.push(self.panel_divider());
}
rows = rows.push(self.panel_field(
item,
Field::new(
format!("{path}.overflow"),
"Overflow",
"Choose when to use the overflow menu.",
Kind::Choice {
default: "auto",
choices: &[("never", "Keep visible"), ("auto", "When needed"), ("always", "Always")],
},
),
));
let representations = item.kind.representations();
if representations.len() > 1 {
let choices: &'static [(&'static str, &'static str)] = match representations {
[Representation::Wide, Representation::Icon] => &[("wide", "Full"), ("icon", "Icon")],
_ => &[("wide", "Full"), ("compact", "Compact"), ("icon", "Icon")],
};
rows = rows.push(self.panel_field(
item,
Field::new(
format!("{path}.representation"),
"Preferred size",
"Adapts to available space.",
Kind::Choice {
default: "wide",
choices,
},
),
));
}
if matches!(item.kind, ItemKind::Battery { .. }) {
rows = rows.push(self.panel_field(
item,
Field::new(
format!("{path}.percentage"),
"Battery percentage",
"",
Kind::Toggle(true),
),
));
}
let group = Zone::ALL
.into_iter()
.flat_map(|zone| &zone.definition(panel).groups)
.find(|group| group.id == destination.group)
.unwrap();
let index = group
.items
.iter()
.position(|candidate| candidate.id == item.id)
.unwrap();
rows.push(self.panel_divider())
.push(self.inspector_actions(
(index > 0).then(|| Message::PanelEdit(Action::Earlier(item.id.clone()))),
(index + 1 < group.items.len()).then(|| Message::PanelEdit(Action::Later(item.id.clone()))),
"Remove item",
Some(Message::PanelEdit(Action::Remove(item.id.clone()))),
))
.into()
}
fn workspace_style_picker(&self, item: &Item) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let ItemKind::Workspaces { style: selected } = item.kind else {
unreachable!()
};
let mut choices = widget::column([])
.spacing(6)
.push(self.label("Workspace style", 13.))
.push(self.note("A sample of active, occupied and empty workspaces."));
for style in ferese_config::panel::WorkspaceStyle::ALL {
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.);
choices = choices.push(
widget::button::custom(
widget::row![self.label(style.label(), 12.).width(Length::Fill), sample,]
.spacing(10)
.align_y(Alignment::Center),
)
.id(format!("workspace-style:{}", style.key()).into())
.name(format!("Workspace style: {}", style.label()))
.padding([8, 10])
.width(Length::Fill)
.class(crate::visuals::panel_button(palette, style == selected))
.on_press_maybe(
(style != selected)
.then(|| Message::PanelEdit(Action::Set(item.id.clone(), "style".into(), style.key().into()))),
),
);
}
choices.into()
}
pub(super) fn group_inspector(&self, panel: &Panel, group: &Group, placement: Zone) -> Element<'static, Message> {
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let id = group.id.clone();
let mut rows = widget::column([])
.spacing(12)
.push(self.inspector_heading(
&crate::panel_controls::group_name(&group.id.0),
"Group arrangement & spacing",
))
.push(
widget::column([])
.spacing(8)
.push(self.label("Placement", 13.))
.push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(Zone::ALL.to_vec(), Some(placement), move |zone| {
Message::PanelEdit(Action::MoveGroup(id.clone(), zone))
})
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
palette,
)),
);
let surface_choices = [
Choice("inherit", "Default"),
Choice("none", "None"),
Choice("inset", "Inset"),
Choice("island", "Island"),
];
let selected_surface = group.surface.map_or(surface_choices[0], |surface| {
*surface_choices.iter().find(|choice| choice.0 == surface.key()).unwrap()
});
let surface_id = group.id.clone();
rows = rows.push(self.label("Surface", 13.)).push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(surface_choices.to_vec(), Some(selected_surface), move |choice| {
Message::PanelEdit(if choice.0 == "inherit" {
Action::ClearGroupSurface(surface_id.clone())
} else {
Action::SetGroup(surface_id.clone(), "surface".into(), choice.0.into())
})
})
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
palette,
));
rows = rows.push(self.panel_divider());
let mut spacing = widget::column([]).spacing(12);
for (name, label, value, maximum) in [
("spacing", "Item spacing", group.spacing, 64.),
("padding_vertical", "Vertical padding", f32::from(group.padding[0]), 32.),
(
"padding_horizontal",
"Horizontal padding",
f32::from(group.padding[1]),
32.,
),
("island_padding", "Island padding", group.island_padding, 32.),
] {
if panel.resolved_surface(group) != ferese_config::panel::GroupSurface::Island && name == "island_padding" {
continue;
}
let change = |value: f32| {
let value = if name.starts_with("padding_") {
serde_json::json!(value as u16)
} else {
serde_json::json!(value)
};
Message::PanelEdit(Action::SetGroup(group.id.clone(), name.into(), value))
};
spacing = spacing.push(
widget::row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label(label, 13.).width(Length::Fill))
.push(
widget::row([])
.spacing(4)
.align_y(Alignment::Center)
.push(self.settings_icon_button(
&format!("Decrease {label}"),
"M5 12h14",
(value > 0.).then(|| change((value - 1.).max(0.))),
))
.push(
self.label(format!("{value} px"), 12.)
.width(48)
.align_x(Alignment::Center),
)
.push(self.settings_icon_button(
&format!("Increase {label}"),
"M12 5v14 M5 12h14",
(value < maximum).then(|| change((value + 1.).min(maximum))),
)),
),
);
}
rows = rows.push(spacing).push(self.panel_divider());
let index = placement
.definition(panel)
.groups
.iter()
.position(|candidate| candidate.id == group.id)
.unwrap();
rows = rows.push(
self.inspector_actions(
(index > 0).then(|| Message::PanelEdit(Action::EarlierGroup(group.id.clone()))),
(index + 1 < placement.definition(panel).groups.len())
.then(|| Message::PanelEdit(Action::LaterGroup(group.id.clone()))),
"Remove group",
group
.items
.is_empty()
.then(|| Message::PanelEdit(Action::RemoveGroup(group.id.clone()))),
),
);
if !group.items.is_empty() {
rows = rows.push(self.note("Move or remove this group’s items before removing the group."));
}
rows.into()
}
fn inspector_actions(
&self,
earlier: Option<Message>,
later: Option<Message>,
remove_label: &str,
remove: Option<Message>,
) -> Element<'static, Message> {
widget::row([])
.spacing(8)
.push(self.settings_icon_button("Up", "M6 15l6-6 6 6", earlier))
.push(self.settings_icon_button("Down", "M6 9l6 6 6-6", later))
.push(self.settings_icon_button(
remove_label,
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
remove,
))
.into()
}
fn panel_field(&self, item: &Item, field: Field) -> Element<'static, Message> {
let p = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let id = item.id.clone();
let name = field.path.rsplit('.').next().unwrap().to_owned();
let values = serde_json::to_value(item).expect("serializable panel item");
match field.kind {
Kind::Choice { default, choices } => {
let value = values.get(&name).and_then(serde_json::Value::as_str).unwrap_or(default);
if name == "overflow" {
let selected = choices
.iter()
.find(|(key, _)| *key == value)
.map(|&(key, label)| Choice(key, label));
return widget::column([])
.spacing(8)
.push(self.label(field.label, 13.))
.push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list(
choices
.iter()
.map(|&(key, label)| Choice(key, label))
.collect::<Vec<_>>(),
selected,
move |choice| {
Message::PanelEdit(Action::Set(id.clone(), name.clone(), choice.0.into()))
},
)
.font(self.font)
.text_size(13)
.padding([8, 10])
.width(Length::Fill),
p,
))
.push(self.note(&field.description))
.into();
}
let options = widget::row(choices.iter().map(|&(key, label)| {
widget::button::custom(self.label(label, 12.))
.name(format!("{}: {label}", field.label))
.padding([8, 4])
.width(Length::Fill)
.class(crate::visuals::panel_button(p, value == key))
.on_press(Message::PanelEdit(Action::Set(id.clone(), name.clone(), key.into())))
.into()
}))
.spacing(2)
.width(Length::Fill);
let mut rows = widget::column([]).spacing(8).push(self.label(field.label, 13.)).push(
widget::container(options)
.padding(3)
.class(crate::visuals::surface(p.sidebar, 9.)),
);
if !field.description.is_empty() {
rows = rows.push(self.note(&field.description));
}
rows.into()
}
Kind::Toggle(default) => {
let enabled = values
.get(&name)
.and_then(serde_json::Value::as_bool)
.unwrap_or(default);
widget::row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.label(field.label.clone(), 13.).width(Length::Fill))
.push(
ferese_theme::controls::switch(enabled, p)
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
.on_press(Message::PanelEdit(Action::Set(id, name, (!enabled).into()))),
)
.into()
}
_ => unreachable!("panel inspector fields are choices or toggles"),
}
}
}
+865
View File
@@ -0,0 +1,865 @@
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{button, column, container, row};
use ferese_config::panel::layout::{Placement, Resolution};
use ferese_config::panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone};
use ferese_theme::panel::{Sample, Spacing, frame};
use crate::{App, Element, Message, panel_controls, visuals};
const SAMPLE_AVAILABILITY: Availability = Availability {
media: true,
network: true,
audio: true,
notifications: true,
battery: true,
external_display: true,
};
fn available(item: &Item) -> bool {
item.available(SAMPLE_AVAILABILITY)
}
impl App {
pub(super) fn panel_preview(&self) -> Element<'_, Message> {
let panel = match self.preview_panel() {
Ok(panel) => panel,
Err(error) => return self.note(&format!("Panel preview unavailable: {error}")),
};
let mut rows = column([])
.spacing(12)
.push(
row([])
.align_y(Alignment::Center)
.push(self.label("Composition preview", 14.).width(Length::Fill))
.push(self.note("Sample content")),
)
.push(
container(self.panel_preview_frame(panel.clone()))
.padding(cosmic::iced::Padding {
top: if panel.edge == ferese_config::panel::Edge::Top {
10.
} else {
26.
},
bottom: if panel.edge == ferese_config::panel::Edge::Top {
26.
} else {
10.
},
left: 12.,
right: 12.,
})
.width(Length::Fill)
.class(visuals::surface(
visuals::Palette::from_resolved(&self.resolved.presented).sidebar,
10.,
)),
);
if self.panel_preview_overflow {
let mut items = column([]).spacing(2);
for id in &self.panel_preview_resolution.overflow {
if let Some(item) = panel.item(id).filter(|item| available(item)) {
items = items.push(self.settings_button(
&format!("{} · {}", item.kind.label(), item.id.0),
"M6 9l6 6 6-6",
Some(Message::PanelSelect(item.id.clone())),
self.panel_selection.as_ref() == Some(id),
));
}
}
if !self.panel_preview_resolution.overflow.is_empty() {
rows = rows.push(container(crate::scrollable(items).height(Length::Shrink)).max_height(160.));
}
}
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
container(rows)
.id("panel-composition-preview")
.padding(16)
.width(Length::Fill)
.class(visuals::surface(palette.card, 14.))
.into()
}
pub(super) fn preview_panel(&self) -> Result<Panel, String> {
let panels = match self.draft.item("panels") {
Some(panels) => panels.clone(),
None => {
let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)? else {
unreachable!()
};
panels
}
};
serde_json::from_value::<Vec<Panel>>(panels)
.map_err(|error| error.to_string())?
.into_iter()
.next()
.ok_or_else(|| "No panel configured.".into())
}
fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> {
let mut samples = Vec::new();
for zone in [&panel.center, &panel.start, &panel.end] {
for item in zone
.groups
.iter()
.flat_map(|group| &group.items)
.filter(|item| available(item))
{
for representation in item.representations() {
samples.push(Sample {
id: item.id.clone(),
representation,
minimum: match item.kind {
ItemKind::Workspaces { .. } => Some(24.),
ItemKind::Media => Some(112.),
_ => None,
},
view: self.preview_control(item, representation, None),
});
}
}
}
let overflow = panel.overflow_group();
samples.push(Sample {
id: ItemId("_overflow".into()),
representation: Representation::Icon,
minimum: None,
view: self.preview_control(&overflow.items[0], Representation::Icon, None),
});
let overflow_width = 2. * f32::from(overflow.padding[1])
+ if panel.resolved_surface(&overflow) == ferese_config::panel::GroupSurface::Island {
2. * overflow.island_padding
} else {
0.
};
let continuous = panel.surface == ferese_config::panel::PanelSurface::Solid;
let corners = panel
.resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32)
.at_edge(panel.edge, panel.geometry.edge_margin == 0)
.0;
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
let measured_panel = panel.clone();
let content = frame(
std::borrow::Cow::Owned(measured_panel),
samples,
Spacing {
overflow_decoration: overflow_width,
zone_gap: 32.,
},
self.panel_preview_resolution.clone(),
move |resolution| {
let start = self.preview_zone(&panel, &panel.start, resolution);
let center = self.preview_zone(&panel, &panel.center, resolution);
let mut end = self.preview_zone(&panel, &panel.end, resolution);
if !resolution.overflow.is_empty() {
let mut trigger = Resolution::default();
trigger.items.insert(
ItemId("_overflow".into()),
Placement::Visible {
representation: Representation::Icon,
width: resolution.overflow_trigger_width,
},
);
let trigger = self.preview_zone(
&panel,
&Zone {
groups: vec![overflow.clone()],
spacing: 0.,
},
&trigger,
);
let has_end = panel.end.groups.iter().flat_map(|group| &group.items)
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.));
end = row([])
.spacing(if has_end { panel.end.spacing } else { 0. })
.align_y(Alignment::Center)
.push(trigger)
.push(end)
.into();
}
cosmic::iced::widget::stack![
row([])
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.push(start)
.push(cosmic::widget::Space::new().width(Length::Fill))
.push(end),
container(center).center_x(Length::Fill).center_y(Length::Fill),
]
.into()
},
Message::PanelPreviewResolved,
);
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
container(content)
.width(Length::Fill)
.height(48)
.clip(true)
.class(cosmic::theme::Container::custom(move |_| {
let mut style = ferese_theme::controls::surface_appearance(
cosmic::iced::Color {
a: opacity,
..palette.card
},
0.,
);
style.border.radius = corners.into();
if !continuous {
style.background = None;
}
style
}))
.into()
}
fn preview_zone(&self, panel: &Panel, zone: &Zone, resolution: &Resolution) -> Element<'static, Message> {
let mut groups = row([]).spacing(zone.spacing).align_y(Alignment::Center);
for group in &zone.groups {
let mut controls = row([]).align_y(Alignment::Center);
let mut count = 0;
for item in &group.items {
let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id) else {
continue;
};
if *width <= 0. {
continue;
}
if count > 0 {
controls =
controls.push(cosmic::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
}
controls = controls.push(self.preview_control(item, *representation, Some(*width)));
count += 1;
}
if count > 0 {
groups = groups.push(self.preview_group(panel, group, controls.into()));
}
}
groups.into()
}
fn preview_group(
&self,
panel: &Panel,
group: &Group,
controls: Element<'static, Message>,
) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let surface = panel.resolved_surface(group);
let radius = panel
.resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32)
.at_edge(
panel.edge,
surface == ferese_config::panel::GroupSurface::Island && panel.geometry.edge_margin == 0,
)
.0;
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
let border = panel.border;
let content: Element<'static, Message> = container(controls)
.padding(group.padding)
.class(cosmic::theme::Container::custom(move |_| {
ferese_theme::panel::group_border(palette.muted.scale_alpha(0.4), radius, border)
}))
.into();
if surface == ferese_config::panel::GroupSurface::None {
return content;
}
let parent_solid = panel.surface == ferese_config::panel::PanelSurface::Solid;
container(content)
.padding(if surface == ferese_config::panel::GroupSurface::Island {
[2., group.island_padding]
} else {
[0., 0.]
})
.class(cosmic::theme::Container::custom(move |_| {
let mut style = ferese_theme::controls::surface_appearance(
cosmic::iced::Color {
a: if parent_solid { opacity * 0.08 } else { opacity },
..if parent_solid { palette.text } else { palette.card }
},
0.,
);
style.border.radius = radius.into();
style
}))
.into()
}
fn preview_control(
&self,
item: &Item,
representation: Representation,
width: Option<f32>,
) -> Element<'static, Message> {
use ferese_theme::icons;
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
if let ItemKind::Workspaces { style } = item.kind {
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
.name("Edit Workspaces")
.padding([4, 2])
.height(28)
.on_press(Message::PanelSelect(item.id.clone()))
.class(preview_button(palette, self.panel_selection.as_ref() == Some(&item.id)));
return container(control)
.id(format!("preview-item:{}", item.id.0))
.width(width.map_or(Length::Shrink, Length::Fixed))
.clip(true)
.into();
}
let source = match item.kind {
ItemKind::Overview => icons::FERESE,
ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA,
ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH,
ItemKind::Recording => icons::RECORD,
ItemKind::Notifications => icons::NOTIFICATIONS,
ItemKind::Battery { .. } => icons::BATTERY_75,
ItemKind::Clock => icons::CALENDAR,
ItemKind::DisplayMode => icons::DISPLAY,
ItemKind::Overflow => icons::CHEVRON_DOWN,
};
let label = match (item.kind, representation) {
(ItemKind::Workspaces { .. }, _) => "1 2 3",
(ItemKind::FocusedWindow, _) => "Settings",
(ItemKind::Media, Representation::Wide) => "Song title · Artist ▷",
(ItemKind::Media, Representation::Compact) => "Song title",
(ItemKind::Battery { percentage: true }, Representation::Wide) => "75%",
(ItemKind::Clock, Representation::Wide) => "Wed, 7 Oct · 14:35",
(ItemKind::Clock, Representation::Compact) => "14:35",
_ => "",
};
let mut content = row([])
.height(Length::Fill)
.spacing(5)
.align_y(Alignment::Center)
.push(container(icons::tinted(source, 16, palette.text)).id(format!("preview-icon:{}", item.id.0)));
if !label.is_empty() {
content = content.push(
self.label(label, 12.)
.wrapping(cosmic::iced::widget::text::Wrapping::None),
);
}
let action = if item.kind == ItemKind::Overflow {
Message::PanelPreviewOverflow
} else {
Message::PanelSelect(item.id.clone())
};
let control: Element<'static, Message> = button::custom(content)
.name(format!("Edit {}", item.id.0))
.padding([4, 7])
.height(28)
.on_press(action)
.class(preview_button(
palette,
if item.kind == ItemKind::Overflow {
self.panel_preview_overflow
} else {
self.panel_selection.as_ref() == Some(&item.id)
},
))
.into();
container(control)
.id(format!("preview-item:{}", item.id.0))
.width(width.map_or(Length::Shrink, Length::Fixed))
.clip(true)
.into()
}
}
/// Preview controls sit on a shared surface. Only interaction paints a background;
/// keep their hit area and border geometry identical in every state.
fn preview_button(palette: visuals::Palette, selected: bool) -> cosmic::theme::Button {
let paint = move |hovered: bool, pressed: bool, focused: bool| {
let background = if selected {
Some(
palette
.accent
.scale_alpha(if hovered || pressed { 0.26 } else { 0.16 })
.into(),
)
} else if pressed {
Some(palette.text.scale_alpha(0.14).into())
} else if hovered || focused {
Some(palette.text.scale_alpha(0.08).into())
} else {
None
};
cosmic::widget::button::Style {
shape: Some(cosmic::iced::border::Shape::Continuous),
background,
text_color: Some(palette.text),
icon_color: Some(palette.text),
border_radius: 6.into(),
border_width: 1.,
border_color: if focused {
palette.accent
} else if selected {
palette.accent.scale_alpha(0.6)
} else {
cosmic::iced::Color::TRANSPARENT
},
outline: None,
outline_width: 0.,
..Default::default()
}
};
cosmic::theme::Button::Custom {
active: Box::new(move |focused, _| paint(false, false, focused)),
hovered: Box::new(move |focused, _| paint(true, false, focused)),
pressed: Box::new(move |focused, _| paint(true, true, focused)),
disabled: Box::new(move |_| paint(false, false, false)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::Application;
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget};
use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size};
#[derive(Default)]
struct Bounds {
targets: Vec<(widget::Id, ItemId)>,
items: Vec<(ItemId, Rectangle)>,
}
impl widget::Operation for Bounds {
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 let Some((_, item)) = self.targets.iter().find(|(target, _)| Some(target) == id) {
self.items.push((item.clone(), bounds));
}
}
}
#[test]
fn preview_measures_adapts_and_targets_exact_instances_without_writing_config() {
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 app = App::init(
crate::Core::default(),
(
"/unused/preview-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
let panel = app.preview_panel().unwrap();
let source = app.draft.source.clone();
let mut tree = None;
for width in [2400., 400., 180., 2400.] {
let mut view = app.panel_preview_frame(panel.clone());
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
tree.diff(view.as_widget_mut());
let viewport = Rectangle::with_size(Size::new(width, 80.));
let node = view
.as_widget_mut()
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut messages = Vec::new();
for _ in 0..3 {
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!(messages.len(), 1, "unchanged allocation is published once");
let Message::PanelPreviewResolved(resolution) = &messages[0] else {
panic!("expected resolution")
};
assert!(resolution.fits(width));
let clock = ItemId("clock".into());
if width == 2400. {
assert!(matches!(
resolution.items.get(&clock),
Some(Placement::Visible {
representation: Representation::Wide,
..
})
));
assert!(resolution.overflow.is_empty());
} else {
assert!(!resolution.overflow.is_empty());
}
let mut bounds = Bounds {
targets: resolution
.items
.keys()
.cloned()
.chain(std::iter::once(ItemId("_overflow".into())))
.map(|id| (widget::Id::new(format!("preview-item:{}", id.0)), id))
.collect(),
..Default::default()
};
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut bounds);
for (id, bounds) in &bounds.items {
let expected = if id.0 == "_overflow" {
resolution.overflow_trigger_width
} else {
let Placement::Visible { width, .. } = resolution.items[id] else {
panic!("overflowed item was rendered")
};
width
};
assert!(
(bounds.width - expected).abs() < 0.1,
"{id:?}: {bounds:?}, expected {expected}"
);
assert!(
bounds.x >= -0.1 && bounds.x + bounds.width <= width + 0.1,
"{bounds:?} outside {width}"
);
}
if width == 2400. {
let target = bounds.items.iter().find(|(id, _)| id == &clock).unwrap().1.center();
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
view.as_widget_mut().update(
tree,
&Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(Point::new(target.x, target.y)),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
assert_eq!(
messages
.iter()
.filter(|message| matches!(message, Message::PanelSelect(id) if id == &clock))
.count(),
1
);
}
}
assert_eq!(app.draft.source, source);
assert!(app.pending.is_empty());
}
#[test]
fn preview_title_stays_centered_and_separate_from_asymmetric_side_groups() {
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 app = App::init(
crate::Core::default(),
(
"/unused/preview-centering.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
for background in [
ferese_config::PanelPreset::Continuous,
ferese_config::PanelPreset::Islands,
] {
let mut panel = Panel::from_preset(ferese_config::PanelPreset::Islands);
panel.apply_preset(background);
// Match a three-group layout with media in navigation and a larger status group.
let media = panel.end.groups[0].items.remove(0);
panel.start.groups[0].items.push(media);
let title = &panel.center.groups[0].items[0];
let mut sample = app.preview_control(title, Representation::Wide, None);
let mut sample_tree = widget::Tree::new(&sample);
let natural = sample
.as_widget_mut()
.layout(
&mut sample_tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(2400., 80.)),
)
.size()
.width;
for width in [560., 668., 800., 1200.] {
let mut view = app.panel_preview_frame(panel.clone());
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 80.)),
);
let mut bounds = Bounds {
targets: [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.map(|item| (widget::Id::new(format!("preview-item:{}", item.id.0)), item.id.clone()))
.chain(std::iter::once((
widget::Id::new("preview-item:_overflow"),
ItemId("_overflow".into()),
)))
.collect(),
..Default::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
let center = bounds
.items
.iter()
.find(|(id, _)| id == &title.id)
.expect("sample title remains visible")
.1;
assert!((center.center_x() - width / 2.).abs() < 0.1, "{center:?} at {width}");
assert!((center.width - natural).abs() < 0.1, "sample title must not be clipped");
for (id, side) in &bounds.items {
if id == &title.id {
continue;
}
let gap = if side.center_x() < center.center_x() {
center.x - side.x - side.width
} else {
side.x - center.x - center.width
};
assert!(
gap >= 31.9,
"{background:?} at {width}: {} crowds title ({gap}px)",
id.0
);
}
}
}
}
#[test]
fn preview_icons_are_centered_in_their_hit_areas_and_across_groups() {
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 mut app = App::init(
crate::Core::default(),
(
"/unused/alignment.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
for background in [
ferese_config::PanelPreset::Continuous,
ferese_config::PanelPreset::Islands,
] {
let panel = Panel::from_preset(background);
let ids: Vec<_> = [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.map(|item| item.id.clone())
.chain(std::iter::once(ItemId("_overflow".into())))
.collect();
for width in [180., 400., 2400.] {
for selected in [None, Some(ItemId("battery".into()))] {
app.panel_selection = selected;
let mut view = app.panel_preview_frame(panel.clone());
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 80.)),
);
let mut controls = Bounds {
targets: ids
.iter()
.map(|id| (widget::Id::new(format!("preview-item:{}", id.0)), id.clone()))
.collect(),
..Default::default()
};
let mut icons = Bounds {
targets: ids
.iter()
.map(|id| (widget::Id::new(format!("preview-icon:{}", id.0)), id.clone()))
.collect(),
..Default::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut controls);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut icons);
assert!(!controls.items.is_empty());
assert_eq!(controls.items.len(), icons.items.len());
for (id, icon) in &icons.items {
let control = controls.items.iter().find(|(item, _)| item == id).unwrap().1;
assert!(
(icon.center_y() - control.center_y()).abs() < 0.01,
"{background:?} at {width}: {} icon {icon:?} is off-center in {control:?}",
id.0
);
assert!(
(icon.center_y() - node.size().height / 2.).abs() < 0.01,
"{} is not on the panel centerline",
id.0
);
}
}
}
}
}
#[test]
fn preview_hover_and_selection_stay_inside_the_control_and_idle_is_transparent() {
use cosmic::iced::Color;
use cosmic::iced::advanced::renderer::{self, Renderer as _};
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.),
Some("tiny-skia"),
))
.unwrap();
let mut app = App::init(
crate::Core::default(),
(
"/unused/hover.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0;
let item = Item::new("battery", ItemKind::Battery { percentage: false });
let viewport = Rectangle::with_size(Size::new(96., 48.));
let backdrop = Color::from_rgb8(35, 47, 61);
let theme = app.native_palette.native_theme();
let mut baseline: Option<Vec<u8>> = None;
let mut baseline_bounds = None;
for state in ["idle", "hovered", "pressed", "selected"] {
app.panel_selection = (state == "selected").then(|| item.id.clone());
let mut view: Element<'_, Message> = container(app.preview_control(&item, Representation::Icon, None))
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
let mut tree = widget::Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let mut bounds = Bounds {
targets: vec![(widget::Id::new("preview-item:battery"), item.id.clone())],
..Default::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
let target = bounds.items[0].1;
if let Some(baseline) = baseline_bounds {
assert_eq!(target, baseline);
} else {
baseline_bounds = Some(target);
}
let cursor = if state == "hovered" || state == "pressed" {
mouse::Cursor::Available(target.center())
} else {
mouse::Cursor::Unavailable
};
if state == "pressed" {
view.as_widget_mut().update(
&mut tree,
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Layout::new(&node),
cursor,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut Vec::new()),
&viewport,
);
}
renderer.reset(viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: 1.,
},
Layout::new(&node),
cursor,
&viewport,
);
let pixels = Headless::screenshot(&mut renderer, Size::new(96, 48), 1., backdrop);
if let Some(baseline) = &baseline {
let mut changed = 0;
for (index, (before, after)) in baseline.chunks_exact(4).zip(pixels.chunks_exact(4)).enumerate() {
if before != after {
changed += 1;
assert!(
target.contains(Point::new((index % 96) as f32 + 0.5, (index / 96) as f32 + 0.5)),
"{state} paints outside the hit area"
);
}
}
assert!(changed > 40, "{state} has no visible feedback");
} else {
// Above the glyph but inside the button: untouched backdrop, not
// an opaque rectangle that hides the panel's chosen opacity.
let index = ((target.y as usize + 2) * 96 + target.center_x() as usize) * 4;
assert_eq!(&pixels[index..index + 4], &pixels[..4]);
baseline = Some(pixels);
}
}
}
#[test]
fn invalid_source_is_reported_before_preview_layout() {
let app = App::init(
crate::Core::default(),
(
"/unused/preview-test.kdl".into(),
crate::store::Snapshot::parse("panel main { group-surface unknown; }".into()),
None,
),
)
.0;
assert!(app.error.is_some());
assert!(app.preview_panel().is_ok());
let _ = app.panel_preview();
assert!(app.pending.is_empty());
}
}
+67 -48
View File
@@ -17,6 +17,53 @@ pub enum Page {
}
impl Page {
pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [
(
"Desktop",
&[Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar],
),
("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]),
(
"System",
&[
Self::Displays,
Self::Connections,
Self::Notifications,
Self::LockScreen,
Self::Startup,
],
),
];
/// Subpages share one entry in navigation and search.
pub fn navigation_page(self) -> Self {
match self {
Self::Motion => Self::Windows,
Self::Shortcuts => Self::Keyboard,
_ => self,
}
}
pub fn subpages(self) -> &'static [(Self, &'static str)] {
match self.navigation_page() {
Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")],
Self::Keyboard => &[(Self::Keyboard, "Keyboard & mouse"), (Self::Shortcuts, "Shortcuts")],
_ => &[],
}
}
pub fn search_destination(self, query: &str) -> Self {
if !self.matches_own(query) {
for &(page, _) in self.subpages() {
if page.matches_own(query) {
return page;
}
}
}
self
}
#[cfg(test)]
pub const ALL: [Self; 14] = [
Self::Appearance,
Self::Accessibility,
@@ -40,7 +87,7 @@ impl Page {
Self::Accessibility => "Accessibility",
Self::Wallpaper => "Wallpaper",
Self::Desktop => "Desktop widgets",
Self::Bar => "Menu bar",
Self::Bar => "Panel & Shell",
Self::Notifications => "Notifications",
Self::LockScreen => "Lock screen",
Self::Windows => "Windows",
@@ -58,14 +105,14 @@ impl Page {
Self::Appearance => "A desktop that feels like yours.",
Self::Accessibility => "Make the desktop easier to see and use.",
Self::Wallpaper => "Set the scene for your workspace.",
Self::Desktop => "A clock that feels at home on your wallpaper.",
Self::Bar => "Everything you need, within reach.",
Self::Desktop => "Clock and sticky notes on your desktop.",
Self::Bar => "Arrange panel items and groups.",
Self::Notifications => "Stay informed on your terms.",
Self::LockScreen => "Your desktop, safely put away.",
Self::Windows => "Make room for the way you work.",
Self::Motion => "Find your rhythm.",
Self::Keyboard => "Fine-tune the everyday details.",
Self::Shortcuts => "Your most-used actions, a keystroke away.",
Self::Windows => "Window layouts, spacing and focus effects.",
Self::Motion => "Animations and transition speed.",
Self::Keyboard => "Keyboard layouts, pointer behavior and touchpad gestures.",
Self::Shortcuts => "Keyboard shortcuts and swipe actions.",
Self::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.",
Self::Connections => "Wi-Fi networks and Bluetooth devices.",
@@ -98,6 +145,10 @@ impl Page {
}
pub fn matches(self, query: &str) -> bool {
self.matches_own(query) || self.subpages().iter().any(|(page, _)| page.matches_own(query))
}
fn matches_own(self, query: &str) -> bool {
let query = query.to_lowercase();
self.title().to_lowercase().contains(&query)
|| self.subtitle().to_lowercase().contains(&query)
@@ -853,34 +904,8 @@ pub fn fields(page: Page) -> Vec<Field> {
),
],
Page::Bar => vec![
choice(
"status.bar_layout",
"Layout",
"Use one continuous background or separate islands around the controls.",
"continuous",
&[("continuous", "Continuous"), ("islands", "Islands")],
),
range(
"status.bar_island_padding",
"Island side padding",
"Space on each side between the island background and its bordered controls.",
RangeSpec {
default: f64::from(ferese_config::default_bar_island_padding()),
min: 0.0,
max: 32.0,
step: 1.0,
suffix: " px",
integer: false,
},
),
toggle(
"status.window_title",
"Focused window title",
"Show the focused window title in the center of the bar when space allows.",
true,
),
range(
"theme.geometry.top_bar_height",
"panels.0.geometry.height",
"Height",
"Keeps icon and text sizes unchanged.",
RangeSpec {
@@ -893,9 +918,9 @@ pub fn fields(page: Page) -> Vec<Field> {
},
),
range(
"theme.geometry.top_bar_margin_top",
"Top margin",
"Set to zero for an edge-to-edge bar.",
"panels.0.geometry.edge_margin",
"Edge margin",
"Space from the selected screen edge. Set to zero for a flush panel.",
RangeSpec {
default: 0.0,
min: 0.0,
@@ -906,22 +931,22 @@ pub fn fields(page: Page) -> Vec<Field> {
},
),
range(
"theme.geometry.top_bar_margin_horizontal",
"panels.0.geometry.side_margins",
"Side margins",
"Space between the bar and the display edges.",
"Inset from both edges; stops before extra controls overflow.",
RangeSpec {
default: 0.0,
min: 0.0,
max: 32.0,
max: 4096.0,
step: 1.0,
suffix: " px",
integer: true,
},
),
range(
"theme.geometry.top_bar_window_gap",
"panels.0.geometry.window_clearance",
"Window clearance",
"Space below the menu bar.",
"Space between the panel and application windows.",
RangeSpec {
default: 0.0,
min: 0.0,
@@ -931,12 +956,6 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: true,
},
),
toggle(
"status.battery_percentage",
"Battery percentage",
"Show the remaining charge beside the battery icon.",
true,
),
],
Page::Windows => vec![
toggle(
+748 -8
View File
@@ -33,9 +33,121 @@ impl Operation for ScrollPosition {
}
}
#[test]
fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
#[derive(Default)]
struct Bounds {
preview: Option<Rectangle>,
viewport: Option<Rectangle>,
}
impl Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("panel-composition-preview")) {
self.preview = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.viewport = Some(bounds);
}
}
}
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 mut app = App::init(
Core::default(),
("/unused/editor.kdl".into(), Snapshot::parse(String::new()), None),
)
.0;
app.page = Page::Bar;
app.panel_page = panel_controls::PanelPage::Arrange;
app.draft
.edit(&panel_controls::initialize(&app.draft).unwrap())
.unwrap();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(740., 650.)),
);
let mut before = Bounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut before);
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(200.) },
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
let mut position = ScrollPosition::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut position);
assert!(position.offset > 0. && position.content_height > position.viewport_height);
let mut after = Bounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut after);
assert_eq!(before.preview, after.preview);
let preview = after.preview.unwrap();
let viewport = after.viewport.unwrap();
assert!(preview.y + preview.height <= viewport.y);
assert!(preview.height > 0. && viewport.height > 0.);
}
#[test]
#[ignore = "release scroll/render sample; requires headless renderer backends"]
fn appearance_scroll_preserves_progress_and_settles_visibility() {
#[derive(Default)]
struct Labels {
labels: Vec<Rectangle>,
viewport: Option<Rectangle>,
translation: Vector,
}
impl Operation for Labels {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
if matches!(
text,
"Shell corner radius" | "Shell opacity" | "Background blur" | "Interface font" | "Focus border"
) {
self.labels.push(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.viewport = Some(bounds);
self.translation = translation;
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
@@ -66,6 +178,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
let mut previous = 0.;
let mut render_times = Vec::new();
let mut visibility_messages = 0;
let mut checked_labels = 0;
for frame in 0..60 {
let mut messages = Vec::new();
@@ -84,7 +197,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels {
x: 0.,
y: if frame < 28 { -12. } else { 12. },
y: if frame < 28 { -48. } else { 48. },
},
})
};
@@ -147,17 +260,39 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
cursor,
&bounds,
);
drop(view);
let mut labels = Labels::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut labels);
let started = std::time::Instant::now();
std::hint::black_box(Headless::screenshot(
&mut renderer,
physical,
scale,
theme.cosmic().bg_color().into(),
));
let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
if (4..52).contains(&frame) {
render_times.push(started.elapsed().as_micros());
}
if let Some(viewport) = labels.viewport {
for bounds in labels.labels {
let bounds = bounds - labels.translation;
if bounds.y < viewport.y + 2. || bounds.y + bounds.height > viewport.y + viewport.height - 2. {
continue;
}
let mut ink = 0;
for y in (bounds.y * scale).ceil() as u32..((bounds.y + bounds.height) * scale).floor() as u32 {
for x in
(bounds.x * scale).ceil() as u32..((bounds.x + bounds.width) * scale).floor() as u32
{
let index = ((y * physical.width + x) * 4) as usize;
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
ink += 1;
}
}
}
assert!(
ink > 12,
"missing field text: backend={backend} split={split} frame={frame} bounds={bounds:?}"
);
checked_labels += 1;
}
}
drop(view);
for message in messages {
if matches!(message, Message::RowVisibility(..)) {
@@ -170,6 +305,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
}
render_times.sort_unstable();
assert!(checked_labels > 20, "scroll check must include visible field labels");
eprintln!(
"backend={backend} split={split} render_median_us={} p95_us={} visibility_messages={visibility_messages}",
render_times[render_times.len() / 2],
@@ -178,3 +314,607 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
}
}
}
#[test]
fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
// Native startup preloads text fonts; headless renderers skip that step.
// Supply a bundled proportional font even on minimal CI images.
cosmic::iced::advanced::graphics::text::font_system()
.write()
.unwrap()
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
"../../../assets/fonts/Comfortaa-Regular.otf"
)));
#[derive(Default)]
struct Labels {
labels: Vec<(String, Rectangle, Option<Rectangle>)>,
traversal_start: usize,
}
impl Operation for Labels {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
let start = self.labels.len();
children(self);
self.traversal_start = start;
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
if matches!(
text,
"Top panel"
| "Shown on all displays"
| "Position"
| "Background"
| "Default group surface"
| "Background opacity"
| "Group borders"
| "Corner radius"
| "Size & spacing"
| "Side margins"
| "Window clearance"
| "Arrangement"
| "Start"
| "Center"
| "End"
| "Add group"
| "Add item"
| "Overview"
| "Workspaces"
| "Battery"
| "Placement"
| "Show item"
| "Overflow"
| "Preferred size"
| "Battery percentage"
| "Make it your own"
| "Surface"
| "Item spacing"
| "Vertical padding"
| "Horizontal padding"
| "Undo"
| "Reload configuration"
) {
self.labels.push((text.into(), bounds, None));
}
}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
viewport: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
// Scrollable reports its offset after traversing its content.
for (_, bounds, clip) in &mut self.labels[self.traversal_start..] {
*bounds = *bounds - translation;
*clip = Some(clip.map_or(viewport, |clip| {
(clip - translation).intersection(&viewport).unwrap_or_default()
}));
}
}
}
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.),
Some("wgpu"),
))
.expect("GPU panel rendering requires a hardware or software WGPU adapter");
for width in [740., 1100.] {
for scale in [1., 1.75] {
for selection in ["panels", "items", "battery", "status"] {
let mut app = App::init(
Core::default(),
(
"/unused/panel-text.kdl".into(),
Snapshot::parse("theme { mode dark; family \"ferese-blue\"; }".into()),
Some(InitialPage::Bar),
),
)
.0;
app.font = Font::with_name("Comfortaa");
app.panel_page = if selection == "panels" {
panel_controls::PanelPage::Panels
} else {
panel_controls::PanelPage::Arrange
};
app.panel_selection = (selection == "battery").then(|| ferese_config::panel::ItemId("battery".into()));
app.panel_group_selection =
(selection == "status").then(|| ferese_config::panel::GroupId("status".into()));
let bounds = Rectangle::with_size(Size::new(width, 650.));
let physical = Size::new((width * scale) as u32, (650. * scale) as u32);
let theme = app.native_palette.native_theme();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
let mut checked = 0;
for offset in [0., 180., 400.] {
for id in ["settings-content", "panel-inspector-scroll"] {
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new(id),
cosmic::iced::widget::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
}
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: scale as f64,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
if let Some(mut overlay) =
view.as_widget_mut()
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
{
let overlay = overlay.as_overlay_mut();
let node = overlay.layout(&renderer, bounds.size());
renderer.with_layer(bounds, |renderer| {
overlay.draw(
renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: scale as f64,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
);
});
}
let mut labels = Labels::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
for (label, rect, clip) in labels.labels {
let clip = clip.unwrap_or(bounds);
if rect.width <= 0.
|| rect.height <= 0.
|| rect.x < clip.x
|| rect.y < clip.y + 2.
|| rect.x + rect.width > clip.x + clip.width
|| rect.y + rect.height > clip.y + clip.height - 2.
{
continue;
}
let mut ink = 0;
for y in (rect.y * scale).ceil() as u32..((rect.y + rect.height) * scale).floor() as u32 {
for x in (rect.x * scale).ceil() as u32..((rect.x + rect.width) * scale).floor() as u32 {
let index = ((y * physical.width + x) * 4) as usize;
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
ink += 1;
}
}
}
assert!(
ink > 12,
"missing panel text {label:?}: state={selection} width={width} scale={scale} offset={offset} bounds={rect:?}"
);
checked += 1;
}
}
assert!(checked >= 6, "state={selection} must exercise visible panel labels");
}
}
}
}
/// Reproducible sketches rendered from the actual widgets, with no live services.
#[test]
#[ignore = "writes Panel & Shell review images to /tmp/ferese-panel-review-{backend}"]
fn render_panel_review_states() {
let backend = std::env::var("FERESE_REVIEW_BACKEND").unwrap_or_else(|_| "tiny-skia".into());
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.),
Some(&backend),
))
.unwrap();
let directory = format!("/tmp/ferese-panel-review-{backend}");
std::fs::create_dir_all(&directory).unwrap();
let mut app = App::init(
Core::default(),
(
"/unused/review.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
),
)
.0;
app.status = "Changes save automatically".into();
for appearance in ["light", "dark"] {
let theme_config = Snapshot::parse(format!("theme {{ mode \"{appearance}\"; }}")).unwrap();
let theme = ferese_config::theme::resolve(
&theme_config.doc,
std::path::Path::new("/tmp"),
"2026-10-07T12:00:00Z".parse().unwrap(),
|_| unreachable!(),
)
.unwrap()
.theme;
app.resolved.presented = theme.clone();
app.resolved.theme = theme;
app.font = fonts::interface_font(&app.resolved.presented.tokens.typography.font_family);
app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented);
for (name, tab, item, group) in [
("panels", panel_controls::PanelPage::Panels, None, None),
("mixed-surfaces", panel_controls::PanelPage::Panels, None, None),
(
"surface-inspector",
panel_controls::PanelPage::Arrange,
None,
Some("title"),
),
("corners", panel_controls::PanelPage::Panels, None, None),
("preview-hover", panel_controls::PanelPage::Panels, None, None),
("navigation", panel_controls::PanelPage::Arrange, None, None),
("shortcuts", panel_controls::PanelPage::Panels, None, None),
("motion", panel_controls::PanelPage::Panels, None, None),
("items", panel_controls::PanelPage::Arrange, None, None),
(
"workspace-styles",
panel_controls::PanelPage::Arrange,
Some("workspaces"),
None,
),
(
"selected-item",
panel_controls::PanelPage::Arrange,
Some("battery"),
None,
),
(
"selected-group",
panel_controls::PanelPage::Arrange,
None,
Some("status"),
),
] {
app.page = match name {
"shortcuts" => Page::Shortcuts,
"motion" => Page::Motion,
_ => Page::Bar,
};
app.draft = Snapshot::parse(String::new()).unwrap();
if matches!(name, "mixed-surfaces" | "surface-inspector") {
let mut panel = ferese_config::panel::Panel::from_preset(ferese_config::PanelPreset::Islands);
panel.center.groups[0].surface = Some(ferese_config::panel::GroupSurface::None);
panel.end.groups[0].surface = Some(ferese_config::panel::GroupSurface::Inset);
panel.geometry.edge_margin = 6;
app.draft
.edit(&crate::store::set("panels", serde_json::to_value(vec![panel]).unwrap()))
.unwrap();
}
if name == "corners" {
for edit in crate::panel_edit::plan(
&app.draft,
crate::panel_edit::Action::SetRadius("4px 8px 12px 16px".into()),
)
.unwrap()
{
app.draft.edit(&edit).unwrap();
}
app.draft = Snapshot::parse(app.draft.source.clone()).unwrap();
}
app.panel_page = tab;
app.sidebar_open = name == "navigation";
app.panel_selection = item.map(|id| ferese_config::panel::ItemId(id.into()));
app.panel_group_selection = group.map(|id| ferese_config::panel::GroupId(id.into()));
for (width, height) in [(1100, 860), (740, 650)] {
let bounds = Rectangle::with_size(Size::new(width as f32, height as f32));
let theme = app.native_palette.native_theme();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
#[derive(Default)]
struct HoverTarget(Option<Rectangle>);
impl Operation for HoverTarget {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("preview-item:battery")) {
self.0 = Some(bounds);
}
}
}
let cursor = if name == "preview-hover" {
let mut target = HoverTarget::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut target);
mouse::Cursor::Available(target.0.expect("battery is visible in the review preview").center())
} else {
mouse::Cursor::Unavailable
};
// Responsive content is laid out before drawing, as it is in the app.
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: 1.,
},
Layout::new(&node),
cursor,
&bounds,
);
if let Some(mut overlay) =
view.as_widget_mut()
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
{
let overlay = overlay.as_overlay_mut();
let layout = overlay.layout(&renderer, bounds.size());
renderer.with_layer(bounds, |renderer| {
overlay.draw(
renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: 1.,
},
Layout::new(&layout),
mouse::Cursor::Unavailable,
);
});
}
let pixels = Headless::screenshot(
&mut renderer,
Size::new(width, height),
1.,
theme.cosmic().bg_color().into(),
);
let path = format!("{directory}/{appearance}-{name}-{width}.png");
image::save_buffer(&path, &pixels, width, height, image::ColorType::Rgba8).unwrap();
eprintln!("{path}");
}
}
}
}
#[test]
fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
#[derive(Default, Debug, PartialEq)]
struct Panes {
preview: Option<Rectangle>,
editor: Option<Rectangle>,
inspector: Option<Rectangle>,
}
impl Operation for Panes {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("panel-composition-preview")) {
self.preview = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.editor = Some(bounds);
}
if id == Some(&widget::Id::new("panel-inspector-scroll")) {
self.inspector = Some(bounds);
}
}
}
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();
for width in [740., 1100.] {
let mut app = App::init(
Core::default(),
(
"/unused/layout.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
),
)
.0;
app.panel_page = panel_controls::PanelPage::Arrange;
let mut tree = None;
let mut initial = None;
for selection in [
None,
Some(Message::PanelSelect(ferese_config::panel::ItemId("battery".into()))),
Some(Message::PanelGroupSelect(ferese_config::panel::GroupId(
"status".into(),
))),
Some(Message::PanelClearSelection),
] {
if let Some(selection) = selection {
let _ = app.update(selection);
}
let mut view = app.page_view();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
);
if initial.is_none() {
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(180.) },
);
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut scroll);
}
let mut panes = Panes::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut panes);
let mut position = ScrollPosition::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut position);
assert_eq!(position.offset, 180., "selection moved the arrangement");
let editor = panes.editor.unwrap();
let inspector = panes.inspector.unwrap();
assert!(editor.x + editor.width < inspector.x, "panes overlap");
assert!(inspector.x + inspector.width <= width, "inspector leaves window");
assert!(editor.height > 100. && inspector.height > 100.);
if let Some(initial) = &initial {
assert_eq!(&panes, initial);
} else {
initial = Some(panes);
}
assert!(app.pending.is_empty() && !app.saving && app.draft.item("panels").is_none());
}
}
}
#[test]
fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
#[derive(Default)]
struct Bounds {
sidebar: Option<Rectangle>,
page: Option<Rectangle>,
toggle: Option<Rectangle>,
}
impl Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("settings-sidebar")) {
self.sidebar = Some(bounds);
}
if id == Some(&widget::Id::new("settings-page")) {
self.page = Some(bounds);
}
if id == Some(&widget::Id::new("settings-navigation-toggle")) {
self.toggle = Some(bounds);
}
}
}
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 mut app = App::init(
Core::default(),
(
"/unused/sidebar.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
),
)
.0;
app.panel_page = panel_controls::PanelPage::Arrange;
let mut tree = None;
for width in [1100., 959., 740., 960., 1100.] {
let mut view = app.page_view();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
);
let mut bounds = Bounds::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut bounds);
let compact = width < 960.;
assert_eq!(bounds.sidebar.is_none(), compact);
assert_eq!(bounds.toggle.is_some(), compact);
assert_eq!(bounds.page.unwrap().x, if compact { 0. } else { 204. });
assert_eq!(bounds.page.unwrap().width, if compact { width } else { width - 204. });
}
let _ = app.update(Message::Sidebar(true));
assert!(app.sidebar_open);
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let size = Size::new(740., 650.);
let node = view
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, size));
let mut messages = Vec::new();
{
let viewport = Rectangle::with_size(size);
let mut overlay = view
.as_widget_mut()
.overlay(&mut tree, Layout::new(&node), &renderer, &viewport, Vector::ZERO)
.expect("compact navigation overlay");
let overlay = overlay.as_overlay_mut();
let layout = overlay.layout(&renderer, size);
let mut bounds = Bounds::default();
overlay.operate(Layout::new(&layout), &renderer, &mut bounds);
assert_eq!(bounds.sidebar.unwrap().width, 244.);
assert!(
bounds.page.is_none(),
"background must be excluded from modal focus navigation"
);
overlay.update(
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Layout::new(&layout),
mouse::Cursor::Available(Point::new(500., 300.)),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
);
assert!(
messages
.iter()
.any(|message| matches!(message, Message::Sidebar(false)))
);
}
drop(view);
for message in messages {
let _ = app.update(message);
}
assert!(!app.sidebar_open);
let _ = app.update(Message::Sidebar(true));
let _ = app.update(Message::Page(Page::Bar));
assert!(!app.sidebar_open, "reselecting the current page closes navigation too");
assert!(app.pending.is_empty() && !app.saving);
}
+5
View File
@@ -21,6 +21,11 @@ impl Snapshot {
return Err("Configuration exceeds the 60 KiB limit.".into());
}
let doc = Document::parse(&source).map_err(|e| e.to_string())?;
if let Some(value) = doc.get("panels") {
let panels: Vec<ferese_config::panel::Panel> =
serde_json::from_value(value.clone()).map_err(|error| error.to_string())?;
ferese_config::panel::validate(&panels)?;
}
Ok(Self { source, doc })
}
+54 -3
View File
@@ -8,6 +8,57 @@ use crate::Message;
use crate::schema::Page;
use crate::store::{Edit, Snapshot, set};
/// Keep focus distinct from selection and hover for keyboard navigation.
pub fn panel_button(p: Palette, selected: bool) -> cosmic::theme::Button {
use cosmic::iced::border::Shape;
let paint = move |hover: bool, focused: bool, enabled: bool| {
let highlighted = hover || focused;
let background = if selected && enabled {
ferese_theme::mix(p.card, p.accent, if highlighted { 0.28 } else { 0.16 })
} else if highlighted && enabled {
ferese_theme::mix(p.card, p.text, 0.09)
} else {
p.card
};
cosmic::widget::button::Style {
shape: Some(Shape::Continuous),
background: Some(background.into()),
text_color: Some(if enabled {
ferese_theme::foreground(background, p.text)
} else {
p.muted
}),
icon_color: Some(if enabled { p.text } else { p.muted }),
border_radius: 8.into(),
border_width: if focused {
2.
} else if selected {
1.
} else {
0.
},
border_color: p.accent.scale_alpha(if focused { 1. } else { 0.45 }),
outline_width: 0.,
outline: None,
..Default::default()
}
};
cosmic::theme::Button::Custom {
active: Box::new(move |focused, _| paint(false, focused, true)),
hovered: Box::new(move |focused, _| paint(true, focused, true)),
pressed: Box::new(move |focused, _| paint(true, focused, true)),
// A selected segment can be inert without losing its selection indicator.
disabled: Box::new(move |_| paint(false, false, selected)),
}
}
pub fn panel_select<'a>(
content: impl Into<Element<'a, Message>>,
p: Palette,
) -> cosmic::widget::Container<'a, Message, cosmic::Theme> {
cosmic::widget::container(content).class(surface(ferese_theme::mix(p.card, p.text, 0.035), 7.))
}
pub fn color(value: &str, fallback: Color) -> Color {
ferese_theme::parse_color(value).unwrap_or(fallback)
}
@@ -205,9 +256,9 @@ fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) ->
let muted = hex(p.muted);
let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.);
let radius = snapshot.number("theme.geometry.window_radius", 14.).clamp(0., 28.);
let bar_y = snapshot.number("theme.geometry.top_bar_margin_top", 0.) * 0.5 + 12.;
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65;
let bar_y = snapshot.number("panels.0.geometry.edge_margin", 0.) * 0.5 + 12.;
let bar_margin = snapshot.number("panels.0.geometry.side_margins", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("panels.0.geometry.height", 28.) * 0.65;
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
snapshot
@@ -380,18 +380,10 @@ impl App {
} else {
self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.)
};
let label_color = if preview.handle.is_none() {
palette.text
} else if appearance == Appearance::Light {
cosmic::iced::Color::from_rgb8(24, 33, 44)
} else {
cosmic::iced::Color::WHITE
};
let label = widget::container(self.label(name, 12.).class(cosmic::theme::Text::Color(label_color)))
.padding(8)
.width(Length::Fill)
.height(Length::Fill);
let tile = cosmic::iced::widget::stack([image, label.into()]);
let label = widget::container(self.label(name, 12.).class(cosmic::theme::Text::Color(palette.text)))
.padding([7, 10])
.width(Length::Fill);
let tile = column([]).push(image).push(label);
defaults = defaults.push(
button::custom(tile)
+2 -1
View File
@@ -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
File diff suppressed because it is too large. Load diff
+285 -29
View File
@@ -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
View File
@@ -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 = &current.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);
+3 -6
View File
@@ -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
View File
@@ -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);
+17 -1
View File
@@ -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 {
+111 -74
View File
@@ -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}"
);
}
}
+1
View File
@@ -0,0 +1 @@
pub(crate) use ferese_config::panel::*;
+1
View File
@@ -0,0 +1 @@
pub(crate) use ferese_config::panel::layout::*;
+1 -1
View File
@@ -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");
}
}
+122 -118
View File
@@ -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 => {
+156 -35
View File
@@ -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));
}
}
+21 -6
View File
@@ -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![
+43 -4
View File
@@ -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,
+89 -21
View File
@@ -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]];
+11 -2
View File
@@ -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())
+918
View File
@@ -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());
}
}