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

+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)