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

@@ -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"),
}
}
}