Add panel-wide styles and the Morph hub (#26)

Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph.

- Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews.
- Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance.
- Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance.
- Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names.

Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-12 01:31:34 +01:00
1 parent 42c3b290dd
commit e22f0ebef9
131 files changed
+12562 -1634

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-layout = { path = "../../crates/ferese-layout" }
ferese-panel = { path = "../../crates/ferese-panel" }
ferese-animation = { path = "../../crates/ferese-animation" }
ferese-theme-client = { path = "../../crates/ferese-theme-client" }
ferese-ipc = { path = "../../crates/ferese-ipc" }
+178 -180
View File
@@ -1,11 +1,13 @@
#[cfg(test)]
use super::Background;
use super::{
Background, BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment,
bar_icon, button, color, container, control, motion, row, status_ui, text, theme, window,
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, bar_icon, button,
color, container, control, motion, row, status_ui, theme, window,
};
use crate::panel::{Availability, Item, ItemKind, Panel, Zone};
#[cfg(test)]
use ferese_config::PanelPreset;
use ferese_config::panel::{GroupSurface, PanelSurface};
use ferese_panel::PanelStyle;
use ferese_panel::{GroupSurface, PanelSurface};
use ferese_theme::panel as adaptive;
pub(super) mod presentation;
@@ -37,16 +39,17 @@ impl FereseShell {
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
if self.bar_hidden(id) {
return container(text("")).width(Length::Fill).height(Length::Fill).into();
return container(self.render.text(""))
.width(Length::Fill)
.height(Length::Fill)
.into();
}
let panel = self.panel_for_bar(id);
if panel.style == ferese_panel::PanelStyle::Morph {
return self.view_hub(id);
}
let panel = &self.config.panels[0];
let mut shell_theme = self.config.theme.for_bar();
shell_theme.bar_background[3] = (shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8;
let corners = panel
.resolved_radius(shell_theme.bar_radius)
.at_edge(panel.edge, panel.geometry.edge_margin == 0);
let mode = panel.surface;
let islands = mode == PanelSurface::None;
let output = self.outputs.iter().find(|output| output.bar == id);
let effects = output.and_then(|output| output.effects.as_ref());
let compositor_material = effects.is_some();
@@ -74,7 +77,7 @@ impl FereseShell {
id,
item,
None,
panel.resolved_surface(group) == GroupSurface::Island,
panel.group_surface() == GroupSurface::Island,
representation,
)
.0;
@@ -103,25 +106,34 @@ impl FereseShell {
id,
&overflow_group.items[0],
None,
panel.resolved_surface(&overflow_group) == GroupSurface::Island,
panel.group_surface() == GroupSurface::Island,
Representation::Icon,
)
.0,
});
// Measure the control itself; reserve group decoration separately.
let overflow_width = 2.0 * f32::from(overflow_group.padding[1])
+ if panel.resolved_surface(&overflow_group) == GroupSurface::Island {
+ if panel.group_surface() == GroupSurface::Island {
2.0 * overflow_group.island_padding
} else {
0.0
};
let materials = [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.chain(std::iter::once(&overflow_group))
.filter(|group| islands && panel.resolved_surface(group) != GroupSurface::None)
.map(|group| (group_material_id(panel, group), 1.))
.collect();
let groups = [
(ferese_panel::shape::ZonePosition::Start, &panel.start),
(ferese_panel::shape::ZonePosition::Center, &panel.center),
(ferese_panel::shape::ZonePosition::End, &panel.end),
]
.into_iter()
.flat_map(|(position, zone)| {
zone.groups
.iter()
.map(move |group| (group_material_id(panel, group), position))
})
.chain(std::iter::once((
group_material_id(panel, &overflow_group),
ferese_panel::shape::ZonePosition::End,
)))
.collect();
let content = adaptive::frame(
std::borrow::Cow::Borrowed(panel),
samples,
@@ -183,7 +195,7 @@ impl FereseShell {
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0));
if end_has_items {
row![trigger, end]
.spacing(panel.end.spacing)
.spacing(panel.zone_spacing(&panel.end))
.align_y(cosmic::iced::Alignment::Center)
.into()
} else {
@@ -204,18 +216,46 @@ impl FereseShell {
let content = container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding([0, panel.geometry.inner_padding.round() as u16])
.class(theme::Container::custom(move |_| {
let mut style = bar_style(shell_theme, compositor_material, islands);
style.border.radius = corners.0.into();
style
}))
.padding([0, panel.resolved_geometry().inner_padding.round() as u16])
.into();
presentation::frame(
let content = ferese_theme::panel_surface::frame(
content,
ferese_theme::panel_surface::Options {
parameters: panel.shape_parameters(),
bounds: None,
groups,
scale: output.map_or(1., |entry| entry.scale),
high_contrast: shell_theme.high_contrast,
background: color(shell_theme.bar_background),
border: if panel.border {
color(shell_theme.border)
} else {
Color::TRANSPARENT
},
divider: color(shell_theme.border),
native: compositor_material,
},
move |scene| {
if let Some(effects) = effects
&& let Err(error) = effects.set_panel_presentation(scene)
{
eprintln!("ferese-shell: could not update panel material: {error}");
}
},
Some(Box::new(move |scene| {
cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone())))
})),
);
self.with_workspace_hover(id, content)
}
pub(super) fn with_workspace_hover<'a>(
&'a self,
id: window::Id,
content: Element<'a, cosmic::Action<Message>>,
) -> Element<'a, cosmic::Action<Message>> {
presentation::hover(
content,
mode,
corners.max(),
materials,
self.workspace_ui
.hover_target(id)
.map(|(workspace, target)| presentation::HoverTarget {
@@ -224,19 +264,6 @@ impl FereseShell {
cosmic::Action::App(Message::HoverWorkspace(id, workspace, target.clone(), false))
}),
}),
move |regions, opacities| {
if let Some(effects) = effects
&& let Err(error) = effects.set_presentation(
regions,
1.,
opacities.iter().copied(),
super::ferese_surface_effects_v1::Role::Panel,
)
{
eprintln!("ferese-shell: could not update bar material: {error}");
}
},
move |regions| cosmic::Action::App(Message::BarRegionsChanged(id, regions)),
)
}
@@ -249,7 +276,7 @@ impl FereseShell {
representation: Representation,
) -> (Element<'_, cosmic::Action<Message>>, bool) {
let shell_theme = self.config.theme.for_bar();
let bar = BarMetrics::from(self.config.panels[0].geometry);
let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry());
let control_height = if islands {
bar.group_item_height
} else {
@@ -264,6 +291,7 @@ impl FereseShell {
});
let element = match item.kind {
ItemKind::Overview => motion::button(
self.render,
button::custom(overview_control(
BarMetrics { control_height, ..bar },
color(shell_theme.accent),
@@ -276,7 +304,7 @@ impl FereseShell {
1.0,
),
ItemKind::Workspaces { style } => {
let workspaces = self.view_workspace_item(id, style, &item.id.0);
let workspaces = self.view_workspace_item(id, style, &item.id.0, 1.);
if let Some(width) = width {
cosmic::iced::widget::scrollable(workspaces)
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
@@ -296,7 +324,9 @@ impl FereseShell {
} else {
""
};
let element: Element<'_, cosmic::Action<Message>> = text(title)
let element: Element<'_, cosmic::Action<Message>> = self
.render
.text(title)
.size(bar.text_size)
.width(width.map_or(Length::Shrink, Length::Fixed))
.height(control_height)
@@ -332,19 +362,22 @@ impl FereseShell {
.align_y(cosmic::iced::Alignment::Center);
if representation == Representation::Wide && !date.is_empty() {
content = content.push(
text(date)
self.render
.text(date)
.size(12)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
);
}
let content = content.push(
text(time)
self.render
.text(time)
.size(bar.text_size)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
);
motion::button(
self.render,
button::custom(container(content).center_y(bar.group_item_height))
.padding([0, 4])
.height(bar.group_item_height)
@@ -366,6 +399,7 @@ impl FereseShell {
)
}
ItemKind::DisplayMode => motion::button(
self.render,
button::custom(bar_content(
bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground),
control_height,
@@ -414,7 +448,7 @@ impl FereseShell {
let Some(output) = self.outputs.iter().find(|output| output.bar == menu.anchor.parent) else {
return rows;
};
let panel = &self.config.panels[0];
let panel = self.panel_for_bar(output.bar);
let Some(resolution) = &output.panel_resolution else {
return rows;
};
@@ -445,20 +479,25 @@ impl FereseShell {
) => cosmic::Action::App(Message::PanelAction(Box::new(message))),
other => other,
});
rows = rows.push(cosmic::widget::column![text(item.kind.label()).size(12), element].spacing(4));
rows = rows.push(cosmic::widget::column![self.render.text(item.kind.label()).size(12), element].spacing(4));
}
rows
}
fn view_workspace_item(
pub(super) fn view_workspace_item(
&self,
id: window::Id,
style: ferese_config::panel::WorkspaceStyle,
style: ferese_panel::WorkspaceStyle,
instance: &str,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let focused_output = self.output_for_bar(id);
let shell_theme = self.config.theme.for_bar();
let bar = BarMetrics::from(self.config.panels[0].geometry);
let mut palette = shell_theme.palette();
palette.text.a *= opacity;
palette.muted.a *= opacity;
palette.accent.a *= opacity;
let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry());
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
.spacing(1)
.align_y(cosmic::iced::Alignment::Center);
@@ -471,7 +510,7 @@ impl FereseShell {
.output
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
let transition = self.workspace_ui.transition(workspace.id, active);
let apps = if style == ferese_config::panel::WorkspaceStyle::AppIcons {
let apps = if style == ferese_panel::WorkspaceStyle::AppIcons {
self.workspace_ui.apps(workspace.id, &self.snapshot)
} else {
Vec::new()
@@ -490,12 +529,17 @@ impl FereseShell {
ferese_theme::workspaces::indicator(
style,
details.clone(),
shell_theme.palette(),
super::shell_font(),
palette,
self.render.font,
bar.group_item_height,
transition.progress(now),
apps.iter()
.map(|handle| cosmic::widget::icon::icon(handle.clone()).size(16).into())
.map(|handle| {
cosmic::widget::icon::icon(handle.clone())
.size(16)
.opacity(opacity)
.into()
})
.collect(),
)
},
@@ -514,6 +558,7 @@ impl FereseShell {
.on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id)));
let selector = motion::button(
self.render,
workspace_button,
color(if active {
shell_theme.accent
@@ -521,7 +566,7 @@ impl FereseShell {
shell_theme.text_primary
}),
false,
1.0,
opacity,
);
let target =
cosmic::iced::advanced::widget::Id::new(format!("workspace:{id:?}:{instance}:{}", workspace.id));
@@ -562,20 +607,19 @@ fn view_zone<'a>(
panel: &Panel,
zone: &Zone,
availability: Availability,
shell_theme: ShellTheme,
compositor_material: bool,
_shell_theme: ShellTheme,
_compositor_material: bool,
visible: impl Fn(&Item) -> bool,
render: impl Fn(&Item, GroupSurface) -> (Element<'a, cosmic::Action<Message>>, bool),
) -> Element<'a, cosmic::Action<Message>> {
let bar = BarMetrics::from(panel.geometry);
let bar = BarMetrics::from(panel.resolved_geometry());
let mut groups = row::with_capacity(zone.groups.len())
.spacing(zone.spacing)
.spacing(panel.zone_spacing(zone))
.align_y(cosmic::iced::Alignment::Center);
for group in &zone.groups {
let surface = panel.resolved_surface(group);
let surface = panel.group_surface();
let mut controls = row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center);
let mut count = 0;
let mut has_content = false;
for item in group
.items
.iter()
@@ -585,8 +629,7 @@ fn view_zone<'a>(
controls =
controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
}
let (element, content) = render(item, surface);
has_content |= content;
let (element, _) = render(item, surface);
let element: Element<'_, cosmic::Action<Message>> = if matches!(item.kind, ItemKind::FocusedWindow) {
element
} else {
@@ -598,57 +641,26 @@ fn view_zone<'a>(
if count == 0 {
continue;
}
let opacity = shell_theme.panel_opacity(panel.background_opacity);
let corners = panel
.resolved_radius(shell_theme.bar_radius)
.at_edge(
panel.edge,
surface == GroupSurface::Island && panel.geometry.edge_margin == 0,
)
.0;
let border = panel.border;
let content: Element<'_, cosmic::Action<Message>> = container(controls)
.padding(group.padding)
.height(bar.group_height)
let horizontal = f32::from(group.padding[1])
+ if panel.group_surface() == GroupSurface::Island {
group.island_padding
} else {
0.
};
let content = container(controls)
.padding(cosmic::iced::Padding {
top: f32::from(group.padding[0]),
bottom: f32::from(group.padding[0]),
left: horizontal,
right: horizontal,
})
.height(bar.height)
.align_y(alignment::Vertical::Center)
.class(theme::Container::custom(move |_| {
ferese_theme::panel::group_border(color(shell_theme.border), corners, border && has_content)
}))
.id(format!("panel:{}:group:{}", panel.id.0, group.id.0));
let content: Element<'_, cosmic::Action<Message>> = container(content)
.id(group_material_id(panel, group))
.height(bar.height)
.into();
let content = container(content)
.id(format!("panel:{}:group:{}", panel.id.0, group.id.0))
.into();
let panel_surface = panel.surface;
let material = compositor_material && panel.surface == PanelSurface::None;
let content = match surface {
GroupSurface::None => content,
GroupSurface::Inset => container(content)
.id(group_material_id(panel, group))
.class(theme::Container::custom(move |_| {
group_surface_style(shell_theme, panel_surface, material, opacity, corners)
}))
.into(),
GroupSurface::Island => {
let content = island(
content,
shell_theme,
panel.geometry,
group.island_padding,
has_content,
material || panel.surface == PanelSurface::Solid,
Some((group_material_id(panel, group), opacity, corners)),
);
if panel.surface == PanelSurface::Solid {
container(content)
.class(theme::Container::custom(move |_| {
group_surface_style(shell_theme, PanelSurface::Solid, false, opacity, corners)
}))
.into()
} else {
content
}
}
};
groups = groups.push(content);
}
groups.into()
@@ -725,6 +737,7 @@ pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::O
output.is_some_and(|output| output.active_workspace == workspace)
}
#[cfg(test)]
pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: bool) -> container::Style {
let mut style = if islands {
container::Style::default()
@@ -740,6 +753,7 @@ pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: b
style
}
#[cfg(test)]
pub(super) fn island<'a>(
content: Element<'a, cosmic::Action<Message>>,
theme: ShellTheme,
@@ -754,7 +768,7 @@ pub(super) fn island<'a>(
}
let bar = BarMetrics::from(geometry);
let fallback = ferese_config::panel::CornerRadii::uniform(theme.bar_radius)
let fallback = ferese_panel::CornerRadii::uniform(theme.bar_radius)
.at_top_edge(geometry.edge_margin == 0)
.0;
let (id, opacity, corners) =
@@ -780,26 +794,6 @@ pub(super) fn island<'a>(
.into()
}
fn group_surface_style(
theme: ShellTheme,
parent: PanelSurface,
material: bool,
opacity: f32,
corners: [f32; 4],
) -> cosmic::widget::container::Style {
let mut style = bar_style(theme, material, false);
style.border.radius = corners.into();
if parent == PanelSurface::Solid {
style.background = Some(Background::Color(Color {
a: opacity * 0.08,
..color(theme.text_primary)
}));
} else if let Some(Background::Color(fill)) = style.background {
style.background = Some(Background::Color(Color { a: opacity, ..fill }));
}
style
}
pub(super) fn input_region(
mode: PanelSurface,
hidden: bool,
@@ -964,7 +958,7 @@ mod island_tests {
shell.clock = "10 oct, 4:35 pm".into();
let item = Item::new("clock", ItemKind::Clock);
for height in [28., 31., 48.] {
shell.config.panels[0].geometry.height = height;
shell.config.panels[0].geometry.height = Some(height);
for representation in [Representation::Wide, Representation::Compact] {
let mut view = shell
.view_panel_item(shell.outputs[0].bar, &item, None, true, representation)
@@ -1015,7 +1009,7 @@ mod island_tests {
availability,
theme,
true,
|_| true,
|item| title_content || !matches!(item.kind, ItemKind::FocusedWindow),
|item, _| {
let width = match item.kind {
ItemKind::Overview => 28.,
@@ -1036,14 +1030,14 @@ mod island_tests {
},
);
let mut view: Element<'_, cosmic::Action<Message>> = container(view)
.height(panel.geometry.height)
.height(panel.resolved_geometry().height)
.align_y(alignment::Vertical::Center)
.into();
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1200., panel.geometry.height)),
&layout::Limits::new(Size::ZERO, Size::new(1200., panel.resolved_geometry().height)),
);
let mut bounds = Bounds {
group_ids: zone
@@ -1076,19 +1070,19 @@ mod island_tests {
#[test]
fn islands_cover_the_full_panel_height_independently_of_group_decoration() {
let mut panel = Panel::from_preset(PanelPreset::Islands);
let mut panel = Panel::from_preset(PanelStyle::Floating);
for margin in [0, 6] {
let theme = ShellTheme::default();
panel.geometry.height = 36.;
panel.geometry.edge_margin = margin;
panel.geometry.height = Some(36.);
panel.geometry.edge_margin = Some(margin);
for border in [false, true] {
panel.border = border;
let bounds = measure_zone_with_theme(&panel, &panel.start, Availability::default(), true, theme);
assert_eq!(bounds.islands[0].y, 0.);
assert_eq!(bounds.islands[0].height, 36.);
assert_eq!(
bounds.groups[0].height, 32.,
"decoration must not give the inner content a second full-height background"
bounds.groups[0].height, 36.,
"group layout follows the same baseline as its shared surface"
);
}
}
@@ -1097,7 +1091,7 @@ mod island_tests {
#[test]
fn composition_preserves_default_group_gaps_and_material_geometry() {
for padding in [0., 4., 13.5] {
for background in [PanelPreset::Continuous, PanelPreset::Islands] {
for background in [PanelStyle::Flat, PanelStyle::Floating] {
let mut panel = Panel::from_preset(background);
for group in [&mut panel.start, &mut panel.center, &mut panel.end]
.into_iter()
@@ -1110,14 +1104,17 @@ mod island_tests {
start.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
["overview", "workspaces"]
);
let islands = background == PanelPreset::Islands;
let islands = background == PanelStyle::Floating;
assert_eq!(start.items[0].1.x, if islands { padding + 3. } else { 0. });
assert_eq!(start.items[1].1.x - start.items[0].1.x, if islands { 29. } else { 34. });
assert_eq!(start.islands.len(), usize::from(islands));
assert_eq!(start.islands.len(), panel.start.groups.len());
if islands {
assert_eq!(
start.islands[0],
Rectangle::new((0., 0.).into(), (107. + 2. * padding, 28.).into())
Rectangle::new(
(0., 0.).into(),
(107. + 2. * padding, panel.resolved_geometry().height).into()
)
);
}
let end = measure_zone(&panel, &panel.end, Availability::default(), true);
@@ -1130,19 +1127,22 @@ mod island_tests {
if islands {
assert_eq!(
end.islands[0],
Rectangle::new((0., 0.).into(), (108. + 2. * padding, 28.).into())
Rectangle::new(
(0., 0.).into(),
(108. + 2. * padding, panel.resolved_geometry().height).into()
)
);
}
let center = measure_zone(&panel, &panel.center, Availability::default(), true);
assert_eq!(center.islands.len(), usize::from(islands));
assert_eq!(center.islands.len(), panel.center.groups.len());
if islands {
assert_eq!(center.islands[0].width, 80. + 2. * padding);
}
let empty_title = measure_zone(&panel, &panel.center, Availability::default(), false);
assert!(empty_title.islands.is_empty());
assert_eq!(
empty_title.items[0].1.width, 80.,
"keep the current empty title reservation"
assert!(
empty_title.items.is_empty(),
"an unavailable title leaves no group behind"
);
}
}
@@ -1150,7 +1150,7 @@ mod island_tests {
#[test]
fn group_renderer_obeys_order_and_instance_identity_and_skips_unavailable_groups() {
let mut panel = Panel::from_preset(PanelPreset::Islands);
let mut panel = Panel::from_preset(PanelStyle::Floating);
let group = &mut panel.end.groups[0];
let mut clock = group
.items
@@ -1206,7 +1206,7 @@ mod island_tests {
#[test]
fn default_groups_have_no_fill_or_outline_in_either_arrangement() {
let theme = ShellTheme::default();
for background in [PanelPreset::Continuous, PanelPreset::Islands] {
for background in [PanelStyle::Flat, PanelStyle::Floating] {
let panel = Panel::from_preset(background);
let group = ferese_theme::panel::group_border(color(theme.border), theme.bar_radius, panel.border);
assert!(group.background.is_none());
@@ -1239,27 +1239,25 @@ mod island_tests {
assert_eq!(inner.border.width, 1.);
}
#[test]
fn mixed_group_surfaces_use_independent_heights_and_padding() {
let mut panel = Panel::from_preset(PanelPreset::Islands);
panel.geometry.height = 36.;
panel.center.groups[0].surface = Some(GroupSurface::None);
panel.end.groups[0].surface = Some(GroupSurface::Inset);
let navigation = measure_zone(&panel, &panel.start, Availability::default(), true);
let title = measure_zone(&panel, &panel.center, Availability::default(), true);
let status = measure_zone(&panel, &panel.end, Availability::default(), true);
assert_eq!(navigation.islands.len(), 1);
assert_eq!(navigation.islands[0].height, 36.);
assert!(title.islands.is_empty());
assert_eq!(title.items.len(), 1);
assert_eq!(status.islands.len(), 1);
assert_eq!(status.islands[0].height, BarMetrics::from(panel.geometry).group_height);
assert_eq!(status.islands[0].width, status.groups[0].width);
assert!(navigation.islands[0].width > navigation.groups[0].width);
// The same media island can sit on a continuous panel.
panel.surface = PanelSurface::Solid;
panel.group_surface = GroupSurface::None;
panel.end.groups[0].surface = Some(GroupSurface::Island);
let status = measure_zone(&panel, &panel.end, Availability::default(), true);
assert_eq!(status.islands[0].height, 36.);
fn panel_surface_policy_applies_to_every_occupied_group() {
let mut panel = Panel::from_preset(PanelStyle::Floating);
panel.geometry.height = Some(36.);
for zone in [&panel.start, &panel.center, &panel.end] {
let measured = measure_zone(&panel, zone, Availability::default(), true);
assert_eq!(measured.islands.len(), measured.groups.len());
for (island, group) in measured.islands.iter().zip(&measured.groups) {
assert_eq!(island.height, 36.);
assert_eq!(island.width, group.width);
}
}
panel.apply_preset(PanelStyle::Flat);
for zone in [&panel.start, &panel.center, &panel.end] {
let measured = measure_zone(&panel, zone, Availability::default(), true);
assert_eq!(
measured.islands.len(),
measured.groups.len(),
"changing the shared shape preserves layout bounds"
);
}
}
}
+24 -16
View File
@@ -1,7 +1,7 @@
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
use cosmic::{Element, Theme};
use ferese_config::panel::PanelSurface;
use ferese_panel::PanelSurface;
pub(crate) fn island_id() -> widget::Id {
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
@@ -13,6 +13,7 @@ pub(crate) fn input_id() -> widget::Id {
ID.get_or_init(widget::Id::unique).clone()
}
#[cfg(test)]
pub(super) fn frame<'a, M: 'a>(
content: Element<'a, M>,
mode: PanelSurface,
@@ -29,7 +30,19 @@ pub(super) fn frame<'a, M: 'a>(
materials,
hover,
present: Box::new(present),
changed: Box::new(changed),
changed: Some(Box::new(changed)),
})
}
pub(super) fn hover<'a, M: 'a>(content: Element<'a, M>, hover: Option<HoverTarget<'a, M>>) -> Element<'a, M> {
Element::new(Presentation {
content,
mode: PanelSurface::None,
radius: 0.,
materials: Vec::new(),
hover,
present: Box::new(|_, _| {}),
changed: None,
})
}
@@ -66,6 +79,8 @@ impl widget::Operation for HoverBounds<'_> {
}
}
type Changed<'a, M> = Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>;
type Present<'a> = Box<dyn Fn(&[[f32; 5]], &[f32]) + 'a>;
struct Presentation<'a, M> {
@@ -75,7 +90,7 @@ struct Presentation<'a, M> {
materials: Vec<(widget::Id, f32)>,
hover: Option<HoverTarget<'a, M>>,
present: Present<'a>,
changed: Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>,
changed: Option<Changed<'a, M>>,
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
@@ -166,7 +181,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
shell,
viewport,
);
if !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) {
if self.changed.is_none() || !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) {
return;
}
@@ -203,7 +218,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
state.mode = Some(self.mode);
state.current.clone_from(&collector.regions);
state.input.clone_from(&collector.input);
shell.publish((self.changed)(if self.mode == PanelSurface::Solid {
shell.publish((self.changed.as_ref().unwrap())(if self.mode == PanelSurface::Solid {
collector.regions.clone()
} else {
collector.input.clone()
@@ -482,25 +497,20 @@ mod tests {
)
};
let islands = row![section(100), section(200),].spacing(60);
let content = container(islands)
let content: Element<'_, cosmic::Action<crate::Message>> = container(islands)
.padding([0, 12])
.width(600)
.height(28)
.align_y(cosmic::iced::alignment::Vertical::Center)
.into();
let mut view = frame(
content,
content.map(|_| Vec::<[f32; 5]>::new()),
mode,
14.,
vec![],
None,
|_, _| {},
|regions| {
cosmic::Action::App(crate::Message::BarRegionsChanged(
cosmic::iced::window::Id::unique(),
regions,
))
},
|regions| regions,
);
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
tree.diff(&mut view);
@@ -531,9 +541,7 @@ mod tests {
} else {
vec![[0., 0., 600., 28., 14.]]
};
let cosmic::Action::App(crate::Message::BarRegionsChanged(_, regions)) = &messages[0] else {
panic!("expected measured bar regions");
};
let regions = &messages[0];
assert_eq!(*regions, expected);
}
}
-1
View File
@@ -1,4 +1,3 @@
//! Visible clock labels, driven by realtime boundaries and timezone changes.
use std::ffi::{CString, OsString};
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
+50 -15
View File
@@ -60,6 +60,7 @@ pub(crate) struct ShellTheme {
pub(crate) bar_text_primary: [u8; 4],
pub(crate) bar_text_muted: [u8; 4],
pub(crate) surface_base: [u8; 4],
pub(crate) surface_raised: [u8; 4],
pub(crate) surface_popover: [u8; 4],
pub(crate) text_primary: [u8; 4],
pub(crate) text_muted: [u8; 4],
@@ -86,13 +87,15 @@ impl Default for ShellTheme {
bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255],
surface_base: [17, 24, 33, 255],
surface_raised: parse_color(&ferese_config::theme::default_tokens().colors.surface_raised)
.expect("valid theme surface color"),
surface_popover: [17, 24, 33, 255],
text_primary: [244, 247, 251, 255],
text_muted: [135, 147, 162, 255],
accent: [61, 123, 230, 255],
accent_gradient: None,
on_accent: [244, 247, 251, 255],
border: [255, 255, 255, 24],
border: parse_color(&default_border()).expect("valid theme border color"),
shadow: [0, 0, 0, 85],
bar_radius: 14.0,
material_radius: 14.0,
@@ -249,6 +252,8 @@ struct TypographyConfig {
struct ThemeColorsConfig {
#[serde(default = "default_surface_base")]
surface_base: String,
#[serde(default = "default_surface_raised")]
surface_raised: String,
#[serde(default = "default_text_primary")]
text_primary: String,
#[serde(default = "default_text_muted")]
@@ -267,6 +272,7 @@ impl Default for ThemeColorsConfig {
fn default() -> Self {
Self {
surface_base: default_surface_base(),
surface_raised: default_surface_raised(),
text_primary: default_text_primary(),
text_muted: default_text_muted(),
accent: default_accent(),
@@ -371,7 +377,7 @@ fn parse_document(
{
Ok(config) => {
if let Some(panels) = &config.panels {
ferese_config::panel::validate(panels).map_err(ferese_config::Error::from)?;
ferese_panel::validate(panels).map_err(ferese_config::Error::from)?;
}
config.animations.validate().map_err(ferese_config::Error::from)?;
@@ -447,6 +453,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted),
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
surface_raised: parse_color(&theme.colors.surface_raised).unwrap_or(defaults.surface_raised),
surface_popover: {
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = alpha;
@@ -501,6 +508,10 @@ fn default_surface_base() -> String {
"#111821".to_owned()
}
fn default_surface_raised() -> String {
ferese_config::theme::default_tokens().colors.surface_raised
}
fn default_text_primary() -> String {
"#F4F7FB".to_owned()
}
@@ -514,7 +525,7 @@ fn default_accent() -> String {
}
fn default_border() -> String {
"#FFFFFF18".to_owned()
ferese_config::theme::default_tokens().colors.border
}
fn default_shadow() -> String {
@@ -547,6 +558,25 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)]
mod tests {
#[test]
fn morph_profile_uses_the_current_strict_schema() {
let config = parse_test_source(
"panel main { style morph; morph { idle-width 180; media-height 184; peek-on-hover #false; }; }\n",
)
.unwrap();
assert_eq!(config.panels[0].morph.idle_width, 180.);
assert_eq!(config.panels[0].morph.media_height, 184.);
assert!(!config.panels[0].morph.peek_on_hover);
assert!(
parse_test_source("panel main { style morph; }\n").unwrap().panels[0]
.morph
.peek_on_hover
);
assert!(parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err());
assert!(parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err());
assert!(parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err());
}
#[test]
fn removed_geometry_is_rejected_before_a_runtime_snapshot_can_hide_it() {
for key in [
@@ -583,11 +613,11 @@ mod tests {
#[test]
fn reduce_transparency_overrides_panel_opacity_for_both_surfaces() {
for surface in ["solid", "none"] {
for surface in ["flat", "floating"] {
for opacity in [0., 0.4, 1.] {
for reduce in [false, true] {
let source = format!(
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}"
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style {surface}; background-opacity {opacity}; }}"
);
let document = ferese_config::Document::parse(&source).unwrap();
let resolved = ferese_config::theme::resolve(
@@ -649,13 +679,13 @@ mod tests {
use crate::panel::{GroupSurface, ItemKind, PanelSurface};
let defaults = parse_test_source("").unwrap();
assert_eq!(ShellConfig::default().panels, defaults.panels);
let source = "panel main { surface none; group-surface island; geometry { height 36; edge-margin 6; }; center { group title { surface none; item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }";
let source = "panel main { style floating; geometry { height 36; edge-margin 6; }; center { group title { item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }";
let current = parse_test_source(source).unwrap();
let panel = &current.panels[0];
assert_eq!(panel.surface, PanelSurface::None);
assert_eq!(panel.group_surface, GroupSurface::Island);
assert_eq!(panel.geometry.height, 36.);
assert_eq!(panel.geometry.edge_margin, 6);
assert_eq!(panel.surface(), PanelSurface::None);
assert_eq!(panel.group_surface(), GroupSurface::Island);
assert_eq!(panel.resolved_geometry().height, 36.);
assert_eq!(panel.resolved_geometry().edge_margin, 6);
assert_eq!(panel.end.groups[0].island_padding, 9.5);
assert!(!panel.center.groups[0].items[0].visible);
assert_eq!(
@@ -667,10 +697,10 @@ mod tests {
#[test]
fn authored_composition_rejects_invalid_reload_data() {
let source = r#"panel "custom" { surface "none"; group-surface "island"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#;
let source = r#"panel "custom" { style "floating"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#;
let config = parse_test_source(source).unwrap();
assert_eq!(config.panels[0].id.0, "custom");
assert_eq!(config.panels[0].surface, crate::panel::PanelSurface::None);
assert_eq!(config.panels[0].surface(), crate::panel::PanelSurface::None);
assert_eq!(config.panels[0].end.groups[0].items.len(), 2);
assert!(parse_test_source(&source.replace("item \"two\"", "item \"one\"")).is_err());
assert!(parse_test_source("panel one; panel two;").is_err());
@@ -680,7 +710,10 @@ mod tests {
#[test]
fn group_surfaces_do_not_change_modal_opacity() {
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
let islands = parse_test_source("panel main { surface none; group-surface island; }\ntheme { material { style translucent; opacity 0.7; }; }").unwrap();
let islands = parse_test_source(
"panel main { style floating; }\ntheme { material { style translucent; opacity 0.7; }; }",
)
.unwrap();
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
@@ -696,7 +729,7 @@ mod tests {
);
let config = parse_test_source(&source).unwrap();
assert_eq!(config.panels[0].end.groups[0].island_padding, padding);
assert_eq!(config.panels[0].geometry.inner_padding, 12.);
assert_eq!(config.panels[0].resolved_geometry().inner_padding, 12.);
}
for value in ["-1", "32.5", "\"small\""] {
assert!(
@@ -788,7 +821,7 @@ theme {
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
let theme = shell_theme(&config.theme);
assert_eq!(crate::panel::PanelGeometry::default().edge_margin, 0);
assert_eq!(crate::panel::PanelGeometry::default().height, 28.0);
assert_eq!(crate::panel::PanelGeometry::default().height, 36.0);
assert_eq!(crate::panel::PanelGeometry::default().side_margins, 0);
assert_eq!(theme.bar_radius, 14.0);
assert_eq!(theme.bar_background[3], 255);
@@ -800,6 +833,7 @@ theme {
fn bar_palette_can_be_changed_without_changing_popovers() {
let config: FereseConfig = ferese_config::from_str(
r##"theme {
colors { surface-raised "#314159"; }
surface {
bar {
background "#EAECEEDD"
@@ -813,6 +847,7 @@ theme {
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background, [234, 236, 238, 255]);
assert_eq!(theme.surface_raised, [49, 65, 89, 255]);
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
+155 -12
View File
@@ -48,15 +48,15 @@ pub(crate) struct WorkspaceSnapshot {
#[derive(Clone, Debug)]
pub(crate) struct WindowSnapshot {
#[cfg_attr(not(test), expect(dead_code, reason = "Retained for window-targeted shell views."))]
pub(crate) id: u64,
pub(crate) workspace: u64,
pub(crate) geometry: Option<[f64; 4]>,
pub(crate) app_id: String,
pub(crate) title: String,
pub(crate) focused: bool,
#[cfg_attr(not(test), expect(dead_code, reason = "The current bar does not display urgency."))]
pub(crate) urgent: bool,
pub(crate) fullscreen: bool,
pub(crate) maximized: bool,
#[cfg_attr(
not(test),
expect(dead_code, reason = "The current bar does not display window placement.")
@@ -136,7 +136,7 @@ impl ShellControl {
let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 4..=6, ())?;
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=8, ())?;
let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel();
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
@@ -194,6 +194,16 @@ impl ShellControl {
}
}
pub(crate) fn move_window_to_workspace(&self, window: u64, workspace: u64) {
if self.shell.version() < 8 {
return;
}
let (hi, lo) = split_id(window);
let (workspace_hi, workspace_lo) = split_id(workspace);
self.shell.move_window_to_workspace(hi, lo, workspace_hi, workspace_lo);
let _ = self.connection.flush();
}
pub(crate) fn activate_workspace(&self, id: u64) {
let (hi, lo) = split_id(id);
@@ -375,14 +385,36 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
state.pending.windows.push(WindowSnapshot {
id: join_id(window_hi, window_lo),
workspace: join_id(workspace_hi, workspace_lo),
geometry: None,
app_id,
title,
focused: flags.contains(ferese_shell_v1::WindowState::Focused),
urgent: flags.contains(ferese_shell_v1::WindowState::Urgent),
fullscreen: flags.contains(ferese_shell_v1::WindowState::Fullscreen),
maximized: flags.contains(ferese_shell_v1::WindowState::Maximized),
floating: flags.contains(ferese_shell_v1::WindowState::Floating),
});
}
ferese_shell_v1::Event::WindowGeometry {
window_hi,
window_lo,
x,
y,
width,
height,
} => {
if [x, y, width, height].iter().all(|n| n.is_finite())
&& width > 0.
&& height > 0.
&& let Some(window) = state
.pending
.windows
.last_mut()
.filter(|window| window.id == join_id(window_hi, window_lo))
{
window.geometry = Some([x, y, width, height]);
}
}
ferese_shell_v1::Event::Capabilities { .. } => {
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
}
@@ -564,6 +596,110 @@ mod tests {
assert_ne!(tiled.id, floating.id);
}
#[test]
fn geometry_is_attached_to_its_window_and_published_only_with_the_complete_snapshot() {
let (control, mut state, queue, _peer) = control_fixture();
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotBegin { serial: 8 },
);
let id = 0x1234_5678_9abc_def0;
dispatch_event(&control, &mut state, &queue, window_event(id, 0));
let (window_hi, window_lo) = split_id(id);
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::WindowGeometry {
window_hi,
window_lo,
x: -640.,
y: 20.,
width: 620.,
height: 480.,
},
);
// Invalid geometry must not replace a valid rectangle.
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::WindowGeometry {
window_hi,
window_lo,
x: 0.,
y: 0.,
width: -1.,
height: 480.,
},
);
assert!(control.poll().snapshot.is_none());
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotEnd { serial: 8 },
);
assert_eq!(
control.poll().snapshot.unwrap().windows[0].geometry,
Some([-640., 20., 620., 480.])
);
}
#[test]
fn geometry_stream_uses_only_the_preceding_window() {
let (control, mut state, queue, _peer) = control_fixture();
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotBegin { serial: 9 },
);
for id in 1..=512 {
dispatch_event(&control, &mut state, &queue, window_event(id, 0));
let (window_hi, window_lo) = split_id(id);
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::WindowGeometry {
window_hi,
window_lo,
x: id as f64,
y: 0.,
width: 50.,
height: 40.,
},
);
}
// A malformed late event must not overwrite an earlier window.
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::WindowGeometry {
window_hi: 0,
window_lo: 1,
x: -1.,
y: 0.,
width: 50.,
height: 40.,
},
);
dispatch_event(
&control,
&mut state,
&queue,
ferese_shell_v1::Event::SnapshotEnd { serial: 9 },
);
let snapshot = control.poll().snapshot.unwrap();
for window in snapshot.windows {
assert_eq!(window.geometry, Some([window.id as f64, 0., 50., 40.]));
}
}
#[test]
fn known_window_flags_survive_unknown_future_bits() {
use ferese_shell_v1::WindowState as Flags;
@@ -575,14 +711,15 @@ mod tests {
wayland_client::WEnum::Unknown(_)
));
let cases = [
(known.bits(), [true, true, true, true]),
(known.bits() | future, [true, true, true, true]),
(Flags::Focused.bits() | future, [true, false, false, false]),
(Flags::Urgent.bits() | future, [false, true, false, false]),
(Flags::Fullscreen.bits() | future, [false, false, true, false]),
(Flags::Floating.bits() | future, [false, false, false, true]),
(future, [false; 4]),
(0, [false; 4]),
(known.bits(), [true, true, true, true, false]),
(known.bits() | future, [true, true, true, true, false]),
(Flags::Focused.bits() | future, [true, false, false, false, false]),
(Flags::Urgent.bits() | future, [false, true, false, false, false]),
(Flags::Fullscreen.bits() | future, [false, false, true, false, false]),
(Flags::Floating.bits() | future, [false, false, false, true, false]),
(Flags::Maximized.bits() | future, [false, false, false, false, true]),
(future, [false; 5]),
(0, [false; 5]),
];
dispatch_event(
&control,
@@ -602,7 +739,13 @@ mod tests {
let snapshot = control.poll().snapshot.unwrap();
for (window, (_, expected)) in snapshot.windows.iter().zip(cases) {
assert_eq!(
[window.focused, window.urgent, window.fullscreen, window.floating],
[
window.focused,
window.urgent,
window.fullscreen,
window.floating,
window.maximized
],
expected
);
}
+15 -9
View File
@@ -390,7 +390,7 @@ impl FereseShell {
let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible);
let pending = model.pending();
let mut header = row![
text("Display mode").size(17),
self.render.text("Display mode").size(17),
cosmic::iced::widget::Space::new().width(Length::Fill)
]
.align_y(cosmic::iced::Alignment::Center)
@@ -401,7 +401,8 @@ impl FereseShell {
.and_then(|inventory| inventory.profile.as_deref())
{
header = header.push(
text(format!("{profile} profile"))
self.render
.text(format!("{profile} profile"))
.size(12)
.class(theme::Text::Color(palette.muted)),
);
@@ -417,10 +418,12 @@ impl FereseShell {
.is_some_and(|inventory| inventory.available(mode));
let card = cosmic::widget::column![
ferese_theme::icons::tinted(mode.icon(), 40, palette.text),
text(mode.label())
self.render
.text(mode.label())
.size(13)
.wrapping(cosmic::iced::widget::text::Wrapping::None),
text((index + 1).to_string())
self.render
.text((index + 1).to_string())
.size(11)
.class(theme::Text::Color(palette.muted)),
]
@@ -449,7 +452,10 @@ impl FereseShell {
displays = displays.push(
row![
ferese_theme::icons::tinted(ferese_theme::icons::DISPLAY, 16, palette.muted),
text(&display.label).size(12).class(theme::Text::Color(palette.muted))
self.render
.text(&display.label)
.size(12)
.class(theme::Text::Color(palette.muted))
]
.spacing(8)
.align_y(cosmic::iced::Alignment::Center),
@@ -460,7 +466,7 @@ impl FereseShell {
}
if let Some(error) = &model.error {
rows = rows.push(text(error).size(12));
rows = rows.push(self.render.text(error).size(12));
}
let hint = if model.busy {
@@ -491,14 +497,14 @@ impl FereseShell {
.inventory
.as_ref()
.is_some_and(|inventory| inventory.available(model.selected)));
rows.push(text(hint).size(12).class(theme::Text::Color(palette.muted)))
rows.push(self.render.text(hint).size(12).class(theme::Text::Color(palette.muted)))
.push(
container(
row![
cosmic::iced::widget::Space::new().width(Length::Fill),
ferese_theme::controls::text_button(
if pending { "Revert" } else { "Cancel" },
shell_font(),
self.render.font,
palette,
false
)
@@ -512,7 +518,7 @@ impl FereseShell {
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
ferese_theme::controls::text_button(
if pending { "Keep" } else { "Apply" },
shell_font(),
self.render.font,
palette,
true
)
+162 -67
View File
@@ -15,9 +15,12 @@ mod motion;
mod notification_ui;
mod notifications;
mod panel;
mod panel_hub;
mod panel_layout;
mod panel_runtime;
mod recording;
mod renderer;
mod rendering;
mod status;
mod status_ui;
mod system_modal;
@@ -57,25 +60,10 @@ const APP_ID: &str = "dev.ferese.Shell";
// that compatibility path gated; current backends use Window::Opened and
// window::run to retrieve handles without updates at the display refresh rate.
static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true);
static SHELL_FONT: std::sync::OnceLock<std::sync::RwLock<cosmic::font::Font>> = std::sync::OnceLock::new();
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
ferese_theme::font(family)
}
fn shell_font() -> cosmic::font::Font {
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
.read()
.unwrap()
}
fn text<'a>(
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
ferese_theme::text(content, shell_font())
}
#[derive(Clone, Copy)]
struct BarMetrics {
height: f32,
@@ -83,6 +71,7 @@ struct BarMetrics {
icon_size: u16,
overview_icon_size: u16,
control_height: f32,
#[cfg(test)]
group_height: f32,
group_item_height: f32,
}
@@ -103,6 +92,7 @@ impl From<panel::PanelGeometry> for BarMetrics {
icon_size: 20,
overview_icon_size: 20,
control_height,
#[cfg(test)]
group_height,
group_item_height: (group_height - 4.0).max(0.0),
}
@@ -116,7 +106,6 @@ fn main() -> cosmic::iced::Result {
});
renderer::configure_shell();
motion::configure(config.animations, config.theme.material_radius);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
if compositor_wallpaper {
// The compositor owns one GPU image, shared across output renderers;
@@ -124,7 +113,6 @@ fn main() -> cosmic::iced::Result {
config.wallpaper.path = None;
}
let shell_font = configured_font(config.font_family.as_deref());
let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font));
// Decode alongside toolkit/GPU initialization, never during a UI draw.
let wallpaper = config.wallpaper.path.clone().map(|path| {
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
@@ -153,6 +141,7 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<im
struct FereseShell {
core: Core,
config: ShellConfig,
render: rendering::Rendering,
wallpaper: Option<image::Handle>,
control: Option<ShellControl>,
snapshot: ShellSnapshot,
@@ -179,6 +168,8 @@ struct FereseShell {
}
struct OutputSurfaces {
runtime: panel_runtime::Runtime,
scale: f64,
output: wl_output::WlOutput,
name: Option<String>,
bar: window::Id,
@@ -193,7 +184,20 @@ struct OutputSurfaces {
#[derive(Clone, Debug)]
enum Message {
BarRegionsChanged(window::Id, Vec<[f32; 5]>),
HubOpen(window::Id, status_ui::Menu),
HubClose(window::Id),
HubWorkspaceScroll(window::Id, cosmic::iced::mouse::ScrollDelta),
HubOverview(window::Id),
HubWorkspaceHover(window::Id, Option<u64>),
HubWorkspaceSelect(window::Id, u64),
HubWorkspaceMove(window::Id, u64, u64),
HubWorkspaceFocus(window::Id, Option<u64>, Option<cosmic::iced::keyboard::key::Named>),
HubToastExpired(window::Id, Instant),
HubWorkspaceFeedbackExpired(window::Id, Instant),
HubIgnore,
PanelScale(window::Id, f32),
AdaptiveDeadline(window::Id, u64),
PanelSurfaceChanged(window::Id, Box<ferese_panel::scene::Scene>),
PanelResolved(window::Id, panel_layout::Resolution),
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
ThemeMode(ferese_config::theme::Mode),
@@ -279,7 +283,7 @@ impl cosmic::Application for FereseShell {
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
let app = Self {
core,
notifications: notifications::Center::new(config.notifications.clone()),
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
notification_surface: None,
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
@@ -295,6 +299,7 @@ impl cosmic::Application for FereseShell {
guide_load: Default::default(),
guide_attempts: 0,
pending_power: None,
render: rendering::Rendering::new(&config),
config,
wallpaper: None,
control: ShellControl::connect()
@@ -339,8 +344,12 @@ impl cosmic::Application for FereseShell {
.load(Ordering::Relaxed)
.then_some(Message::Event(event, id)),
Event::Keyboard(_)
| Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_) | cosmic::iced::mouse::Event::CursorLeft)
| Event::Window(window::Event::Opened { .. } | window::Event::Closed)
| Event::Mouse(
cosmic::iced::mouse::Event::ButtonPressed(_)
| cosmic::iced::mouse::Event::CursorLeft
| cosmic::iced::mouse::Event::CursorEntered,
)
| Event::Window(window::Event::Opened { .. } | window::Event::Closed | window::Event::Rescaled(_))
| Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..),
)) => Some(Message::Event(event, id)),
@@ -358,6 +367,17 @@ impl cosmic::Application for FereseShell {
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
match message {
Message::HubOpen(id, kind) => self.open_hub(id, kind),
Message::HubClose(id) => self.close_hub(id),
Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta),
Message::HubOverview(id) => self.open_hub_overview(id),
Message::HubWorkspaceHover(id, workspace) => self.hub_workspace_hover(id, workspace),
Message::HubWorkspaceSelect(id, workspace) => self.hub_workspace_select(id, workspace),
Message::HubWorkspaceMove(id, window, workspace) => self.hub_workspace_move(id, window, workspace),
Message::HubWorkspaceFocus(id, focused, key) => self.hub_workspace_focus(id, focused, key),
Message::HubToastExpired(id, revision) => self.hub_toast_expired(id, revision),
Message::HubWorkspaceFeedbackExpired(id, revision) => self.hub_workspace_feedback_expired(id, revision),
Message::HubIgnore => Task::none(),
Message::PanelResolved(id, resolution) => {
let allocation_changed = self
.outputs
@@ -390,7 +410,12 @@ impl cosmic::Application for FereseShell {
Task::batch([margin, tooltip])
}
}
Message::BarRegionsChanged(id, regions) => {
Message::PanelSurfaceChanged(id, scene) => {
let regions = scene
.input_rectangles(1.)
.into_iter()
.map(|r| [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32, 0.])
.collect();
let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else {
return Task::none();
};
@@ -398,7 +423,7 @@ impl cosmic::Application for FereseShell {
output.bar_regions = regions;
set_input_zone(
id,
bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
)
}
@@ -515,7 +540,7 @@ impl cosmic::Application for FereseShell {
}
Message::StopRecording => {
self.recorder.stop();
Task::none()
self.sync_hub_activity()
}
Message::StatusUpdated(update) => {
if update.generation >= self.status_service.generation {
@@ -530,9 +555,14 @@ impl cosmic::Application for FereseShell {
dnd: self.notifications.dnd,
});
}
self.sync_notification_surface()
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
}
Message::NotificationEvent(event) => {
let notice = if let notifications::Event::Notice(notice) = &event {
Some(notice.id)
} else {
None
};
self.notifications.handle_event(event);
if self.notifications.ready {
self.status.notifications = Some(status::Notifications {
@@ -540,11 +570,12 @@ impl cosmic::Application for FereseShell {
dnd: self.notifications.dnd,
});
}
self.sync_notification_surface()
let hub = notice.map_or_else(Task::none, |notice| self.hub_notice(notice));
Task::batch([hub, self.sync_hub_notices(), self.sync_notification_surface()])
}
Message::NotificationTick => {
self.notifications.tick();
self.sync_notification_surface()
Task::batch([self.sync_hub_notices(), self.sync_notification_surface()])
}
Message::DismissNotification(id) => {
self.notifications.close(id, 2);
@@ -715,10 +746,22 @@ impl cosmic::Application for FereseShell {
}
Message::RecorderEvent(event) => {
self.recorder.handle(event);
Task::none()
self.sync_hub_activity()
}
Message::RecorderElapsed => Task::none(),
Message::PanelScale(id, scale) => {
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id)
&& scale.is_finite()
&& scale > 0.
&& scale <= 16.
{
entry.scale = f64::from(scale);
}
self.sync_hub_workspaces()
}
Message::AdaptiveDeadline(id, ticket) => self.adaptive_deadline(id, ticket),
Message::ControlReady => {
let overview_was_active = self.overview_active;
let mut reload_task = Task::none();
if let Some(control) = &self.control {
let poll = control.poll();
@@ -740,9 +783,15 @@ impl cosmic::Application for FereseShell {
if let Some(snapshot) = poll.snapshot {
self.snapshot = snapshot;
let mut tasks = vec![reload_task, self.update_workspace_ui()];
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
let mut tasks = vec![
reload_task,
self.update_workspace_ui(),
feedback,
self.refresh_adaptive_panels(),
];
let mut visibility_changed = false;
let mut suspended_hubs = Vec::new();
for entry in &mut self.outputs {
let output = self
.snapshot
@@ -756,7 +805,14 @@ impl cosmic::Application for FereseShell {
continue;
}
tasks.push(set_exclusive_zone(
entry.bar,
entry.runtime.panel(&self.config.panels[0]).exclusive_zone(hidden),
));
entry.hidden = hidden;
if hidden && entry.runtime.hub.is_some() {
suspended_hubs.push(entry.bar);
}
visibility_changed = true;
if let Some(effects) = &entry.effects
&& let Err(error) = effects.set_visible(!hidden)
@@ -765,9 +821,12 @@ impl cosmic::Application for FereseShell {
}
tasks.push(set_input_zone(
entry.bar,
bar::input_region(self.config.panels[0].surface, hidden, &entry.bar_regions),
bar::input_region(ferese_panel::PanelSurface::None, hidden, &entry.bar_regions),
));
}
for id in suspended_hubs {
tasks.push(self.suspend_hub(id));
}
if self
.menu
.as_ref()
@@ -826,11 +885,11 @@ impl cosmic::Application for FereseShell {
}
fn view(&self) -> Element<'_, Self::Message> {
text("").into()
self.render.text("").into()
}
fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> {
text("").into()
self.render.text("").into()
}
}
@@ -854,13 +913,7 @@ impl FereseShell {
config.wallpaper.path = None;
}
if self.config.font_family != config.font_family {
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
.write()
.unwrap() = configured_font(config.font_family.as_deref());
}
motion::configure(config.animations, config.theme.material_radius);
self.render = rendering::Rendering::new(&config);
if self.config.animations != config.animations {
self.notifications.update_motion_settings(config.animations);
@@ -877,14 +930,25 @@ impl FereseShell {
let composition_changed = !self.config.panels[0].same_composition(&config.panels[0]);
let edge_changed = self.config.panels[0].edge != config.panels[0].edge;
let old = self.config.theme;
let old_geometry = self.config.panels[0].geometry;
let old_geometry = self.config.panels[0].resolved_geometry();
let theme = config.theme;
let geometry = config.panels[0].geometry;
let geometry = config.panels[0].resolved_geometry();
let geometry_changed = edge_changed || old_geometry != geometry;
self.notifications.configure(config.notifications.clone());
self.config = config;
if composition_changed || geometry_changed {
for entry in &mut self.outputs {
entry
.runtime
.reset(&self.config.panels[0], &self.snapshot, entry.name.as_deref());
}
} else {
for entry in &mut self.outputs {
entry.runtime.update_appearance(&self.config.panels[0]);
}
}
let workspace_task = self.update_workspace_ui();
let mut tasks = vec![workspace_task];
let mut tasks = vec![workspace_task, self.sync_hub_activity()];
if composition_changed || geometry_changed {
tasks.push(self.destroy_menu());
@@ -893,7 +957,7 @@ impl FereseShell {
// publishes its replacement through PanelResolved.
tasks.push(set_input_zone(
output.bar,
bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
));
}
}
@@ -909,21 +973,40 @@ impl FereseShell {
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
}
for entry in self.outputs.iter().filter(|_| geometry_changed) {
for entry in self.outputs.iter().filter(|_| geometry_changed || composition_changed) {
let panel = entry.runtime.panel(&self.config.panels[0]);
let geometry = panel.resolved_geometry();
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT));
tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32)));
tasks.push(set_exclusive_zone(
let hub_size = entry.runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
tasks.push(set_anchor(
entry.bar,
(geometry.height.round() as i32).saturating_add(geometry.window_clearance),
if hub_size.is_some() {
anchor
} else {
anchor | Anchor::LEFT | Anchor::RIGHT
},
));
let (width, height) = hub_size.map_or((None, Some(geometry.height.round() as u32)), |size| {
(Some(size[0]), Some(size[1]))
});
tasks.push(set_size(entry.bar, width, height));
tasks.push(
cosmic::iced::platform_specific::shell::commands::layer_surface::set_keyboard_interactivity(
entry.bar,
KeyboardInteractivity::None,
),
);
tasks.push(set_exclusive_zone(entry.bar, panel.exclusive_zone(entry.hidden)));
}
let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect();
for bar in bars {
tasks.push(self.update_panel_margin(bar, edge_changed || old_geometry.edge_margin != geometry.edge_margin));
tasks.push(self.update_panel_margin(
bar,
composition_changed || edge_changed || old_geometry.edge_margin != geometry.edge_margin,
));
}
if geometry_changed {
if geometry_changed || composition_changed {
tasks.push(self.dismiss_workspace_tooltip());
if let Some(surface) = self.notification_surface.take() {
tasks.push(destroy_layer_surface(surface.id));
@@ -934,20 +1017,26 @@ impl FereseShell {
}
fn update_panel_margin(&mut self, bar: window::Id, force: bool) -> Task<Message> {
let geometry = self.config.panels[0].geometry;
let panel = self.panel_for_bar(bar);
let is_hub = panel.style == ferese_panel::PanelStyle::Morph;
let geometry = panel.resolved_geometry();
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == bar) else {
return Task::none();
};
let margin = entry.size.zip(entry.panel_resolution.as_ref()).map_or(
entry.bar_margin_horizontal,
|((width, _), resolution)| {
resolution.side_margin(
geometry.side_margins,
width,
f32::from(geometry.inner_padding.round() as u16),
)
},
);
let margin = if is_hub {
0
} else {
entry.size.zip(entry.panel_resolution.as_ref()).map_or(
entry.bar_margin_horizontal,
|((width, _), resolution)| {
resolution.side_margin(
geometry.side_margins,
width,
f32::from(geometry.inner_padding.round() as u16),
)
},
)
};
if entry.bar_margin_horizontal == margin && !force {
return Task::none();
}
@@ -982,7 +1071,7 @@ mod tests {
let output = wl_output::WlOutput::inert(connection.backend().downgrade());
FereseShell {
core: Core::default(),
notifications: notifications::Center::new(config.notifications.clone()),
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
notification_surface: None,
status_service: status::Service::start(None),
status: Default::default(),
@@ -998,6 +1087,7 @@ mod tests {
guide_load: Default::default(),
guide_attempts: 0,
pending_power: None,
render: rendering::Rendering::new(&config),
config,
wallpaper: None,
control: None,
@@ -1006,6 +1096,8 @@ mod tests {
overview_active: false,
clock: String::new(),
outputs: vec![OutputSurfaces {
runtime: panel_runtime::Runtime::new(&panel::Panel::default(), &ShellSnapshot::default(), None),
scale: 1.,
output,
name: Some("test".into()),
bar: window::Id::unique(),
@@ -1025,7 +1117,7 @@ mod tests {
#[test]
fn panel_placement_uses_only_the_selected_edge_for_anchor_and_margin() {
use ferese_config::panel::Edge;
use ferese_panel::Edge;
for gap in [0, 12] {
let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32);
assert_eq!(anchor, Anchor::TOP);
@@ -1039,7 +1131,8 @@ mod tests {
#[test]
fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() {
let mut shell = shell_with_measured_panel();
shell.config.panels[0].geometry.side_margins = 240;
shell.config.panels[0].style = ferese_panel::PanelStyle::Floating;
shell.config.panels[0].geometry.side_margins = Some(240);
drop(shell.update_panel_margin(shell.outputs[0].bar, false));
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
let popup = window::Id::unique();
@@ -1061,7 +1154,6 @@ mod tests {
("background_opacity", serde_json::json!(0.5)),
("corner_radius", serde_json::json!("4px 8px")),
("border", serde_json::json!(true)),
("group_surface", serde_json::json!("inset")),
] {
let edit = field;
let mut document = ferese_config::Document::parse("").unwrap();
@@ -1205,7 +1297,8 @@ mod tests {
fn an_empty_measured_panel_is_not_an_unmeasured_panel() {
let mut shell = shell_with_measured_panel();
let bar = shell.outputs[0].bar;
shell.config.panels[0].geometry.side_margins = 240;
shell.config.panels[0].style = ferese_panel::PanelStyle::Floating;
shell.config.panels[0].geometry.side_margins = Some(240);
shell.outputs[0].panel_resolution = None;
drop(shell.update_panel_margin(bar, false));
assert_eq!(shell.outputs[0].bar_margin_horizontal, 0);
@@ -1365,7 +1458,9 @@ mod tests {
focused: true,
urgent: false,
fullscreen: true,
maximized: false,
floating: false,
geometry: None,
}],
}
}
+11 -4
View File
@@ -138,10 +138,17 @@ impl FereseShell {
}
pub fn media_tick_subscription(&self) -> Subscription<()> {
let visible = self
.menu
.as_ref()
.is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing())
let hub_visible = self.outputs.iter().any(|entry| {
!entry.hidden
&& entry.runtime.hub.as_ref().is_some_and(|hub| {
hub.borrow().hub.mode() == ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media)
})
});
let visible = (hub_visible
|| self
.menu
.as_ref()
.is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()))
&& self.outputs.iter().any(|output| !output.hidden);
let on_battery = self
.status
+11 -38
View File
@@ -1,5 +1,5 @@
mod frames;
pub(crate) use frames::frame_driven;
pub(crate) use frames::{ContentMeasurement, frame_driven, geometry_driven};
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
@@ -10,29 +10,6 @@ pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>;
pub(crate) use ferese_animation::MotionSettings as Settings;
use ferese_animation::{AnimatedValue, SpringConfig};
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
/// All shell shapes share one radius, capped to fit smaller controls.
pub(crate) fn radius(limit: f32) -> f32 {
f32::from_bits(SHELL_RADIUS.load(std::sync::atomic::Ordering::Relaxed)).min(limit)
}
pub(crate) fn configure(settings: Settings, radius: f32) {
SHELL_RADIUS.store(radius.to_bits(), std::sync::atomic::Ordering::Relaxed);
*SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
.write()
.unwrap() = settings;
}
pub(crate) fn settings() -> Settings {
*SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
.read()
.unwrap()
}
pub(crate) struct PopupMotion {
value: AnimatedValue,
started: Option<std::time::Instant>,
@@ -129,18 +106,20 @@ impl PopupMotion {
/// Animate paint only; the original button retains its layout, hit target,
/// keyboard handling, pressed state, and accessibility identity.
pub(crate) fn button<'a, M: Clone + 'a>(
render: crate::rendering::Rendering,
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
selected: bool,
opacity: f32,
) -> Element<'a, M> {
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed)
ferese_theme::controls::shell_button(foreground, selected, opacity, render.radius(14.), progress, pressed)
};
painted_button(button, paint)
painted_button(render, button, paint)
}
pub(crate) fn circular_button<'a, M: Clone + 'a>(
render: crate::rendering::Rendering,
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
fill: Option<cosmic::iced::Color>,
@@ -163,10 +142,11 @@ pub(crate) fn circular_button<'a, M: Clone + 'a>(
style
};
painted_button(button, paint)
painted_button(render, button, paint)
}
fn painted_button<'a, M: Clone + 'a>(
render: crate::rendering::Rendering,
button: cosmic::widget::button::Button<'a, M>,
paint: impl Fn(f32, bool) -> cosmic::widget::button::Style + Copy + 'static,
) -> Element<'a, M> {
@@ -184,10 +164,7 @@ fn painted_button<'a, M: Clone + 'a>(
Element::new(Hover {
content,
progress,
duration: SETTINGS
.get()
.map_or_else(Settings::default, |settings| *settings.read().unwrap())
.duration(120.0),
duration: render.motion.duration(120.0),
})
}
@@ -358,7 +335,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
}
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
animated_from_edge(content, progress, regions, radius, ferese_config::panel::Edge::Top)
animated_from_edge(content, progress, regions, radius, ferese_panel::Edge::Top)
}
pub fn animated_from_edge<'a, M: 'a>(
@@ -366,18 +343,14 @@ pub fn animated_from_edge<'a, M: 'a>(
progress: f32,
regions: Regions,
radius: f32,
edge: ferese_config::panel::Edge,
edge: ferese_panel::Edge,
) -> Element<'a, M> {
Element::new(Motion {
content,
progress,
regions,
radius,
direction: if edge == ferese_config::panel::Edge::Top {
-1.
} else {
1.
},
direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. },
})
}
+94 -11
View File
@@ -3,18 +3,50 @@
use super::*;
use std::time::Instant;
pub(crate) struct ContentMeasurement<'a, M> {
pub content: Element<'a, M>,
pub width: f32,
pub measured: Box<dyn Fn(Size) + 'a>,
}
pub(crate) fn geometry_driven<'a, M: 'a>(
revision: Option<Instant>,
build: impl Fn(Instant) -> Element<'a, M> + 'a,
active: impl Fn(Instant) -> bool + 'a,
after_frame: impl Fn(Instant) + 'a,
completion: impl Fn(Instant) -> Option<M> + 'a,
measurement: Option<ContentMeasurement<'a, M>>,
) -> Element<'a, M> {
driven(revision, build, active, after_frame, completion, true, measurement)
}
pub(crate) fn frame_driven<'a, M: 'a>(
revision: Option<Instant>,
build: impl Fn(Instant) -> Element<'a, M> + 'a,
active: impl Fn(Instant) -> bool + 'a,
after_frame: impl Fn(Instant) + 'a,
completion: impl Fn(Instant) -> Option<M> + 'a,
) -> Element<'a, M> {
driven(revision, build, active, after_frame, completion, false, None)
}
fn driven<'a, M: 'a>(
revision: Option<Instant>,
build: impl Fn(Instant) -> Element<'a, M> + 'a,
active: impl Fn(Instant) -> bool + 'a,
after_frame: impl Fn(Instant) + 'a,
completion: impl Fn(Instant) -> Option<M> + 'a,
geometry_changes: bool,
measurement: Option<ContentMeasurement<'a, M>>,
) -> Element<'a, M> {
let now = Instant::now();
let content = build(now);
let content_active = active(now);
Element::new(FrameDriven {
content,
measurement,
measured: false,
geometry_changes,
content_active,
revision,
build: Box::new(build),
@@ -29,6 +61,8 @@ struct FrameState {
revision: Option<Instant>,
last_frame: Option<Instant>,
completed: bool,
pending: bool,
limits: Option<layout::Limits>,
}
impl FrameState {
@@ -42,6 +76,7 @@ impl FrameState {
return false;
}
self.last_frame = Some(at);
self.pending = true;
true
}
@@ -56,7 +91,10 @@ impl FrameState {
struct FrameDriven<'a, M> {
content: Element<'a, M>,
measurement: Option<ContentMeasurement<'a, M>>,
measured: bool,
content_active: bool,
geometry_changes: bool,
revision: Option<Instant>,
build: Box<dyn Fn(Instant) -> Element<'a, M> + 'a>,
active: Box<dyn Fn(Instant) -> bool + 'a>,
@@ -72,10 +110,20 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
widget::tree::State::new(FrameState::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
std::iter::once(widget::Tree::new(&self.content))
.chain(self.measurement.iter().map(|m| widget::Tree::new(&m.content)))
.collect()
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
tree.children.truncate(1 + usize::from(self.measurement.is_some()));
tree.children[0].diff(&mut self.content);
if let Some(measurement) = &mut self.measurement {
if tree.children.len() == 1 {
tree.children.push(widget::Tree::new(&measurement.content));
} else {
tree.children[1].diff(&mut measurement.content);
}
}
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
@@ -86,6 +134,23 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
tree.state.downcast_mut::<FrameState>().limits = Some(*limits);
if !self.measured {
if let Some(measurement) = &mut self.measurement {
// Measure at the final width once, so spring overshoot never changes wrapping or its target.
let probe_limits = layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
let node = measurement
.content
.as_widget_mut()
.layout(&mut tree.children[1], renderer, &probe_limits);
(measurement.measured)(node.size());
let now = Instant::now();
self.content = (self.build)(now);
self.content_active = (self.active)(now);
tree.children[0].diff(&mut self.content);
}
self.measured = true;
}
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
@@ -117,39 +182,57 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
} else {
None
};
let mut updated_geometry = false;
let mut fresh_layout = None;
if let Some(at) = redraw {
// Only paint parameters depend on time. Preserve the child tree's
// focus/hover/scroll state and its existing layout.
// Preserve focus and scroll state while sampling the current frame.
let active = (self.active)(at);
// Include the final settled sample, then reuse the tree on hover
// and other redraws. Application updates build fresh content.
if active || self.content_active {
self.content = (self.build)(at);
tree.children[0].diff(&mut self.content);
if self.geometry_changes {
self.content_active = (self.active)(at);
let limits = tree
.state
.downcast_ref::<FrameState>()
.limits
.expect("laid-out animation");
fresh_layout = Some(
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, &limits)
.move_to(layout.position()),
);
updated_geometry = true;
}
}
self.content_active = active;
self.content_active = (self.active)(at);
}
// Iced revalidates the base layout without replaying the event. Publish
// the measured shape and run the child against this frame's layout now.
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
fresh_layout.as_ref().map_or(layout, Layout::new),
cursor,
renderer,
clipboard,
shell,
viewport,
);
if updated_geometry {
shell.invalidate_layout();
}
if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
let active = (self.active)(*at);
if active {
shell.request_redraw();
}
// Iced can replay the same redraw after a child invalidates layout.
// Keep its NextFrame request, but sample/publish only once.
if redraw.is_some() {
// Complete only after a valid layout, including the replay of this frame.
if std::mem::take(&mut tree.state.downcast_mut::<FrameState>().pending) {
(self.after_frame)(*at);
}
if redraw.is_some() {
let completion = (self.completion)(*at);
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
&& let Some(message) = completion
+100 -38
View File
@@ -27,6 +27,7 @@ fn blend(base: Color, foreground: Color, amount: f32) -> Color {
}
fn icon_well<'a>(
render: crate::rendering::Rendering,
content: impl Into<Element<'a, cosmic::Action<Message>>>,
foreground: Color,
extent: u16,
@@ -36,7 +37,7 @@ fn icon_well<'a>(
.center_y(extent)
.class(ferese_theme::controls::surface(
foreground.scale_alpha(0.07),
motion::radius(f32::from(extent) / 2.0),
render.radius(f32::from(extent) / 2.0),
))
.into()
}
@@ -51,6 +52,7 @@ fn age_label(age: Duration) -> String {
}
fn card_button<'a>(
render: crate::rendering::Rendering,
content: impl Into<Element<'a, cosmic::Action<Message>>>,
message: Message,
foreground: Color,
@@ -65,7 +67,7 @@ fn card_button<'a>(
.class(ferese_theme::controls::notification_button(
foreground,
foreground.scale_alpha(0.08),
if round { motion::radius(11.) } else { 0. },
if round { render.radius(11.) } else { 0. },
round,
))
.into()
@@ -89,6 +91,7 @@ fn card_surface<'a>(
}
fn icon_control<'a>(
render: crate::rendering::Rendering,
source: &'static [u8],
label: &'static str,
message: Message,
@@ -97,6 +100,7 @@ fn icon_control<'a>(
extent: u16,
) -> Element<'a, cosmic::Action<Message>> {
let control = card_button(
render,
container(bar_icon(source, 14, foreground))
.center_x(extent)
.center_y(extent),
@@ -107,14 +111,14 @@ fn icon_control<'a>(
);
cosmic::widget::tooltip(
control,
text(label).size(11).class(theme::Text::Color(foreground)),
render.text(label).size(11).class(theme::Text::Color(foreground)),
cosmic::widget::tooltip::Position::Left,
)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(background)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(12.0).into(),
radius: render.radius(12.0).into(),
..Default::default()
},
text_color: Some(foreground),
@@ -145,13 +149,30 @@ impl FereseShell {
})
.and_then(|output| output.size)
.map_or(HISTORY_MAX_HEIGHT, |(_, height)| {
let geometry = self.config.panels[0].geometry;
let geometry = self
.menu
.as_ref()
.map(|menu| self.panel_for_bar(menu.anchor.parent))
.unwrap_or(&self.config.panels[0])
.resolved_geometry();
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0)
})
.min(HISTORY_MAX_HEIGHT)
}
pub(super) fn toggle_notification_history(&mut self) -> Task<Message> {
if let Some(parent) = self.active_bar()
&& self.hub_mode(parent).is_some()
{
return if self.hub_mode(parent)
== Some(ferese_panel::morph::Mode::Expanded(
ferese_panel::morph::Content::Notifications,
)) {
self.close_hub(parent)
} else {
self.open_hub(parent, status_ui::Menu::Notifications)
};
}
if self.notifications.history_open {
return self.close_menu();
}
@@ -174,7 +195,7 @@ impl FereseShell {
x: (width - 48).max(0),
y: 0,
width: 24,
height: self.config.panels[0].geometry.height.round() as i32,
height: self.config.panels[0].resolved_geometry().height.round() as i32,
},
};
self.open_menu(status_ui::Menu::Notifications, anchor)
@@ -195,7 +216,10 @@ impl FereseShell {
});
}
let count = self.notifications.popup_groups().len();
let wanted = self.notifications.ready && !self.notifications.history_open && count > 0;
let wanted = self.config.panels[0].style != ferese_panel::PanelStyle::Morph
&& self.notifications.ready
&& !self.notifications.history_open
&& count > 0;
let selected = self
.outputs
.iter()
@@ -236,7 +260,13 @@ impl FereseShell {
.find(|entry| entry.output == output)
.and_then(|entry| entry.size)
.map_or(1080, |size| size.1);
let top = self.config.panels[0].geometry.height + self.config.panels[0].geometry.edge_margin as f32 + 12.0;
let geometry = self
.outputs
.iter()
.find(|entry| entry.output == output)
.map(|entry| entry.runtime.panel(&self.config.panels[0]).resolved_geometry())
.unwrap_or_else(|| self.config.panels[0].resolved_geometry());
let top = geometry.height + geometry.edge_margin as f32 + 12.0;
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
if let Some(surface) = &mut self.notification_surface {
if surface.height != height {
@@ -306,7 +336,7 @@ impl FereseShell {
let divider = tint(palette.border);
let well = blend(base, foreground, 0.07);
let id = notice.id;
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
let mut bold = self.render.font;
bold.weight = cosmic::iced::font::Weight::Bold;
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() {
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
@@ -316,7 +346,7 @@ impl FereseShell {
icon::from_name(notice.icon.as_str()).size(18).icon()
}
.opacity(opacity);
let icon_well = icon_well(app_icon, foreground, 30);
let icon_well = icon_well(self.render, app_icon, foreground, 30);
let icon: Element<'_, cosmic::Action<Message>> = if count > 1 {
let badge_fill = ferese_theme::composite(
color(palette.accent),
@@ -328,7 +358,8 @@ impl FereseShell {
);
let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let badge = container(
text(count.to_string())
self.render
.text(count.to_string())
.size(10)
.font(bold)
.class(theme::Text::Color(badge_text.scale_alpha(opacity))),
@@ -339,7 +370,7 @@ impl FereseShell {
background: Some(Background::Color(badge_fill.scale_alpha(opacity))),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(8.0).into(),
radius: self.render.radius(8.0).into(),
..Default::default()
},
..Default::default()
@@ -372,17 +403,25 @@ impl FereseShell {
.align_y(alignment::Vertical::Center)
.push(icon)
.push(
container(text(app).size(11).font(bold).class(theme::Text::Color(muted)))
.width(Length::Fill)
.height(16)
.clip(true),
container(
self.render
.text(app)
.size(11)
.font(bold)
.class(theme::Text::Color(muted)),
)
.width(Length::Fill)
.height(16)
.clip(true),
)
.push(
text(age_label(notice.received_at.elapsed()))
self.render
.text(age_label(notice.received_at.elapsed()))
.size(11)
.class(theme::Text::Color(muted)),
)
.push(icon_control(
self.render,
ferese_theme::icons::CLOSE,
"Dismiss notification",
if history && count > 1 {
@@ -398,7 +437,8 @@ impl FereseShell {
));
let mut content = column([]).spacing(4).push(header).push(
container(
text(notice.title.clone())
self.render
.text(notice.title.clone())
.size(14)
.font(bold)
.class(theme::Text::Color(foreground)),
@@ -417,7 +457,7 @@ impl FereseShell {
};
if !body.is_empty() {
content = content.push(
container(text(body).size(13).class(theme::Text::Color(muted)))
container(self.render.text(body).size(13).class(theme::Text::Color(muted)))
.height(if history && count == 1 {
Length::Shrink
} else {
@@ -447,8 +487,10 @@ impl FereseShell {
));
}
actions = actions.push(card_button(
self.render,
container(
text(label.clone())
self.render
.text(label.clone())
.font(bold)
.size(13)
.class(theme::Text::Color(accent)),
@@ -511,7 +553,7 @@ impl FereseShell {
})),
border: Border {
shape: BorderShape::Continuous,
radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(),
radius: [0.0, 0.0, self.render.radius(6.0), self.render.radius(6.0)].into(),
..Default::default()
},
..Default::default()
@@ -579,7 +621,7 @@ impl FereseShell {
let base = color(palette.surface_base);
let compositor_material = surface.is_some_and(|menu| menu.effects.is_some());
let well = blend(base, primary, 0.07);
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
let mut bold = self.render.font;
bold.weight = cosmic::iced::font::Weight::Bold;
let groups = self.notifications.history_groups();
let count = self.notifications.entries.len();
@@ -596,17 +638,19 @@ impl FereseShell {
let heading = column([])
.spacing(3)
.push(
text("Notifications")
self.render
.text("Notifications")
.size(16)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(text(subtitle).size(11).class(theme::Text::Color(muted)));
.push(self.render.text(subtitle).size(11).class(theme::Text::Color(muted)));
let header = row([])
.spacing(10)
.align_y(alignment::Vertical::Center)
.push(container(heading).width(Length::Fill))
.push(icon_control(
self.render,
ferese_theme::icons::CLOSE,
"Close notification center",
Message::ToggleNotificationHistory,
@@ -629,27 +673,31 @@ impl FereseShell {
column([])
.spacing(2)
.push(
text("Do Not Disturb")
self.render
.text("Do Not Disturb")
.size(12)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
text(if self.notifications.dnd {
"Popups paused"
} else {
"Popups enabled"
})
.size(11)
.class(theme::Text::Color(muted)),
self.render
.text(if self.notifications.dnd {
"Popups paused"
} else {
"Popups enabled"
})
.size(11)
.class(theme::Text::Color(muted)),
),
)
.width(Length::Fill),
)
.push(motion::button(
self.render,
button::custom(
container(
text(if self.notifications.dnd { "On" } else { "Off" })
self.render
.text(if self.notifications.dnd { "On" } else { "Off" })
.size(12)
.font(bold)
.class(theme::Text::Color(dnd_color)),
@@ -672,18 +720,21 @@ impl FereseShell {
.spacing(10)
.align_x(alignment::Horizontal::Center)
.push(icon_well(
self.render,
accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent),
primary,
56,
))
.push(
text("You're all caught up")
self.render
.text("You're all caught up")
.size(15)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
text("New notifications will appear here.")
self.render
.text("New notifications will appear here.")
.size(11)
.class(theme::Text::Color(muted)),
);
@@ -693,13 +744,15 @@ impl FereseShell {
row([])
.align_y(alignment::Vertical::Center)
.push(
text("Recent")
self.render
.text("Recent")
.size(11)
.font(bold)
.class(theme::Text::Color(muted))
.width(Length::Fill),
)
.push(icon_control(
self.render,
ferese_theme::icons::TRASH,
"Clear all notifications",
Message::ClearNotifications,
@@ -721,13 +774,15 @@ impl FereseShell {
.spacing(6)
.align_y(alignment::Vertical::Center)
.push(
text(format!("{} · {}", app, group.len()))
self.render
.text(format!("{} · {}", app, group.len()))
.size(11)
.font(bold)
.class(theme::Text::Color(muted))
.width(Length::Fill),
)
.push(icon_control(
self.render,
ferese_theme::icons::CHEVRON_UP,
"Collapse group",
Message::ToggleNotificationGroup(latest.app.clone()),
@@ -736,6 +791,7 @@ impl FereseShell {
24,
))
.push(icon_control(
self.render,
ferese_theme::icons::TRASH,
"Clear group",
Message::RemoveNotificationGroup(latest.app.clone()),
@@ -834,7 +890,12 @@ mod tests {
.unwrap();
for foreground in [Color::BLACK, Color::WHITE] {
for extent in [30, 56] {
let inset = icon_well(cosmic::widget::Space::new(), foreground, extent);
let inset = icon_well(
crate::rendering::Rendering::new(&crate::config::ShellConfig::default()),
cosmic::widget::Space::new(),
foreground,
extent,
);
let controls = ferese_theme::menus::section(
cosmic::widget::Space::new().width(100).height(40).into(),
foreground,
@@ -899,6 +960,7 @@ mod tests {
for history in [false, true] {
for scale in [1.0, 1.25, 1.5] {
let action = card_button(
crate::rendering::Rendering::new(&crate::config::ShellConfig::default()),
cosmic::widget::Space::new().height(38),
Message::InvokeNotification(1, "action".into()),
Color::WHITE,
+6 -2
View File
@@ -279,10 +279,11 @@ pub struct Center {
pub entries: VecDeque<Notice>,
toasts: VecDeque<Toast>,
last_tick: Instant,
motion: super::motion::Settings,
}
impl Center {
pub fn new(config: ferese_config::notifications::NotificationConfig) -> Self {
pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self {
Self {
service: Service::start(&config),
ready: false,
@@ -294,6 +295,7 @@ impl Center {
entries: VecDeque::new(),
toasts: VecDeque::new(),
last_tick: Instant::now(),
motion,
}
}
@@ -321,6 +323,7 @@ impl Center {
}
pub fn update_motion_settings(&mut self, settings: super::motion::Settings) {
self.motion = settings;
for toast in &mut self.toasts {
toast.motion.update_settings(settings);
}
@@ -518,7 +521,7 @@ impl Center {
self.toasts.push_back(Toast {
id: notice.id,
motion: {
let mut motion = super::motion::PopupMotion::new(super::motion::settings());
let mut motion = super::motion::PopupMotion::new(self.motion);
motion.begin(Instant::now());
motion
},
@@ -641,6 +644,7 @@ mod tests {
let (commands, receiver) = mpsc::sync_channel(256);
let center = Center {
config: NotificationConfig::default(),
motion: Default::default(),
service: Service {
timeout: Arc::new(std::sync::atomic::AtomicU32::new(6000)),
events: Events(Arc::new(tokio::sync::Mutex::new(events))),
+1 -1
View File
@@ -1 +1 @@
pub(crate) use ferese_config::panel::*;
pub(crate) use ferese_panel::*;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,199 @@
use cosmic::app::Task;
use cosmic::iced::{Alignment, Length, window};
use cosmic::widget::{button, container, row};
use cosmic::{Element, theme};
use ferese_theme::icons;
use super::{Event, Peek};
use crate::{FereseShell, Message, recording, status_ui};
impl FereseShell {
pub(super) fn hub_low_battery(&self) -> Option<u8> {
self.status
.battery
.as_ref()
.filter(|battery| {
battery.percent < self.config.status.low_battery_threshold
&& matches!(battery.status.as_str(), "Discharging" | "Pending discharge" | "Empty")
})
.map(|battery| battery.percent)
}
pub(super) fn hub_priority(&self) -> Option<Peek> {
if matches!(
self.recorder.state,
recording::State::Recording(_) | recording::State::Saving
) {
Some(Peek::Recording)
} else {
self.hub_low_battery().map(|_| Peek::Battery)
}
}
pub(super) fn hub_peek_content(&self, id: window::Id) -> (Peek, f64) {
if let Some(priority) = self.hub_priority() {
return (
priority,
if priority == Peek::Recording && self.hub_low_battery().is_some() {
280.
} else {
240.
},
);
}
let player = self.media.snapshot.selected.as_ref().filter(|player| {
matches!(
player.status,
ferese_ipc::media::Playback::Playing | ferese_ipc::media::Playback::Paused
)
});
let maximum = self.panel_for_bar(id).morph.peek_width.clamp(200., 280.);
player.map_or_else(
|| {
(
Peek::Date,
(self.clock.chars().count() as f64 * 7. + 54.).clamp(200., maximum),
)
},
|player| {
(
Peek::Media,
(player.label().chars().count() as f64 * 7. + 77.).clamp(200., maximum),
)
},
)
}
pub(crate) fn sync_hub_activity(&mut self) -> Task<Message> {
let priority = self.hub_priority();
let changes: Vec<_> = self
.outputs
.iter()
.filter(|entry| entry.runtime.hub.is_some())
.map(|entry| (entry.bar, self.hub_peek_content(entry.bar)))
.collect();
Task::batch(
changes
.into_iter()
.map(|(id, peek)| self.transition_hub(id, Event::Activity { priority, peek })),
)
}
pub(super) fn hub_activity_strip(
&self,
id: window::Id,
priority: Peek,
expanded: bool,
intrinsic: bool,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let palette = self.config.theme.palette();
let foreground = palette.text.scale_alpha(opacity);
let warning = palette.error.scale_alpha(opacity);
let width = if intrinsic || !expanded {
Length::Shrink
} else {
Length::Fill
};
if priority == Peek::Battery {
let percent = self
.hub_low_battery()
.or_else(|| self.status.battery.as_ref().map(|b| b.percent));
let label = percent.map_or_else(
|| "Battery".into(),
|percent| {
if expanded {
format!("Low battery · {percent}%")
} else {
format!("{percent}% battery")
}
},
);
return row![
icons::tinted(icons::BATTERY_25, 18, warning),
self.render
.text(label)
.size(13)
.class(theme::Text::Color(foreground))
.width(width)
.align_x(if expanded { Alignment::End } else { Alignment::Start })
]
.spacing(8)
.align_y(Alignment::Center)
.width(width)
.into();
}
let elapsed = self.recorder.elapsed();
let saving = matches!(self.recorder.state, recording::State::Saving);
let mut strip = row::with_capacity(4)
.spacing(8)
.align_y(Alignment::Center)
.width(width)
.push(icons::tinted(
if saving { icons::RECORD_SAVING } else { icons::RECORD },
18,
warning,
));
if let Some(elapsed) = elapsed {
if expanded {
strip = strip.push(
self.render
.text("Recording")
.size(13)
.class(theme::Text::Color(foreground))
.width(Length::Fill),
);
}
// Reserve the same width for every second, regardless of digit advances.
let timer_width = elapsed.len().max(5) as f32 * 8.;
strip = strip.push(
container(self.render.text(elapsed).size(13).class(theme::Text::Color(foreground)))
.center_x(timer_width),
);
} else {
strip = strip.push(
self.render
.text(if saving && !expanded {
"Saving…"
} else if saving {
"Saving recording…"
} else {
"Recording ended"
})
.size(13)
.class(theme::Text::Color(foreground))
.width(width),
);
}
if let Some(percent) = self.hub_low_battery() {
let style = button::Style {
text_color: Some(warning),
..Default::default()
};
strip = strip.push(
button::custom(
row![
icons::tinted(icons::BATTERY_25, 16, warning),
self.render
.text(format!("{percent}%"))
.size(12)
.class(theme::Text::Color(warning))
]
.spacing(4)
.align_y(Alignment::Center),
)
.name(format!("Low battery, {percent} percent. Open battery controls"))
.padding(0)
.height(28)
.class(theme::Button::Custom {
active: Box::new(move |_, _| style),
hovered: Box::new(move |_, _| style),
pressed: Box::new(move |_, _| style),
disabled: Box::new(move |_| style),
})
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Battery))),
);
}
strip.into()
}
}
@@ -0,0 +1,170 @@
use crate::Message;
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
use cosmic::{Element, Theme};
type Action = cosmic::Action<Message>;
// Content keeps its destination layout while the shell springs around it.
// Centering against the animated width cancels the shell's movement; anchoring
// to the screen edge keeps its vertical position stable too.
pub(super) fn view(content: Element<'_, Action>, size: [f64; 2], edge: ferese_panel::Edge) -> Element<'_, Action> {
Element::new(ContentFrame {
content,
target: Size::new(size[0] as f32, size[1] as f32),
edge,
})
}
struct ContentFrame<'a> {
content: Element<'a, Action>,
target: Size,
edge: ferese_panel::Edge,
}
impl Widget<Action, Theme, cosmic::Renderer> for ContentFrame<'_> {
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> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
let size = limits.resolve(Length::Fill, Length::Fill, Size::ZERO);
let child = self
.content
.as_widget_mut()
.layout(
&mut tree.children[0],
renderer,
&layout::Limits::new(self.target, self.target),
)
.move_to(cosmic::iced::Point::new(
(size.width - self.target.width) / 2.,
if self.edge == ferese_panel::Edge::Bottom {
size.height - self.target.height
} else {
0.
},
));
layout::Node::with_children(size, vec![child])
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Action>,
viewport: &Rectangle,
) {
let viewport = viewport.intersection(&layout.bounds()).unwrap_or_default();
let cursor = if cursor.is_over(viewport) {
cursor
} else {
mouse::Cursor::Unavailable
};
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout.children().next().expect("content layout"),
cursor,
renderer,
clipboard,
shell,
&viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
if let Some(clip) = viewport.intersection(&layout.bounds()) {
renderer.with_layer(clip, |renderer| {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout.children().next().expect("content layout"),
cursor,
&clip,
);
});
}
}
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(
layout.children().next().expect("content layout"),
&tree.children[0],
cursor,
)
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout.children().next().expect("content layout"),
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout.children().next().expect("content layout"),
renderer,
operation,
);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Action, Theme, cosmic::Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout.children().next().expect("content layout"),
renderer,
viewport,
translation,
)
}
}
@@ -0,0 +1,152 @@
use std::time::Instant;
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Color, Event, Length, Rectangle, Size, window};
use cosmic::{Element, Theme};
use crate::Message;
pub(super) fn view<'a>(started: Instant, animate: bool, color: Color) -> Element<'a, cosmic::Action<Message>> {
Element::new(Equalizer {
started,
animate,
color,
})
}
struct Equalizer {
started: Instant,
animate: bool,
color: Color,
}
impl Widget<cosmic::Action<Message>, Theme, cosmic::Renderer> for Equalizer {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<Instant>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(self.started)
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fixed(13.), Length::Fixed(14.))
}
fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node {
layout::Node::new(limits.resolve(13., 14., Size::ZERO))
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
_: Layout<'_>,
_: mouse::Cursor,
_: &cosmic::Renderer,
_: &mut dyn Clipboard,
shell: &mut Shell<'_, cosmic::Action<Message>>,
_: &Rectangle,
) {
if let Event::Window(window::Event::RedrawRequested(at)) = event {
*tree.state.downcast_mut::<Instant>() = *at;
if self.animate {
shell.request_redraw();
}
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
_: &Theme,
_: &renderer::Style,
layout: Layout<'_>,
_: mouse::Cursor,
viewport: &Rectangle,
) {
let bounds = layout.bounds();
let Some(clip) = bounds.intersection(viewport) else {
return;
};
let elapsed = tree
.state
.downcast_ref::<Instant>()
.saturating_duration_since(self.started)
.as_secs_f32();
renderer.with_layer(clip, |renderer| {
for index in 0..3 {
let phase = (elapsed - index as f32 * 0.2) * std::f32::consts::TAU / 0.9;
let fraction = if self.animate { 0.65 + 0.35 * phase.sin() } else { 0.45 };
let height = bounds.height * fraction;
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
(bounds.x + index as f32 * 5., bounds.y + bounds.height - height).into(),
(3., height).into(),
),
border: cosmic::iced::Border {
radius: 2.0.into(),
..Default::default()
},
..Default::default()
},
self.color,
);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::{clipboard, renderer::Headless};
#[test]
fn bars_redraw_without_rebuilding_layout_or_publishing_messages() {
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
for animate in [true, false] {
let started = Instant::now();
let mut element = view(started, animate, Color::WHITE);
let mut tree = widget::Tree::new(&element);
let node = element.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(100., 100.)),
);
let mut messages = Vec::new();
for frame in 0..120 {
let mut shell = Shell::new(&mut messages);
element.as_widget_mut().update(
&mut tree,
&Event::Window(window::Event::RedrawRequested(
started + std::time::Duration::from_millis(frame * 8),
)),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard::Null,
&mut shell,
&Rectangle::with_size(Size::new(100., 100.)),
);
assert!(!shell.is_layout_invalid());
assert_eq!(
shell.redraw_request(),
if animate {
window::RedrawRequest::NextFrame
} else {
window::RedrawRequest::Wait
}
);
}
assert!(messages.is_empty());
}
}
}
+126
View File
@@ -0,0 +1,126 @@
use crate::Message;
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Vector, window};
use cosmic::{Element, Theme};
type Action = cosmic::Action<Message>;
pub(super) fn area(content: Element<'_, Action>, id: window::Id) -> Element<'_, Action> {
Element::new(WheelArea { content, id })
}
// Handle the event directly: the pinned toolkit's MouseArea emits its scroll
// callback twice per wheel event, which doubles trackpad accumulation.
struct WheelArea<'a> {
content: Element<'a, Action>,
id: window::Id,
}
impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
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: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
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: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Action>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if !shell.is_event_captured()
&& cursor.is_over(layout.bounds())
&& cursor.is_over(*viewport)
&& let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event
{
shell.publish(cosmic::Action::App(Message::HubWorkspaceScroll(self.id, *delta)));
shell.capture_event();
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
}
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 mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::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: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Action, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
@@ -0,0 +1,196 @@
use std::time::Instant;
use ferese_panel::morph::Mode;
use super::{Peek, PreviousContent, Runtime, content};
use crate::status_ui::Menu;
/// A transient owns its expiry token and the content it interrupted.
#[derive(Clone, Copy, Default)]
pub(super) enum Transient {
#[default]
None,
Toast {
notice: u32,
token: Instant,
},
Workspaces {
token: Instant,
resume: (Peek, f64),
},
}
impl Transient {
pub(super) fn toast(self) -> Option<u32> {
match self {
Self::Toast { notice, .. } => Some(notice),
_ => None,
}
}
pub(super) fn workspace_token(self) -> Option<Instant> {
match self {
Self::Workspaces { token, .. } => Some(token),
_ => None,
}
}
}
pub(super) enum Event {
Hover {
entered: bool,
enabled: bool,
peek: (Peek, f64),
priority: Option<Peek>,
},
Open(Menu),
Close,
Activity {
priority: Option<Peek>,
peek: (Peek, f64),
},
Toast {
notice: u32,
token: Instant,
},
ToastExpired(Instant),
Workspaces(Instant),
WorkspacesExpired(Instant),
/// Recompute the allocation after a size change without changing content.
Resize,
#[cfg(test)]
Present(Mode, Option<Menu>),
}
impl Runtime {
fn restore_transient(&mut self) {
if let Transient::Workspaces { resume, .. } = self.transient {
(self.peek, self.peek_width) = resume;
}
self.transient = Transient::None;
}
/// All semantic state changes enter here. Retargeting below handles only geometry.
pub(super) fn transition(&mut self, event: Event, available: [f64; 2]) -> bool {
let before = self.sample(Instant::now());
let outgoing = PreviousContent {
menu: self.menu,
peek: self.peek,
toast: self.transient.toast(),
size: self.hub.target_size(),
};
let mut replace_content = false;
let current = self.hub.mode();
let (mode, menu) = match event {
Event::Hover {
entered,
enabled,
peek,
priority,
} => {
self.hovered = entered;
match current {
Mode::Idle if entered && enabled => {
(self.peek, self.peek_width) = peek;
self.priority = priority;
(Mode::Peek, None)
}
Mode::Peek if !entered => {
self.restore_transient();
(Mode::Idle, None)
}
_ => return false,
}
}
Event::Open(menu) => {
self.restore_transient();
self.last_menu = menu;
self.workspace_hover = None;
self.workspace_selected = None;
(Mode::Expanded(content(menu)), Some(menu))
}
Event::Close => {
self.restore_transient();
(Mode::Idle, None)
}
Event::Activity { priority, peek } => {
let changed = self.priority != priority;
let resized = priority.is_some() && current == Mode::Peek && self.peek_width != peek.1;
self.priority = priority;
if (!changed && !resized) || current.expanded() {
return false;
}
if current != Mode::Peek && !(current == Mode::Toast && priority.is_some()) {
return false;
}
// Preserve the outgoing content for its fade, but discard its expiry.
self.transient = Transient::None;
(self.peek, self.peek_width) = peek;
if changed && current == Mode::Peek {
replace_content = true;
}
(if self.hovered { Mode::Peek } else { Mode::Idle }, None)
}
Event::Toast { notice, token } => {
if current.expanded() || self.priority.is_some() {
return false;
}
self.restore_transient();
self.transient = Transient::Toast { notice, token };
(Mode::Toast, None)
}
Event::ToastExpired(token) => {
if !matches!(self.transient, Transient::Toast { token: live, .. } if live == token) {
return false;
}
self.restore_transient();
(Mode::Idle, None)
}
Event::Workspaces(token) => {
if !matches!(current, Mode::Idle | Mode::Peek) || self.priority.is_some() {
return false;
}
let resume = match self.transient {
Transient::Workspaces { resume, .. } => resume,
_ => {
let resume = (self.peek, self.peek_width);
self.peek = Peek::Workspaces;
self.peek_width = 260.;
if current == Mode::Peek {
replace_content = true;
}
resume
}
};
self.transient = Transient::Workspaces { token, resume };
(Mode::Peek, None)
}
Event::WorkspacesExpired(token) => {
if self.transient.workspace_token() != Some(token) {
return false;
}
self.restore_transient();
if self.hovered {
replace_content = true;
}
(if self.hovered { Mode::Peek } else { Mode::Idle }, None)
}
Event::Resize => (current, self.menu),
#[cfg(test)]
Event::Present(mode, menu) => {
self.restore_transient();
(mode, menu)
}
};
replace_content |= current == mode && self.menu != menu;
let changing_content = current != mode || replace_content;
if changing_content && (replace_content || before.opacity >= before.previous_opacity) {
self.previous = outgoing;
}
self.retarget(mode, available, menu);
if replace_content {
self.hub.replace_content();
}
true
}
}
@@ -0,0 +1,602 @@
mod drag;
mod thumbnail;
use cosmic::app::Task;
use cosmic::iced::advanced::widget::{Id, operation};
use cosmic::iced::{Alignment, Length, keyboard::key::Named, window};
use cosmic::widget::{button, column, container, mouse_area, row, scrollable};
use cosmic::{Element, theme};
use crate::control::{WindowSnapshot, WorkspaceSnapshot};
use crate::{FereseShell, Message, motion};
const TILE_WIDTH: f32 = 56.;
const TILE_HEIGHT: f32 = 40.;
const GAP: f32 = 8.;
fn tiles(mut workspaces: Vec<&WorkspaceSnapshot>, active: u64) -> Vec<&WorkspaceSnapshot> {
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
let empty = workspaces
.iter()
.find(|w| w.window_count == 0 && w.id != active)
.map(|w| w.id);
workspaces.retain(|w| w.window_count > 0 || w.id == active || Some(w.id) == empty);
workspaces
}
fn columns(width: f32) -> usize {
(((width + GAP) / (TILE_WIDTH + GAP)).floor() as usize).clamp(1, 5)
}
fn caption(workspace: &WorkspaceSnapshot) -> String {
let name = if workspace.name.is_empty() || workspace.name == workspace.index.to_string() {
workspace.index.to_string()
} else {
format!("{} · {}", workspace.index, workspace.name)
};
format!(
"{name} — {} {}",
workspace.window_count,
if workspace.window_count == 1 {
"window"
} else {
"windows"
}
)
}
fn tile_id(bar: window::Id, workspace: u64) -> Id {
Id::new(format!("morph-workspace-{bar:?}-{workspace}"))
}
struct FocusedWorkspace {
tiles: Vec<(Id, u64)>,
focused: Option<u64>,
}
impl cosmic::iced::advanced::widget::Operation<Option<u64>> for FocusedWorkspace {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<u64>>)) {
operate(self);
}
fn focusable(&mut self, id: Option<&Id>, _: cosmic::iced::Rectangle, state: &mut dyn operation::Focusable) {
if state.is_focused() {
self.focused = self
.tiles
.iter()
.find(|(tile, _)| Some(tile) == id)
.map(|(_, workspace)| *workspace)
.or(self.focused);
}
}
fn finish(&self) -> operation::Outcome<Option<u64>> {
operation::Outcome::Some(self.focused)
}
}
// Fit the actual rectangles together, preserving aspect and relative positions.
fn mini_rects(windows: &[&WindowSnapshot]) -> Vec<(u64, cosmic::iced::Rectangle)> {
let rectangles: Vec<_> = windows.iter().filter_map(|w| w.geometry.map(|r| (w.id, r))).collect();
if rectangles.is_empty() {
return Vec::new();
}
let x = rectangles.iter().map(|(_, r)| r[0]).fold(f64::INFINITY, f64::min);
let y = rectangles.iter().map(|(_, r)| r[1]).fold(f64::INFINITY, f64::min);
let right = rectangles
.iter()
.map(|(_, r)| r[0] + r[2])
.fold(f64::NEG_INFINITY, f64::max);
let bottom = rectangles
.iter()
.map(|(_, r)| r[1] + r[3])
.fold(f64::NEG_INFINITY, f64::max);
let scale = (44. / (right - x)).min(17. / (bottom - y));
let dx = (44. - (right - x) * scale) / 2.;
let dy = (17. - (bottom - y) * scale) / 2.;
rectangles
.into_iter()
.map(|(id, r)| {
(
id,
cosmic::iced::Rectangle {
x: (dx + (r[0] - x) * scale + 0.3) as f32,
y: (dy + (r[1] - y) * scale + 0.3) as f32,
width: (r[2] * scale - 0.6).max(0.2) as f32,
height: (r[3] * scale - 0.6).max(0.2) as f32,
},
)
})
.collect()
}
impl FereseShell {
pub(crate) fn hub_workspace_move(&mut self, id: window::Id, window: u64, destination: u64) -> Task<Message> {
let output = self.output_for_bar(id).map(|o| o.id);
let source = self
.snapshot
.windows
.iter()
.find(|w| w.id == window)
.map(|w| w.workspace);
let valid = |workspace| self.snapshot.workspaces_for_output(output).any(|w| w.id == workspace);
if source.is_some_and(valid)
&& valid(destination)
&& let Some(control) = &self.control
{
control.move_window_to_workspace(window, destination);
}
Task::none()
}
pub(crate) fn hub_workspace_hover(&mut self, id: window::Id, workspace: Option<u64>) -> Task<Message> {
if let Some(runtime) = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.runtime.hub.as_ref())
{
let mut runtime = runtime.borrow_mut();
runtime.workspace_hover = workspace;
runtime.workspace_selected = None;
}
Task::none()
}
pub(crate) fn hub_workspace_select(&mut self, id: window::Id, workspace: u64) -> Task<Message> {
let valid = self
.snapshot
.workspaces_for_output(self.output_for_bar(id).map(|o| o.id))
.any(|w| w.id == workspace);
if !valid {
return Task::none();
}
if let Some(control) = &self.control {
control.activate_workspace(workspace);
}
if let Some(runtime) = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.runtime.hub.as_ref())
{
runtime.borrow_mut().workspace_selection_pending = Some(workspace);
}
self.close_hub(id)
}
pub(crate) fn hub_workspace_key(&self, id: window::Id, key: Named) -> Task<Message> {
self.query_hub_workspace_focus(id, Some(key))
}
pub(crate) fn query_hub_workspace_focus(&self, id: window::Id, key: Option<Named>) -> Task<Message> {
let output = self.output_for_bar(id);
let tiles = tiles(
self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(),
output.map_or(0, |o| o.active_workspace),
)
.into_iter()
.map(|workspace| (tile_id(id, workspace.id), workspace.id))
.collect();
cosmic::iced::advanced::widget::operate(FocusedWorkspace { tiles, focused: None })
.map(move |focused| cosmic::Action::App(Message::HubWorkspaceFocus(id, focused, key)))
}
pub(crate) fn hub_workspace_focus(
&mut self,
id: window::Id,
focused: Option<u64>,
key: Option<Named>,
) -> Task<Message> {
if self.hub_mode(id)
!= Some(ferese_panel::morph::Mode::Expanded(
ferese_panel::morph::Content::Workspaces,
))
{
return Task::none();
}
let Some(output) = self.output_for_bar(id) else {
return Task::none();
};
let workspaces = tiles(
self.snapshot.workspaces_for_output(Some(output.id)).collect(),
output.active_workspace,
);
let Some(runtime) = self
.outputs
.iter()
.find(|e| e.bar == id)
.and_then(|e| e.runtime.hub.as_ref())
else {
return Task::none();
};
let mut runtime = runtime.borrow_mut();
runtime.workspace_selected = focused;
let Some(key) = key else {
return Task::none();
};
let selected = focused.unwrap_or(output.active_workspace);
let current = workspaces.iter().position(|w| w.id == selected).unwrap_or(0);
let cols = columns((runtime.hub.target_size()[0] - 28.) as f32);
let next = match key {
Named::ArrowLeft => current.saturating_sub(1),
Named::ArrowRight => (current + 1).min(workspaces.len().saturating_sub(1)),
Named::ArrowUp => current.saturating_sub(cols),
Named::ArrowDown => (current + cols).min(workspaces.len().saturating_sub(1)),
_ => return Task::none(),
};
let Some(workspace) = workspaces.get(next) else {
return Task::none();
};
runtime.workspace_selected = Some(workspace.id);
runtime.workspace_hover = None;
let rows = workspaces.len().div_ceil(cols);
Task::batch([
cosmic::iced::advanced::widget::operate(operation::focusable::focus(tile_id(id, workspace.id))),
cosmic::iced::widget::scrollable::snap_to(
Id::new(format!("morph-workspace-grid-{id:?}")),
cosmic::iced::widget::scrollable::RelativeOffset {
x: None,
y: Some(if rows > 2 {
(next / cols).saturating_sub(1) as f32 / (rows - 2) as f32
} else {
0.
}),
},
),
])
}
pub(crate) fn hub_workspace_grid(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
let output = self.output_for_bar(id);
let active = output.map_or(0, |o| o.active_workspace);
let workspaces = tiles(
self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(),
active,
);
let runtime = self
.outputs
.iter()
.find(|e| e.bar == id)
.and_then(|e| e.runtime.hub.as_ref())
.map(|r| r.borrow());
let width = runtime
.as_ref()
.map_or(312., |r| (r.hub.target_size()[0] - 28.) as f32)
.max(1.);
let cols = columns(width);
let hovered = runtime
.as_ref()
.and_then(|r| r.workspace_hover.or(r.workspace_selected))
.unwrap_or(active);
let current = workspaces
.iter()
.find(|w| w.id == hovered)
.or_else(|| workspaces.iter().find(|w| w.id == active));
let mut label = current.map_or_else(|| "No workspaces".to_owned(), |w| caption(w));
if let Some(workspace) = current.filter(|_| runtime.as_ref().is_some_and(|r| r.workspace_hover.is_some())) {
let titles = self
.snapshot
.windows
.iter()
.filter(|w| w.workspace == workspace.id && !w.title.is_empty())
.map(|w| w.title.as_str())
.collect::<Vec<_>>()
.join(", ");
if !titles.is_empty() {
label.push_str(&format!(" · {titles}"));
}
}
let palette = self.config.theme.palette();
let mut targets = Vec::new();
let mut grid = column::with_capacity(workspaces.len().div_ceil(cols)).spacing(GAP);
for group in workspaces.chunks(cols) {
let mut line = row::with_capacity(cols).spacing(GAP);
for workspace in group {
let workspace_id = workspace.id;
let index = workspace.index;
let windows: Vec<_> = self
.snapshot
.windows
.iter()
.filter(|w| w.workspace == workspace_id)
.collect();
let miniatures: thumbnail::Geometry = mini_rects(&windows).into();
let index_in_grid = targets.len();
let origin = cosmic::iced::Vector::new(
(index_in_grid % cols) as f32 * (TILE_WIDTH + GAP),
(index_in_grid / cols) as f32 * (TILE_HEIGHT + GAP),
);
targets.push(drag::Target {
workspace: workspace_id,
bounds: cosmic::iced::Rectangle::new(
cosmic::iced::Point::ORIGIN + origin,
cosmic::iced::Size::new(TILE_WIDTH.min(width), TILE_HEIGHT),
),
windows: miniatures
.iter()
.copied()
.map(|(id, rect)| (id, rect + origin + cosmic::iced::Vector::new(6., 19.)))
.collect(),
});
let urgent = workspace_id != active && windows.iter().any(|w| w.urgent);
let empty = workspace.window_count == 0 && workspace_id != active;
let label = format!(
"{}{}{}",
caption(workspace),
if workspace_id == active { ", active" } else { "" },
windows.iter().map(|w| format!(", {}", w.title)).collect::<String>()
);
let transition = self.workspace_ui.transition(workspace_id, workspace_id == active);
line = line.push(motion::geometry_driven(
transition.revision(),
move |now| {
let progress = transition.progress(now).clamp(0., 1.);
let alpha = opacity * if empty { 0.6 } else { 1. };
let foreground = ferese_theme::mix(palette.muted, palette.accent, progress).scale_alpha(alpha);
let fill = cosmic::iced::Color {
a: (0.055 + 0.105 * progress) * alpha,
..ferese_theme::mix(palette.text, palette.accent, progress)
};
let appearance = button::Style {
background: Some(fill.into()),
border_radius: 10.0.into(),
border_width: 0.5,
border_color: palette.accent.scale_alpha(progress * alpha * 0.5),
text_color: Some(foreground),
..Default::default()
};
let mut heading = row![
self.render
.text(index.to_string())
.size(11)
.class(theme::Text::Color(foreground))
.width(Length::Fill)
]
.align_y(Alignment::Center);
if urgent {
let pulse = if self.render.motion.motion_enabled() {
0.65 + 0.35
* (now
.saturating_duration_since(transition.revision().unwrap_or(now))
.as_secs_f32()
* 4.)
.cos()
} else {
1.
};
heading = heading.push(container(cosmic::widget::Space::new()).width(4).height(4).class(
theme::Container::custom(move |_| container::Style {
background: Some(palette.error.scale_alpha(alpha * pulse).into()),
border: cosmic::iced::Border {
radius: 2.0.into(),
..Default::default()
},
..Default::default()
}),
));
}
let content = column![
container(heading).height(14),
thumbnail::view(miniatures.clone(), foreground)
]
.spacing(1);
mouse_area(
button::custom(content)
.id(tile_id(id, workspace_id))
.name(label.clone())
.width(TILE_WIDTH.min(width))
.height(TILE_HEIGHT)
.padding([4, 6])
.class(theme::Button::Custom {
active: Box::new(move |focused, _| button::Style {
outline_width: if focused { 1. } else { 0. },
outline_color: palette.accent.scale_alpha(alpha),
..appearance
}),
hovered: Box::new(move |_, _| button::Style {
background: Some(palette.accent.scale_alpha(0.12 * alpha).into()),
..appearance
}),
pressed: Box::new(move |_, _| appearance),
disabled: Box::new(move |_| appearance),
})
.on_press(cosmic::Action::App(Message::HubWorkspaceSelect(id, workspace_id))),
)
.on_enter(cosmic::Action::App(Message::HubWorkspaceHover(id, Some(workspace_id))))
.on_exit(cosmic::Action::App(Message::HubWorkspaceHover(id, None)))
.into()
},
move |now| transition.active(now) || (urgent && self.render.motion.motion_enabled()),
|_| {},
|_| None,
None,
));
}
grid = grid.push(line);
}
column![
scrollable(drag::area(grid.into(), id, targets))
.id(Id::new(format!("morph-workspace-grid-{id:?}")))
.height(88)
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::default()
.width(0)
.scroller_width(0)
)),
self.render
.text(label)
.size(12)
.class(theme::Text::Color(palette.muted.scale_alpha(opacity)))
.width(Length::Fill)
.wrapping(cosmic::iced::widget::text::Wrapping::None),
]
.spacing(8)
.width(Length::Fill)
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn workspace(id: u64, count: u32) -> WorkspaceSnapshot {
WorkspaceSnapshot {
id,
index: id as u32,
name: id.to_string(),
output: Some(1),
window_count: count,
visible: false,
focused: false,
}
}
#[test]
fn grid_keeps_occupied_active_and_one_empty_destination_without_a_ten_tile_cap() {
let mut all: Vec<_> = (1..=14).map(|id| workspace(id, u32::from(id < 12))).collect();
all.reverse();
let shown = tiles(all.iter().collect(), 13);
assert_eq!(
shown.iter().map(|w| w.id).collect::<Vec<_>>(),
(1..=13).collect::<Vec<_>>()
);
assert_eq!(columns(312.), 5);
assert_eq!(columns(212.), 3);
assert_eq!(caption(&workspace(3, 1)), "3 — 1 window");
let mut named = workspace(3, 2);
named.name = "Chat".into();
assert_eq!(caption(&named), "3 · Chat — 2 windows");
}
#[test]
fn arrows_follow_the_tile_focused_by_tab_in_both_directions_and_narrow_grids() {
use cosmic::iced::Size;
use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget};
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
for (width, start, reverse, key, expected) in [
(340., 4, false, Named::ArrowDown, 10),
(340., 8, true, Named::ArrowLeft, 6),
(240., 4, false, Named::ArrowDown, 8),
] {
let mut shell = crate::tests::shell_with_measured_panel();
shell.config.panels[0].style = ferese_panel::PanelStyle::Morph;
shell.config.panels[0].morph.expanded_width = width;
shell.outputs[0]
.runtime
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
let bar = shell.outputs[0].bar;
shell.snapshot.outputs = vec![crate::control::OutputSnapshot {
id: 1,
name: "test".into(),
active_workspace: 1,
focused: true,
}];
shell.snapshot.workspaces = (1..=12).map(|id| workspace(id, 1)).collect();
drop(shell.open_hub_overview(bar));
shell.outputs[0]
.runtime
.hub
.as_ref()
.unwrap()
.borrow_mut()
.workspace_selected = Some(1);
let focused = {
let mut view = shell.hub_workspace_grid(bar, 1.);
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 as f32 - 28., 128.)),
);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut operation::focusable::focus::<()>(tile_id(bar, start)),
);
let mut tab: Box<dyn widget::Operation> = if reverse {
Box::new(operation::focusable::focus_previous())
} else {
Box::new(operation::focusable::focus_next())
};
loop {
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut tab);
match tab.finish() {
operation::Outcome::Chain(next) => tab = next,
_ => break,
}
}
let mut query = FocusedWorkspace {
tiles: (1..=12).map(|id| (tile_id(bar, id), id)).collect(),
focused: None,
};
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut operation::black_box(&mut query),
);
query.focused
};
assert_eq!(focused, Some(if reverse { start - 1 } else { start + 1 }));
drop(shell.hub_workspace_focus(bar, focused, None));
assert_eq!(
shell.outputs[0]
.runtime
.hub
.as_ref()
.unwrap()
.borrow()
.workspace_selected,
focused
);
drop(shell.hub_workspace_focus(bar, focused, Some(key)));
assert_eq!(
shell.outputs[0]
.runtime
.hub
.as_ref()
.unwrap()
.borrow()
.workspace_selected,
Some(expected)
);
}
}
#[test]
fn miniatures_preserve_real_layout_and_do_not_invent_missing_windows() {
let window = |id, rect| WindowSnapshot {
id,
workspace: 1,
app_id: "test".into(),
title: "test".into(),
geometry: rect,
focused: false,
urgent: false,
fullscreen: false,
maximized: false,
floating: false,
};
let a = window(1, Some([-400., 20., 300., 200.]));
let b = window(2, Some([-100., 20., 100., 200.]));
let c = window(3, None);
let rectangles = mini_rects(&[&a, &b, &c]);
assert_eq!(rectangles.len(), 2);
assert_eq!(rectangles[0].0, 1);
let left = rectangles[0].1;
let right = rectangles[1].1;
assert!((left.height - right.height).abs() < 0.001);
assert!(left.width > right.width * 2.9);
assert!(left.x + left.width < right.x);
assert!(right.x + right.width <= 44. && left.y + left.height <= 17.);
}
}
@@ -0,0 +1,265 @@
use crate::Message;
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Vector, window};
use cosmic::{Element, Theme};
type Action = cosmic::Action<Message>;
pub(super) struct Target {
pub workspace: u64,
pub bounds: Rectangle,
pub windows: Vec<(u64, Rectangle)>,
}
pub(super) fn area(content: Element<'_, Action>, id: window::Id, targets: Vec<Target>) -> Element<'_, Action> {
Element::new(DragArea { content, id, targets })
}
struct DragArea<'a> {
content: Element<'a, Action>,
id: window::Id,
targets: Vec<Target>,
}
#[derive(Default)]
struct State {
pressed: Option<(u64, u64, cosmic::iced::Point)>,
moved: bool,
}
impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
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: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
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: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Action>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
let point = cursor
.position()
.map(|p| p - Vector::new(layout.bounds().x, layout.bounds().y));
let target = point
.filter(|_| cursor.is_over(*viewport))
.and_then(|p| self.targets.iter().find(|t| t.bounds.contains(p)));
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let (Some(target), Some(point)) = (target, point)
&& let Some((window, _)) = target.windows.iter().rev().find(|(_, rect)| rect.contains(point))
{
state.pressed = Some((*window, target.workspace, point));
state.moved = false;
shell.capture_event();
return;
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if let (Some((_, _, start)), Some(point)) = (state.pressed, point) {
state.moved |= start.distance(point) > 4.;
shell.request_redraw();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if let Some((window, source, _)) = state.pressed.take() {
if let Some(target) = target {
if state.moved && target.workspace != source {
shell.publish(Action::App(Message::HubWorkspaceMove(
self.id,
window,
target.workspace,
)));
} else if !state.moved && target.workspace == source {
shell.publish(Action::App(Message::HubWorkspaceSelect(self.id, source)));
}
}
state.moved = false;
shell.capture_event();
return;
}
}
Event::Window(window::Event::Unfocused) => {
state.pressed = None;
state.moved = false;
}
_ => {}
}
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 cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
}
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 mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
if tree.state.downcast_ref::<State>().pressed.is_some() {
return mouse::Interaction::Grabbing;
}
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::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: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Action, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::{clipboard, renderer::Headless};
#[test]
fn drag_moves_the_pressed_window_once_and_click_still_selects_workspace() {
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
None,
))
.unwrap();
let bar = window::Id::unique();
let build = || {
area(
cosmic::widget::Space::new().width(120).height(40).into(),
bar,
vec![
Target {
workspace: 100,
bounds: Rectangle::new((0., 0.).into(), (56., 40.).into()),
windows: vec![(999, Rectangle::new((6., 19.).into(), (44., 17.).into()))],
},
Target {
workspace: 101,
bounds: Rectangle::new((64., 0.).into(), (56., 40.).into()),
windows: vec![],
},
],
)
};
for (end, expected) in [((80., 25.), Some((999, 101))), ((15., 25.), None)] {
let mut view = build();
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(120., 40.)),
);
let mut messages = Vec::new();
for (event, cursor) in [
(
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
(15., 25.),
),
(Event::Mouse(mouse::Event::CursorMoved { position: end.into() }), end),
(Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end),
(Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end),
] {
view.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
mouse::Cursor::Available(cursor.into()),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::new((0., 0.).into(), (120., 40.).into()),
);
}
assert_eq!(
messages.len(),
1,
"one action per gesture, even with a repeated release"
);
match (&messages[0], expected) {
(Action::App(Message::HubWorkspaceMove(id, window, destination)), Some((w, d))) => {
assert_eq!((*id, *window, *destination), (bar, w, d))
}
(Action::App(Message::HubWorkspaceSelect(id, workspace)), None) => {
assert_eq!((*id, *workspace), (bar, 100))
}
_ => panic!("unexpected gesture action"),
}
}
}
}
@@ -0,0 +1,55 @@
use cosmic::iced::advanced::{Layout, Renderer as _, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Color, Length, Rectangle, Size};
use cosmic::{Element, Theme};
use std::sync::Arc;
pub(super) type Geometry = Arc<[(u64, Rectangle)]>;
pub(super) fn view<'a, M: 'a>(rectangles: Geometry, color: Color) -> Element<'a, M> {
Element::new(Thumbnail { rectangles, color })
}
struct Thumbnail {
rectangles: Geometry,
color: Color,
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Thumbnail {
fn size(&self) -> Size<Length> {
Size::new(Length::Fixed(44.), Length::Fixed(17.))
}
fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node {
layout::Node::new(limits.resolve(44., 17., Size::ZERO))
}
fn draw(
&self,
_: &widget::Tree,
renderer: &mut cosmic::Renderer,
_: &Theme,
_: &renderer::Style,
layout: Layout<'_>,
_: mouse::Cursor,
viewport: &Rectangle,
) {
let Some(clip) = layout.bounds().intersection(viewport) else {
return;
};
renderer.with_layer(clip, |renderer| {
for (_, rect) in self.rectangles.iter() {
renderer.fill_quad(
renderer::Quad {
bounds: *rect + (layout.bounds().position() - cosmic::iced::Point::ORIGIN),
border: cosmic::iced::Border {
radius: 0.8.into(),
..Default::default()
},
..Default::default()
},
self.color,
);
}
});
}
}
@@ -0,0 +1,427 @@
use std::time::{Duration, Instant};
use cosmic::app::Task;
use cosmic::iced::{Alignment, mouse::ScrollDelta, window};
use cosmic::widget::{button, container, row};
use cosmic::{Element, theme};
use ferese_panel::{ItemKind, morph::Mode};
use super::Event;
use crate::control::WorkspaceSnapshot;
use crate::{FereseShell, Message, color_with_opacity, motion};
const SLOT: f32 = 12.;
const ACTIVE_EXTRA: f32 = 12.;
const DOT_INSET: f32 = 3.;
const LIMIT: usize = 4;
struct Selection<'a> {
workspaces: Vec<&'a WorkspaceSnapshot>,
hidden: usize,
}
impl Selection<'_> {
fn width(&self) -> f32 {
if self.workspaces.is_empty() {
return 0.;
}
// Six-pixel gaps, room for the active pill, and an optional overflow count.
self.workspaces.len() as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET
+ if self.hidden > 0 {
6. + (self.hidden.to_string().len() + 1) as f32 * 7.
} else {
0.
}
}
}
fn select(mut workspaces: Vec<&WorkspaceSnapshot>, active: u64, budget: f32) -> Selection<'_> {
workspaces.retain(|workspace| workspace.window_count > 0 || workspace.id == active);
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
let count = workspaces.len();
if count == 0 || (count <= LIMIT && count as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET <= budget) {
return Selection { workspaces, hidden: 0 };
}
let overflow_width = 6. + (count.to_string().len() + 1) as f32 * 7.;
let visible = (((budget - overflow_width - ACTIVE_EXTRA + DOT_INSET) / SLOT)
.floor()
.max(1.) as usize)
.min(LIMIT)
.min(count);
let position = workspaces
.iter()
.position(|workspace| workspace.id == active)
.unwrap_or(0);
let start = position.saturating_sub(visible / 2).min(count - visible);
Selection {
workspaces: workspaces[start..start + visible].to_vec(),
hidden: count - visible,
}
}
#[derive(Default)]
pub(super) struct Scroll {
amount: f32,
pixels: bool,
last_input: Option<Instant>,
last_switch: Option<Instant>,
}
impl Scroll {
fn step(&mut self, delta: ScrollDelta, now: Instant) -> Option<i32> {
let (x, y, pixels) = match delta {
ScrollDelta::Lines { x, y } => (x, y, false),
ScrollDelta::Pixels { x, y } => (x, y, true),
};
let amount = if y.abs() >= x.abs() { y } else { -x };
if !amount.is_finite() || amount == 0. {
return None;
}
if pixels != self.pixels
|| self
.last_input
.is_none_or(|last| now.saturating_duration_since(last) > Duration::from_millis(250))
|| self.amount.signum() != amount.signum()
{
self.amount = 0.;
}
self.pixels = pixels;
self.last_input = Some(now);
if self
.last_switch
.is_some_and(|last| now.saturating_duration_since(last) < Duration::from_millis(120))
{
return None;
}
self.amount += amount;
let threshold = if pixels { 40. } else { 1. };
if self.amount.abs() < threshold {
return None;
}
let direction = if self.amount > 0. { -1 } else { 1 };
self.amount = 0.;
self.last_switch = Some(now);
Some(direction)
}
}
impl FereseShell {
fn hub_workspace_selection(&self, id: window::Id) -> Selection<'_> {
let panel = self.panel_for_bar(id);
let enabled = [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.any(|item| item.visible && matches!(item.kind, ItemKind::Workspaces { .. }));
let Some(output) = self.output_for_bar(id).filter(|_| enabled) else {
return Selection {
workspaces: Vec::new(),
hidden: 0,
};
};
let available = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.size)
.map_or(panel.morph.expanded_width, |(width, _)| {
(f64::from(width) - 2. * f64::from(panel.resolved_geometry().side_margins)).max(0.)
});
select(
self.snapshot.workspaces_for_output(Some(output.id)).collect(),
output.active_workspace,
// Keep the compact strip within its existing width budget.
(panel.morph.expanded_width.min(available) - 28. - 5. * 32. - 4.).max(0.) as f32,
)
}
pub(super) fn hub_workspace_width(&self, id: window::Id) -> f64 {
f64::from(self.hub_workspace_selection(id).width())
}
pub(crate) fn sync_hub_workspaces(&mut self) -> Task<Message> {
let changes: Vec<_> = self
.outputs
.iter()
.filter_map(|entry| {
let runtime = entry.runtime.hub.as_ref()?;
let width = self.hub_workspace_width(entry.bar);
let width = if width > 0. { width + 10. } else { 0. };
let mut runtime = runtime.borrow_mut();
if runtime.observed_workspace.is_none() {
runtime.observed_workspace = self.output_for_bar(entry.bar).map(|output| output.active_workspace);
}
if runtime.workspace_width == width {
return None;
}
runtime.workspace_width = width;
matches!(runtime.hub.mode(), Mode::Idle).then_some(entry.bar)
})
.collect();
Task::batch(changes.into_iter().map(|id| self.transition_hub(id, Event::Resize)))
}
pub(crate) fn open_hub_overview(&mut self, id: window::Id) -> Task<Message> {
self.open_hub(id, crate::status_ui::Menu::Workspaces)
}
pub(crate) fn sync_hub_workspace_feedback(&mut self, suppressed: bool) -> Task<Message> {
let changed: Vec<_> = self
.outputs
.iter()
.filter_map(|entry| {
let active = self.output_for_bar(entry.bar)?.active_workspace;
let mut runtime = entry.runtime.hub.as_ref()?.borrow_mut();
let previous = runtime.observed_workspace.replace(active);
let selected = runtime.workspace_selection_pending == Some(active);
if selected {
runtime.workspace_selection_pending = None;
}
(!suppressed
&& !selected
&& previous.is_some_and(|previous| previous != active)
&& matches!(runtime.hub.mode(), Mode::Idle | Mode::Peek))
.then_some(entry.bar)
})
.collect();
Task::batch(changed.into_iter().map(|id| self.show_hub_workspace_feedback(id)))
}
fn show_hub_workspace_feedback(&mut self, id: window::Id) -> Task<Message> {
if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. || self.hub_priority().is_some() {
return Task::none();
}
let Some(runtime) = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.runtime.hub.as_ref())
else {
return Task::none();
};
let revision = Instant::now();
let first = runtime.borrow().transient.workspace_token().is_none();
let policy = self.render.motion;
let fade = if first && policy.motion_enabled() {
0.25 / policy.speed
} else {
0.
};
let timeout = Duration::from_secs_f64(1.2 + fade);
Task::batch([
self.transition_hub(id, Event::Workspaces(revision)),
Task::perform(
async move {
tokio::time::sleep(timeout).await;
},
move |_| cosmic::Action::App(Message::HubWorkspaceFeedbackExpired(id, revision)),
),
])
}
pub(crate) fn hub_workspace_feedback_expired(&mut self, id: window::Id, revision: Instant) -> Task<Message> {
self.transition_hub(id, Event::WorkspacesExpired(revision))
}
pub(crate) fn scroll_hub_workspaces(&mut self, id: window::Id, delta: ScrollDelta) -> Task<Message> {
if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. {
return Task::none();
}
let Some(runtime) = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.runtime.hub.as_ref())
else {
return Task::none();
};
let Some(direction) = runtime.borrow_mut().workspace_scroll.step(delta, Instant::now()) else {
return Task::none();
};
let Some(output) = self.output_for_bar(id) else {
return Task::none();
};
// Include the trailing empty workspace so scrolling can create a new working space.
let mut workspaces: Vec<_> = self.snapshot.workspaces_for_output(Some(output.id)).collect();
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
if let Some(position) = workspaces
.iter()
.position(|workspace| workspace.id == output.active_workspace)
&& let Some(next) = position
.checked_add_signed(direction as isize)
.and_then(|next| workspaces.get(next))
&& let Some(control) = &self.control
{
control.activate_workspace(next.id);
}
self.dismiss_workspace_tooltip_for_bar(id)
}
pub(super) fn hub_workspaces(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
let selection = self.hub_workspace_selection(id);
let width = selection.width();
let output = self.output_for_bar(id);
let active = output.map_or(0, |output| output.active_workspace);
let palette = self.config.theme.palette();
let foreground = color_with_opacity(self.config.theme.text_primary, opacity);
let hidden_urgent = self.snapshot.windows.iter().any(|window| {
window.urgent
&& !selection
.workspaces
.iter()
.any(|workspace| workspace.id == window.workspace)
&& self
.snapshot
.workspaces_for_output(output.map(|output| output.id))
.any(|workspace| workspace.id == window.workspace)
});
let mut dots = row::with_capacity(LIMIT).align_y(Alignment::Center);
let mut label = selection
.workspaces
.iter()
.find(|workspace| workspace.id == active)
.map_or_else(
|| "Workspace overview".to_owned(),
|workspace| {
format!(
"Workspace {} · {} · {} windows · Active · Open overview",
workspace.index, workspace.name, workspace.window_count
)
},
);
for (index, workspace) in selection.workspaces.iter().enumerate() {
let urgent = self
.snapshot
.windows
.iter()
.any(|window| window.workspace == workspace.id && window.urgent);
if urgent {
label.push_str(&format!(" · Workspace {} needs attention", workspace.index));
}
let transition = self.workspace_ui.transition(workspace.id, workspace.id == active);
dots = dots.push(motion::geometry_driven(
transition.revision(),
move |now| {
let progress = transition.progress(now).clamp(0., 1.);
let color = if urgent {
palette.error
} else {
ferese_theme::mix(palette.muted, palette.accent, progress)
};
container(
container(cosmic::widget::Space::new())
.width(6. + 12. * progress)
.height(6.)
.class(theme::Container::custom(move |_| container::Style {
background: Some(color.scale_alpha(opacity).into()),
border: cosmic::iced::Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
})),
)
// The first dot starts at the group's edge; only the gaps
// between dots need the two half-slot insets.
.padding(cosmic::iced::Padding {
left: if index == 0 { 0. } else { DOT_INSET },
right: DOT_INSET,
..Default::default()
})
.center_y(28)
.into()
},
move |now| transition.active(now),
|_| {},
|_| None,
None,
));
}
if selection.hidden > 0 {
label.push_str(&format!(" · {} other workspaces", selection.hidden));
if hidden_urgent {
label.push_str(" · Another workspace needs attention");
}
dots = dots.push(
container(
self.render
.text(format!("+{}", selection.hidden))
.size(11)
.class(theme::Text::Color(if hidden_urgent {
palette.error.scale_alpha(opacity)
} else {
foreground
})),
)
.padding([0, 3]),
);
}
let appearance = button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
..Default::default()
};
let control = button::custom(dots)
.name(label)
.padding(0)
.width(width)
.height(28)
.class(theme::Button::Custom {
active: Box::new(move |_, _| appearance),
hovered: Box::new(move |_, _| appearance),
pressed: Box::new(move |_, _| appearance),
disabled: Box::new(move |_| appearance),
})
.on_press(cosmic::Action::App(Message::HubOverview(id)));
super::scroll::area(control.into(), id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overflow_keeps_the_active_empty_workspace_and_order() {
let workspaces: Vec<_> = (1..=20)
.map(|id| WorkspaceSnapshot {
id,
index: id as u32,
name: id.to_string(),
output: Some(1),
window_count: u32::from(id != 20),
visible: id == 20,
focused: id == 20,
})
.collect();
for active in [1, 8, 20] {
let selection = select(workspaces.iter().collect(), active, 200.);
assert!(selection.workspaces.iter().any(|workspace| workspace.id == active));
assert!(
selection
.workspaces
.windows(2)
.all(|pair| pair[0].index < pair[1].index)
);
assert_eq!(selection.workspaces.len(), 4);
assert_eq!(selection.hidden, if active == 20 { 16 } else { 15 });
let narrow = select(workspaces.iter().collect(), active, 148.);
assert!(narrow.width() <= 148.);
assert!(narrow.workspaces.iter().any(|workspace| workspace.id == active));
}
}
#[test]
fn trackpad_accumulates_and_resets_between_gestures() {
let mut scroll = Scroll::default();
let start = Instant::now();
let pixels = |y| ScrollDelta::Pixels { x: 0., y };
assert_eq!(scroll.step(pixels(-20.), start), None);
assert_eq!(scroll.step(pixels(-20.), start + Duration::from_millis(10)), Some(1));
assert_eq!(scroll.step(pixels(-100.), start + Duration::from_millis(20)), None);
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(200)), None);
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(500)), None);
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(510)), Some(-1));
assert_eq!(scroll.step(pixels(f32::NAN), start + Duration::from_secs(1)), None);
}
}
+1 -1
View File
@@ -1 +1 @@
pub(crate) use ferese_config::panel::layout::*;
pub(crate) use ferese_panel::layout::*;
+257
View File
@@ -0,0 +1,257 @@
use std::time::{Duration, Instant};
use cosmic::app::Task;
use cosmic::iced::platform_specific::shell::commands::layer_surface::{set_exclusive_zone, set_input_zone, set_size};
use cosmic::iced::window;
use ferese_panel::adaptive::{Adaptive, WindowState};
use ferese_panel::{Panel, PanelStyle};
use crate::{FereseShell, Message, control::ShellSnapshot, output_bar_hidden};
pub(super) struct Runtime {
pub(super) hub: Option<std::cell::RefCell<crate::panel_hub::Runtime>>,
projection: Option<Panel>,
adaptive: Adaptive,
epoch: Instant,
ticket: u64,
scheduled: Option<Duration>,
}
impl Runtime {
pub(super) fn new(panel: &Panel, snapshot: &ShellSnapshot, name: Option<&str>) -> Self {
let adaptive = Adaptive::new(window_state(snapshot, name));
let projection = project(panel, &adaptive);
Self {
hub: crate::panel_hub::new(panel),
projection,
adaptive,
epoch: Instant::now(),
ticket: 0,
scheduled: None,
}
}
pub(super) fn panel<'a>(&'a self, authored: &'a Panel) -> &'a Panel {
self.projection.as_ref().unwrap_or(authored)
}
pub(super) fn reset(&mut self, panel: &Panel, snapshot: &ShellSnapshot, name: Option<&str>) {
let ticket = self.ticket.wrapping_add(1);
*self = Self::new(panel, snapshot, name);
self.ticket = ticket;
}
pub(super) fn update_appearance(&mut self, panel: &Panel) {
if let Some(hub) = &self.hub {
hub.borrow_mut()
.hub
.pin_radii(panel.corner_radius.map(|r| r.0.map(f64::from)))
.expect("validated panel radii");
}
if let Some(projection) = &mut self.projection {
projection.border = panel.border;
projection.background_opacity = panel.background_opacity;
projection.corner_radius = panel.corner_radius;
}
}
fn observe(&mut self, panel: &Panel, snapshot: &ShellSnapshot, name: Option<&str>) -> bool {
if panel.style != PanelStyle::Adaptive {
return false;
}
if self
.adaptive
.observe(window_state(snapshot, name), self.epoch.elapsed())
{
self.projection = project(panel, &self.adaptive);
true
} else {
false
}
}
fn schedule(&mut self) -> Option<(u64, Duration)> {
let deadline = self.adaptive.deadline();
if self.scheduled == deadline {
return None;
}
self.scheduled = deadline;
self.ticket = self.ticket.wrapping_add(1);
deadline.map(|deadline| (self.ticket, deadline.saturating_sub(self.epoch.elapsed())))
}
}
fn project(panel: &Panel, adaptive: &Adaptive) -> Option<Panel> {
(panel.style == PanelStyle::Adaptive).then(|| {
let mut projection = panel.clone();
projection.style = adaptive.current().style().unwrap_or(PanelStyle::Floating);
projection
})
}
fn window_state(snapshot: &ShellSnapshot, name: Option<&str>) -> WindowState {
let Some(output) = snapshot
.outputs
.iter()
.find(|output| Some(output.name.as_str()) == name)
else {
return WindowState::default();
};
let mut state = WindowState::default();
for window in snapshot
.windows
.iter()
.filter(|window| window.workspace == output.active_workspace)
{
state.maximized |= window.maximized;
state.fullscreen |= window.fullscreen;
}
state
}
impl FereseShell {
pub(super) fn panel_for_bar(&self, id: window::Id) -> &Panel {
self.outputs
.iter()
.find(|entry| entry.bar == id)
.map_or(&self.config.panels[0], |entry| {
entry.runtime.panel(&self.config.panels[0])
})
}
pub(super) fn adaptive_deadline(&mut self, id: window::Id, ticket: u64) -> Task<Message> {
if self
.outputs
.iter()
.any(|entry| entry.bar == id && entry.runtime.ticket == ticket)
{
self.refresh_adaptive_panels()
} else {
Task::none()
}
}
pub(super) fn refresh_adaptive_panels(&mut self) -> Task<Message> {
let mut tasks = Vec::new();
let mut changed = Vec::new();
for entry in &mut self.outputs {
if entry
.runtime
.observe(&self.config.panels[0], &self.snapshot, entry.name.as_deref())
{
let panel = entry.runtime.panel(&self.config.panels[0]);
let geometry = panel.resolved_geometry();
let output = self
.snapshot
.outputs
.iter()
.find(|output| Some(output.name.as_str()) == entry.name.as_deref())
.map(|output| output.id);
entry.bar_regions.clear();
entry.panel_resolution = None;
tasks.push(set_input_zone(entry.bar, Some(Vec::new())));
tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32)));
tasks.push(set_exclusive_zone(
entry.bar,
panel.exclusive_zone(output_bar_hidden(&self.snapshot, output)),
));
changed.push(entry.bar);
}
if let Some((ticket, delay)) = entry.runtime.schedule() {
let bar = entry.bar;
tasks.push(Task::perform(
async move {
tokio::time::sleep(delay).await;
},
move |_| cosmic::Action::App(Message::AdaptiveDeadline(bar, ticket)),
));
}
}
let geometry_changed = !changed.is_empty();
for bar in changed {
tasks.push(self.update_panel_margin(bar, true));
tasks.push(self.dismiss_workspace_tooltip_for_bar(bar));
if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) {
tasks.push(self.destroy_menu());
}
}
if geometry_changed {
if let Some(surface) = self.notification_surface.take() {
tasks.push(crate::destroy_layer_surface(surface.id));
}
tasks.push(self.sync_notification_surface());
}
Task::batch(tasks)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_preserves_the_authored_composition_and_geometry_pins() {
let mut panel = Panel::from_preset(PanelStyle::Adaptive);
panel.geometry.side_margins = Some(24);
let mut runtime = Runtime::new(&panel, &ShellSnapshot::default(), None);
assert_eq!(runtime.panel(&panel).resolved_geometry().side_margins, 24);
runtime.adaptive.observe(
WindowState {
maximized: true,
fullscreen: false,
},
Duration::ZERO,
);
runtime.adaptive.observe(
WindowState {
maximized: true,
fullscreen: false,
},
Duration::from_millis(150),
);
runtime.projection = project(&panel, &runtime.adaptive);
assert_eq!(runtime.panel(&panel).resolved_geometry().side_margins, 0);
assert_eq!(runtime.panel(&panel).start, panel.start);
assert_eq!(panel.style, PanelStyle::Adaptive);
assert_eq!(panel.geometry.side_margins, Some(24));
}
#[test]
fn appearance_reload_updates_the_projection_without_resetting_adaptive_state() {
let mut panel = Panel::from_preset(PanelStyle::Adaptive);
let mut runtime = Runtime::new(&panel, &ShellSnapshot::default(), None);
runtime.adaptive.observe(
WindowState {
maximized: true,
fullscreen: false,
},
Duration::ZERO,
);
let pending = runtime.adaptive.deadline();
panel.background_opacity = Some(0.4);
panel.border = true;
panel.corner_radius = Some(ferese_panel::CornerRadii::uniform(12.));
runtime.update_appearance(&panel);
assert_eq!(runtime.panel(&panel).background_opacity, Some(0.4));
assert_eq!(runtime.panel(&panel).corner_radius, panel.corner_radius);
assert!(runtime.panel(&panel).border);
assert_eq!(runtime.adaptive.deadline(), pending);
}
#[test]
fn reset_invalidates_a_pending_deadline() {
let panel = Panel::from_preset(PanelStyle::Adaptive);
let mut runtime = Runtime::new(&panel, &ShellSnapshot::default(), None);
runtime.adaptive.observe(
WindowState {
maximized: true,
fullscreen: false,
},
Duration::ZERO,
);
let (ticket, _) = runtime.schedule().unwrap();
runtime.reset(&panel, &ShellSnapshot::default(), None);
assert_ne!(runtime.ticket, ticket);
assert_eq!(runtime.scheduled, None);
}
}
-2
View File
@@ -1,5 +1,3 @@
//! The bar only controls the recorder and consumes bounded status messages.
//! Encoding and portal negotiation run in the separate ferese-record process.
use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
+29
View File
@@ -0,0 +1,29 @@
use cosmic::font::Font;
#[derive(Clone, Copy)]
pub(crate) struct Rendering {
pub font: Font,
pub motion: crate::motion::Settings,
radius: f32,
}
impl Rendering {
pub fn new(config: &crate::config::ShellConfig) -> Self {
Self {
font: crate::configured_font(config.font_family.as_deref()),
motion: config.animations,
radius: config.theme.material_radius,
}
}
pub fn radius(self, limit: f32) -> f32 {
self.radius.min(limit)
}
pub fn text<'a>(
self,
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
ferese_theme::text(content, self.font)
}
}
+22 -6
View File
@@ -6,7 +6,7 @@ use super::bar::audio_icon;
use super::controls::{control_card, shell_switch, slider_row};
use super::{MenuRows, MenuStyle};
use crate::status::Action;
use crate::{FereseShell, Message, color_with_opacity, text};
use crate::{FereseShell, Message, color_with_opacity};
pub(super) fn view<'a>(
shell: &'a FereseShell,
@@ -21,14 +21,23 @@ pub(super) fn view<'a>(
opacity: p,
} = style;
if let Some(a) = &shell.status.audio {
let mut volume_heading = row![text("Volume").size(13).width(Length::Fill)].align_y(Alignment::Center);
let mut volume_heading =
row![shell.render.text("Volume").size(13).width(Length::Fill)].align_y(Alignment::Center);
if combined {
volume_heading = volume_heading.push(shell_switch("Mute", a.muted, Action::Mute(!a.muted), theme, p));
volume_heading = volume_heading.push(shell_switch(
shell.render,
"Mute",
a.muted,
Action::Mute(!a.muted),
theme,
p,
));
}
let audio = column![
volume_heading,
text(&a.output).size(12).class(theme::Text::Color(muted)),
shell.render.text(&a.output).size(12).class(theme::Text::Color(muted)),
slider_row(
shell.render,
audio_icon(a.volume, a.muted),
a.volume,
false,
@@ -39,7 +48,7 @@ pub(super) fn view<'a>(
]
.spacing(4);
let audio: Element<'_, cosmic::Action<Message>> = if combined {
control_card(audio.into(), primary, p)
control_card(shell.render, audio.into(), primary, p)
} else {
audio.into()
};
@@ -55,7 +64,14 @@ pub(super) fn heading<'a>(
) -> super::MenuHeading<'a> {
let MenuStyle { theme, opacity: p, .. } = style;
if let Some(audio) = &shell.status.audio {
heading = heading.push(shell_switch("Mute", audio.muted, Action::Mute(!audio.muted), theme, p));
heading = heading.push(shell_switch(
shell.render,
"Mute",
audio.muted,
Action::Mute(!audio.muted),
theme,
p,
));
}
heading
}
+12 -8
View File
@@ -5,7 +5,7 @@ use cosmic::{Element, theme};
use super::Menu;
use crate::status::Snapshot;
use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording, text};
use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording};
impl FereseShell {
pub(crate) fn view_media_item(
@@ -21,7 +21,7 @@ impl FereseShell {
super::media::bar(
self,
theme,
BarMetrics::from(self.config.panels[0].geometry),
BarMetrics::from(self.config.panels[0].resolved_geometry()),
representation,
selected,
width,
@@ -36,7 +36,7 @@ impl FereseShell {
selected: bool,
) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme.for_bar();
let metrics = BarMetrics::from(self.config.panels[0].geometry);
let metrics = BarMetrics::from(self.config.panels[0].resolved_geometry());
let (source, enabled) = if kind == Menu::Recording {
let source: &'static [u8] = match self.recorder.state {
recording::State::Selecting => ferese_theme::icons::RECORD_CANCEL,
@@ -70,7 +70,8 @@ impl FereseShell {
&& let Some(elapsed) = self.recorder.elapsed()
{
content = content.push(
text(elapsed)
self.render
.text(elapsed)
.size(metrics.text_size)
.class(theme::Text::Color(foreground)),
);
@@ -81,7 +82,8 @@ impl FereseShell {
&& let Some(battery) = &self.status.battery
{
content = content.push(
text(format!("{}%", battery.percent))
self.render
.text(format!("{}%", battery.percent))
.size(metrics.text_size)
.class(theme::Text::Color(foreground)),
);
@@ -94,14 +96,14 @@ impl FereseShell {
.is_some_and(|n| n.count > 0 && !n.dnd)
{
content = content.push(
container(text(""))
container(self.render.text(""))
.width(4)
.height(4)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(foreground)),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(2.0).into(),
radius: self.render.radius(2.0).into(),
..Default::default()
},
..Default::default()
@@ -138,7 +140,7 @@ impl FereseShell {
},
))
});
motion::button(control, foreground, selected, 1.0)
motion::button(self.render, control, foreground, selected, 1.0)
}
}
@@ -161,6 +163,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
Some(n) => format!("Notifications: {} unread", n.count),
_ => "Notifications: unavailable".into(),
},
Menu::Workspaces => "Workspaces".into(),
Menu::Calendar => "Calendar".into(),
Menu::Overflow => "More panel items".into(),
Menu::Recording => "Screen recording".into(),
@@ -239,6 +242,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
(ferese_theme::icons::NOTIFICATIONS, true)
}
}
Menu::Workspaces => (ferese_theme::icons::OVERVIEW, true),
Menu::System => (ferese_theme::icons::CONTROL_CENTER, true),
}
}
+45 -22
View File
@@ -6,7 +6,7 @@ use super::bar::status_icon;
use super::controls::{control_card, level_meter, slider_row};
use super::{Menu, MenuRows, MenuStyle};
use crate::status::Action;
use crate::{FereseShell, Message, ShellTheme, accented_icon, bar_icon, color_with_opacity, motion, shell_font, text};
use crate::{FereseShell, Message, ShellTheme, accented_icon, bar_icon, color_with_opacity, motion};
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
let MenuStyle {
@@ -27,7 +27,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
rows = rows.push(
container(
row![
text(format!("{}%", b.percent)).size(24).width(Length::Fill),
shell
.render
.text(format!("{}%", b.percent))
.size(24)
.width(Length::Fill),
accented_icon(
status_icon(Menu::Battery, &shell.status).0,
24,
@@ -44,19 +48,25 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
..Default::default()
})),
);
rows = rows.push(level_meter(b.percent, battery_color, p));
rows = rows.push(level_meter(shell.render, b.percent, battery_color, p));
rows = rows.push(
text(match b.status.as_str() {
"Charging" => "Connected to power",
"Discharging" => "Running on battery power",
"Full" => "Battery fully charged",
_ => "Battery status reported by the system",
})
.size(12)
.class(theme::Text::Color(muted)),
shell
.render
.text(match b.status.as_str() {
"Charging" => "Connected to power",
"Discharging" => "Running on battery power",
"Full" => "Battery fully charged",
_ => "Battery status reported by the system",
})
.size(12)
.class(theme::Text::Color(muted)),
);
}
rows = rows.push(ferese_theme::menus::section_label("Power mode", shell_font(), muted));
rows = rows.push(ferese_theme::menus::section_label(
"Power mode",
shell.render.font,
muted,
));
if let Some(profiles) = &shell.status.power_profiles {
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
for (index, (label, icon, profile)) in [
@@ -68,13 +78,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
.enumerate()
{
if profiles.available[index] {
modes = modes.push(power_tile(label, icon, profile, profiles.active == profile, theme, p));
modes = modes.push(power_tile(
shell.render,
label,
icon,
profile,
profiles.active == profile,
theme,
p,
));
}
}
rows = rows.push(modes);
} else {
rows = rows.push(
text("Power Profiles service unavailable")
shell
.render
.text("Power Profiles service unavailable")
.size(12)
.class(theme::Text::Color(muted)),
);
@@ -102,12 +122,12 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
),
] {
if enabled {
power = power.push(power_tile_action(label, icon, action, theme, p));
power = power.push(power_tile_action(shell.render, label, icon, action, theme, p));
}
}
if shell.status.reboot || shell.status.poweroff || shell.status.suspend {
rows = rows.push(ferese_theme::menus::section_label("Power", shell_font(), muted));
rows = rows.push(ferese_theme::menus::section_label("Power", shell.render.font, muted));
rows = rows.push(power);
}
rows
@@ -122,8 +142,9 @@ pub(super) fn brightness<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, sty
} = style;
if let Some(value) = shell.status.brightness {
let brightness = column![
text("Brightness").size(13),
shell.render.text("Brightness").size(13),
slider_row(
shell.render,
ferese_theme::icons::BRIGHTNESS,
value,
true,
@@ -133,12 +154,13 @@ pub(super) fn brightness<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, sty
)
]
.spacing(6);
rows = rows.push(control_card(brightness.into(), primary, p));
rows = rows.push(control_card(shell.render, brightness.into(), primary, p));
}
rows
}
fn power_tile<'a>(
render: crate::rendering::Rendering,
label: &'static str,
icon: &'static [u8],
profile: &'static str,
@@ -148,7 +170,7 @@ fn power_tile<'a>(
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(if selected { theme.accent } else { theme.text_primary }, opacity);
let tile = container(
column![bar_icon(icon, 19, foreground), text(label).size(11)]
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center),
)
@@ -159,10 +181,11 @@ fn power_tile<'a>(
.padding([8, 2])
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(profile))));
motion::button(btn, foreground, selected, opacity)
motion::button(render, btn, foreground, selected, opacity)
}
fn power_tile_action<'a>(
render: crate::rendering::Rendering,
label: &'static str,
icon: &'static [u8],
action: Action,
@@ -171,7 +194,7 @@ fn power_tile_action<'a>(
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(theme.text_primary, opacity);
let tile = container(
column![bar_icon(icon, 19, foreground), text(label).size(11)]
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center),
)
@@ -182,5 +205,5 @@ fn power_tile_action<'a>(
.padding([8, 2])
.on_press(cosmic::Action::App(Message::ConfirmPower(action)));
motion::button(btn, foreground, false, opacity)
motion::button(render, btn, foreground, false, opacity)
}
+15 -12
View File
@@ -6,7 +6,7 @@ use super::bar::status_icon;
use super::controls::status_summary;
use super::{Menu, MenuRows, MenuStyle};
use crate::status::Action;
use crate::{FereseShell, Message, text};
use crate::{FereseShell, Message};
const DEVICE_LIST_HEIGHT: f32 = 180.0;
@@ -14,10 +14,11 @@ fn device_list_height(count: usize) -> Option<f32> {
(count > 4).then_some(DEVICE_LIST_HEIGHT)
}
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle, in_hub: bool) -> MenuRows<'a> {
let MenuStyle { muted, .. } = style;
if let Some(b) = &shell.status.bluetooth {
rows = rows.push(status_summary(
shell.render,
status_icon(Menu::Bluetooth, &shell.status).0,
if b.enabled {
"Bluetooth is on"
@@ -41,21 +42,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
for device in &b.devices {
devices = devices.push(
column![
text(device).size(14),
text("Connected").size(12).class(theme::Text::Color(muted)),
shell.render.text(device).size(14),
shell.render.text("Connected").size(12).class(theme::Text::Color(muted)),
]
.spacing(3),
);
}
let list: Element<'_, cosmic::Action<Message>> = if let Some(height) = device_list_height(b.devices.len()) {
cosmic::iced::widget::scrollable(devices)
.height(height)
.width(Length::Fill)
.into()
} else {
devices.into()
};
// Morph scrolls the whole view, so its device list needs no nested viewport.
let list: Element<'_, cosmic::Action<Message>> =
if let Some(height) = device_list_height(b.devices.len()).filter(|_| !in_hub) {
cosmic::iced::widget::scrollable(devices)
.height(height)
.width(Length::Fill)
.into()
} else {
devices.into()
};
rows = rows.push(list);
}
}
+45 -6
View File
@@ -2,20 +2,59 @@ use cosmic::Element;
use jiff::Zoned;
use super::controls::menu_button;
use crate::{Message, ShellTheme, color_with_opacity, shell_font};
use crate::{Message, ShellTheme, color_with_opacity};
pub(super) fn view<'a>(offset: i32, theme: ShellTheme, opacity: f32) -> Element<'a, cosmic::Action<Message>> {
pub(super) fn view<'a>(
render: crate::rendering::Rendering,
offset: i32,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let primary = color_with_opacity(theme.text_primary, opacity);
ferese_theme::calendar::grid(
Zoned::now().date(),
offset,
theme.palette(),
shell_font(),
render.font,
opacity,
[
menu_button("‹", Message::CalendarMonth(-1), primary, opacity),
menu_button("Today", Message::CalendarToday, primary, opacity),
menu_button("›", Message::CalendarMonth(1), primary, opacity),
menu_button(render, "‹", Message::CalendarMonth(-1), primary, opacity),
menu_button(render, "Today", Message::CalendarToday, primary, opacity),
menu_button(render, "›", Message::CalendarMonth(1), primary, opacity),
],
)
}
pub(super) fn hub<'a>(
render: crate::rendering::Rendering,
offset: i32,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let primary = color_with_opacity(theme.text_primary, opacity);
let navigation = |label: &'static str, message: Message| {
crate::motion::button(
render,
cosmic::widget::button::custom(render.text(label).size(11).class(cosmic::theme::Text::Color(primary)))
.name(label)
.padding([2, 4])
.height(22)
.on_press(cosmic::Action::App(message)),
primary,
false,
opacity,
)
};
ferese_theme::calendar::compact_grid(
Zoned::now().date(),
offset,
theme.palette(),
render.font,
opacity,
[
navigation("‹", Message::CalendarMonth(-1)),
navigation("Today", Message::CalendarToday),
navigation("›", Message::CalendarMonth(1)),
],
)
}
+29 -11
View File
@@ -4,9 +4,10 @@ use cosmic::widget::{button, column, container, row, slider};
use cosmic::{Element, theme};
use crate::status::Action;
use crate::{Message, ShellTheme, accented_icon, color, color_with_opacity, motion, shell_font, text};
use crate::{Message, ShellTheme, accented_icon, color, color_with_opacity, motion};
pub(super) fn status_summary<'a>(
render: crate::rendering::Rendering,
source: &'static [u8],
title: &'a str,
subtitle: &'a str,
@@ -34,13 +35,14 @@ pub(super) fn status_summary<'a>(
})),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(16.0).into(),
radius: render.radius(16.0).into(),
..Default::default()
},
..Default::default()
}));
let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
motion::button(
render,
button::custom(badge)
.padding(0)
.name(if enabled { "Turn off" } else { "Turn on" })
@@ -55,8 +57,8 @@ pub(super) fn status_summary<'a>(
let summary = row![
badge,
column![
text(title).size(14),
text(subtitle).size(12).class(theme::Text::Color(muted)),
render.text(title).size(14),
render.text(subtitle).size(12).class(theme::Text::Color(muted)),
]
.spacing(4)
.width(Length::Fill)
@@ -66,9 +68,14 @@ pub(super) fn status_summary<'a>(
summary.into()
}
pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element<'static, cosmic::Action<Message>> {
pub(super) fn level_meter(
render: crate::rendering::Rendering,
value: u8,
foreground: Color,
opacity: f32,
) -> Element<'static, cosmic::Action<Message>> {
let segment = move |amount: u16, filled: bool| {
container(text(""))
container(render.text(""))
.width(Length::FillPortion(amount))
.height(5)
.class(theme::Container::custom(move |_| container::Style {
@@ -82,7 +89,7 @@ pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element
})),
border: Border {
shape: BorderShape::Continuous,
radius: motion::radius(2.5).into(),
radius: render.radius(2.5).into(),
..Default::default()
},
..Default::default()
@@ -100,21 +107,24 @@ pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element
}
pub(super) fn control_card<'a>(
render: crate::rendering::Rendering,
content: Element<'a, cosmic::Action<Message>>,
foreground: Color,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
ferese_theme::menus::section(content, foreground, opacity, motion::radius(22.))
ferese_theme::menus::section(content, foreground, opacity, render.radius(22.))
}
pub(super) fn menu_button<'a>(
render: crate::rendering::Rendering,
label: &'a str,
message: Message,
foreground: Color,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
motion::button(
ferese_theme::menus::button(label, cosmic::Action::App(message), shell_font()),
render,
ferese_theme::menus::button(label, cosmic::Action::App(message), render.font),
foreground,
false,
opacity,
@@ -122,6 +132,7 @@ pub(super) fn menu_button<'a>(
}
pub(super) fn shell_switch<'a>(
render: crate::rendering::Rendering,
label: &str,
enabled: bool,
action: Action,
@@ -130,6 +141,7 @@ pub(super) fn shell_switch<'a>(
) -> Element<'a, cosmic::Action<Message>> {
let toggle = ferese_theme::menus::switch(enabled, palette.palette(), opacity);
motion::button(
render,
button::custom(toggle)
.padding(4)
.name(format!("{label}: {}", if enabled { "on" } else { "off" }))
@@ -141,16 +153,22 @@ pub(super) fn shell_switch<'a>(
}
pub(super) fn toggle_row<'a>(
render: crate::rendering::Rendering,
label: &'a str,
on: bool,
action: Action,
palette: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
ferese_theme::menus::row(label, shell_switch(label, on, action, palette, opacity), shell_font())
ferese_theme::menus::row(
label,
shell_switch(render, label, on, action, palette, opacity),
render.font,
)
}
pub(super) fn slider_row(
render: crate::rendering::Rendering,
source: &'static [u8],
value: u8,
brightness: bool,
@@ -171,7 +189,7 @@ pub(super) fn slider_row(
row![
accented_icon(source, 18, foreground, icon_accent),
control,
text(format!("{value}%")).size(12).width(36)
render.text(format!("{value}%")).size(12).width(36)
]
.spacing(8)
.align_y(Alignment::Center)
@@ -10,11 +10,11 @@ use super::{Menu, OpenMenu, PopoverAnchor};
use crate::status::Action;
use crate::{FereseShell, Message};
fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_config::panel::Edge) -> SctkPositioner {
fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_panel::Edge) -> SctkPositioner {
// xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars.
let (anchor, gravity, gap) = match edge {
ferese_config::panel::Edge::Top => (2u32, 6u32, 8),
ferese_config::panel::Edge::Bottom => (1u32, 5u32, -8),
ferese_panel::Edge::Top => (2u32, 6u32, 8),
ferese_panel::Edge::Bottom => (1u32, 5u32, -8),
};
SctkPositioner {
anchor_rect: rectangle,
@@ -46,6 +46,9 @@ impl FereseShell {
{
return Task::none();
}
if self.panel_for_bar(anchor.parent).style == ferese_panel::PanelStyle::Morph {
return self.open_hub(anchor.parent, kind);
}
if self
.menu
.as_ref()
@@ -274,7 +277,7 @@ mod tests {
#[test]
fn menus_open_toward_the_desktop_from_either_panel_edge() {
use ferese_config::panel::Edge;
use ferese_panel::Edge;
let rectangle = Rectangle {
x: 400,
y: 0,
+185 -23
View File
@@ -8,7 +8,7 @@ use serde_json::json;
use super::{Menu, MenuRows, MenuStyle};
use crate::{
BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion, text,
BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion,
};
const SEEK_THUMB_DIAMETER: f32 = 10.0;
@@ -78,7 +78,7 @@ pub(super) fn bar<'a>(
width: Option<f32>,
) -> Element<'a, cosmic::Action<Message>> {
let Some(player) = &shell.media.snapshot.selected else {
return text("").into();
return shell.render.text("").into();
};
let foreground = color(if selected { palette.accent } else { palette.text_primary });
let height = (metrics.group_item_height - 4.0).max(0.0);
@@ -89,7 +89,7 @@ pub(super) fn bar<'a>(
.width(side)
.height(side)
.content_fit(cosmic::iced::ContentFit::Cover)
.border_radius(motion::radius(6.0))
.border_radius(shell.render.radius(6.0))
.shape(BorderShape::Continuous)
.into(),
None => container(accented_icon(
@@ -103,7 +103,9 @@ pub(super) fn bar<'a>(
.width(side)
.into(),
};
let label = text(player.label())
let label = shell
.render
.text(player.label())
.size(metrics.text_size)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
@@ -170,8 +172,8 @@ pub(super) fn bar<'a>(
let controls = container(
row![
motion::button(details, foreground, false, 1.0),
motion::button(toggle, foreground, false, 1.0)
motion::button(shell.render, details, foreground, false, 1.0),
motion::button(shell.render, toggle, foreground, false, 1.0)
]
.spacing(2)
.align_y(Alignment::Center),
@@ -211,6 +213,18 @@ fn transport<'a>(
action: &str,
enabled: bool,
style: MenuStyle,
) -> Element<'a, cosmic::Action<Message>> {
transport_sized(shell, source, label, action, enabled, style, 44.)
}
fn transport_sized<'a>(
shell: &'a FereseShell,
source: &'static [u8],
label: &'static str,
action: &str,
enabled: bool,
style: MenuStyle,
size: f32,
) -> Element<'a, cosmic::Action<Message>> {
let fill = (action == "play-pause").then_some(if enabled { style.primary } else { style.muted });
let foreground = match fill {
@@ -227,19 +241,142 @@ fn transport<'a>(
}
};
let mut control = button::custom(
container(accented_icon(source, 24, foreground, color(style.theme.accent)))
.center_x(24)
.center_y(24),
container(
accented_icon(
source,
(size - 20.) as u16,
cosmic::iced::Color { a: 1., ..foreground },
color(style.theme.accent),
)
.opacity(foreground.a),
)
.center_x(size - 20.)
.center_y(size - 20.),
)
.padding(10)
.width(44)
.height(44)
.width(size)
.height(size)
.name(label);
if enabled {
control = control.on_press(cosmic::Action::App(Message::MediaAction(shell.media.action(action))));
}
motion::circular_button(control, foreground, fill, style.opacity, 44.0)
motion::circular_button(shell.render, control, foreground, fill, style.opacity, size)
}
pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
let Some(player) = &shell.media.snapshot.selected else {
return rows.push(
shell
.render
.text("No active player")
.size(14)
.class(theme::Text::Color(style.muted)),
);
};
let cover: Element<'_, cosmic::Action<Message>> =
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
Some(handle) => image(handle.clone())
.width(64)
.height(64)
.opacity(style.opacity)
.content_fit(cosmic::iced::ContentFit::Cover)
.border_radius(10.)
.into(),
None => container(
accented_icon(
icons::MEDIA,
26,
color(style.theme.text_primary),
color(style.theme.accent),
)
.opacity(style.opacity),
)
.center_x(64)
.center_y(64)
.class(theme::Container::custom(move |_| {
ferese_theme::controls::surface_appearance(style.primary.scale_alpha(0.06), 10.)
}))
.into(),
};
let mut info = column![
shell
.render
.text(player.label())
.size(14)
.class(theme::Text::Color(style.primary))
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1)
)),
shell
.render
.text(&player.artist)
.size(12)
.class(theme::Text::Color(style.muted))
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1)
))
]
.spacing(4)
.width(Length::Fill);
if let (Some(length), Some(position)) = (
player.length_us.filter(|length| *length > 0),
player.position_at(shell.media.now_us),
) {
let value =
(shell.media.seeking.unwrap_or(position).min(length) as f64 / length as f64 * 10_000.).round() as u32;
if player.can_control && player.can_seek && player.track_id.is_some() {
info = info.push(
slider(0..=10_000_u32, value, move |value| {
cosmic::Action::App(Message::MediaSeek(seek_position(length, value)))
})
.on_release(cosmic::Action::App(Message::MediaSeekCommit))
.height(16)
.handle_width(SEEK_THUMB_DIAMETER)
.class(seek_slider_style(style))
.width(Length::Fill),
);
}
}
let rows = rows.push(row![cover, info].spacing(12).align_y(Alignment::Center));
let playing = player.status == Playback::Playing;
rows.push(
container(
row![
transport_sized(
shell,
icons::MEDIA_PREVIOUS,
"Previous track",
"previous",
player.can_control && player.can_previous,
style,
36.
),
transport_sized(
shell,
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
if playing { "Pause" } else { "Play" },
"play-pause",
player.can_toggle(),
style,
36.
),
transport_sized(
shell,
icons::MEDIA_NEXT,
"Next track",
"next",
player.can_control && player.can_next,
style,
36.
),
]
.spacing(8),
)
.center_x(Length::Fill),
)
}
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
@@ -250,7 +387,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
.width(64)
.height(64)
.content_fit(cosmic::iced::ContentFit::Contain)
.border_radius(motion::radius(12.0))
.border_radius(shell.render.radius(12.0))
.shape(BorderShape::Continuous)
.into(),
None => container(accented_icon(icons::MEDIA, 30, style.muted, color(style.theme.accent)))
@@ -259,14 +396,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
.into(),
};
let text_size = if player.label().len() >= 24 { 13 } else { 15 };
let mut info = column![text(player.label()).size(text_size).width(Length::Fill).ellipsize(
cosmic::iced::widget::text::Ellipsize::End(cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2))
)]
let mut info = column![
shell
.render
.text(player.label())
.size(text_size)
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2)
))
]
.spacing(4)
.width(Length::Fill);
if !player.artist.is_empty() {
info = info.push(
text(&player.artist)
shell
.render
.text(&player.artist)
.size(12)
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
@@ -277,7 +423,9 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
}
if !player.album.is_empty() {
info = info.push(
text(&player.album)
shell
.render
.text(&player.album)
.size(11)
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
@@ -342,12 +490,16 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
progress = progress.push(
row![
text(time_label(position))
shell
.render
.text(time_label(position))
.font(cosmic::iced::Font::MONOSPACE)
.size(11)
.class(theme::Text::Color(style.muted))
.width(Length::Fill),
text(time_label(length))
shell
.render
.text(time_label(length))
.font(cosmic::iced::Font::MONOSPACE)
.size(11)
.class(theme::Text::Color(style.muted))
@@ -398,7 +550,8 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
player.identity.clone()
};
rows = rows.push(motion::button(
button::custom(text(label).size(12))
shell.render,
button::custom(shell.render.text(label).size(12))
.padding([6, 8])
.on_press(cosmic::Action::App(Message::MediaChoose)),
style.primary,
@@ -406,12 +559,19 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
style.opacity,
));
} else {
rows = rows.push(text("No active player").size(13).class(theme::Text::Color(style.muted)));
rows = rows.push(
shell
.render
.text("No active player")
.size(13)
.class(theme::Text::Color(style.muted)),
);
}
if shell.media.choosing || shell.media.snapshot.selected.is_none() {
let mut choices = column::with_capacity(shell.media.snapshot.players.len() + 1).spacing(4);
choices = choices.push(super::controls::menu_button(
shell.render,
"Choose automatically",
Message::MediaAction(json!({"action":"auto"})),
style.primary,
@@ -432,12 +592,14 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
choices = choices.push(
row![
super::controls::menu_button(
shell.render,
label,
Message::MediaAction(pin),
if player.ignored { style.muted } else { style.primary },
style.opacity
),
super::controls::menu_button(
shell.render,
if player.ignored { "Show" } else { "Ignore" },
Message::MediaAction(ignore),
style.muted,
@@ -452,7 +614,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
}
if let Some(error) = &shell.media.error {
rows = rows.push(text(error).size(12).class(theme::Text::Color(style.muted)));
rows = rows.push(shell.render.text(error).size(12).class(theme::Text::Color(style.muted)));
}
rows
+175 -22
View File
@@ -32,6 +32,7 @@ struct MenuStyle {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu {
Workspaces,
Network,
Bluetooth,
Audio,
@@ -74,6 +75,7 @@ impl Menu {
fn title(self) -> &'static str {
match self {
Self::Workspaces => "Workspaces",
Self::System => "Control Center",
Self::Overflow => "Panel items",
Self::Network => "Wi-Fi",
@@ -95,7 +97,7 @@ impl Menu {
Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording | Self::Media => true,
Self::Notifications => status.notifications.is_some(),
Self::System | Self::Overflow => true,
Self::System | Self::Overflow | Self::Workspaces => true,
}
}
}
@@ -161,8 +163,151 @@ impl OpenMenu {
}
impl FereseShell {
pub(crate) fn view_hub_menu(&self, kind: Menu, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme;
let style = MenuStyle {
theme,
primary: color_with_opacity(theme.text_primary, opacity),
muted: color_with_opacity(theme.text_muted, opacity),
opacity,
};
let mut rows = column::with_capacity(8).spacing(8).width(Length::Fill);
if kind == Menu::Network
&& let Some(network) = &self.status.network
{
rows = rows.push(toggle_row(
self.render,
"Wi-Fi",
network.enabled,
Action::Wifi(!network.enabled),
theme,
opacity,
));
}
if kind == Menu::Audio
&& let Some(audio) = &self.status.audio
{
rows = rows.push(toggle_row(
self.render,
"Mute",
audio.muted,
Action::Mute(!audio.muted),
theme,
opacity,
));
}
let mut rows = match kind {
Menu::System => system::view(self, rows, style, true),
Menu::Network => network::view(self, rows, style),
Menu::Bluetooth => bluetooth::view(self, rows, style, true),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => rows.push(calendar::hub(self.render, self.calendar_offset, theme, opacity)),
Menu::Media => media::hub(self, rows, style),
Menu::Notifications => {
let mut rows = notification_controls(self, rows, style, true);
if self.notifications.entries.is_empty() {
rows = rows.push(
self.render
.text("No notifications")
.size(13)
.class(theme::Text::Color(style.muted)),
);
} else {
rows = rows.push(menu_button(
self.render,
"Clear notifications",
Message::ClearNotifications,
style.muted,
opacity,
));
for notice in &self.notifications.entries {
rows = rows.push(control_card(
self.render,
column![
row![
self.render
.text(notice.app.clone())
.size(11)
.class(theme::Text::Color(style.muted))
.width(Length::Fill),
menu_button(
self.render,
"×",
Message::RemoveNotification(notice.id),
style.muted,
opacity
)
],
self.render
.text(notice.title.clone())
.size(13)
.class(theme::Text::Color(style.primary)),
self.render
.text(notice.body.clone())
.size(12)
.class(theme::Text::Color(style.muted)),
]
.spacing(4)
.into(),
style.primary,
opacity,
));
for (action, label) in &notice.actions {
rows = rows.push(menu_button(
self.render,
label,
Message::InvokeNotification(notice.id, action.clone()),
style.primary,
opacity,
));
}
}
}
rows
}
Menu::Recording => rows.push(menu_button(
self.render,
if self.recorder.busy() {
"Stop recording"
} else {
"Record screen"
},
if self.recorder.busy() {
Message::StopRecording
} else {
Message::StartRecording
},
style.primary,
opacity,
)),
Menu::Overflow | Menu::Workspaces => rows,
};
if !kind.available(&self.status) {
rows = rows.push(
self.render
.text("Service unavailable")
.size(13)
.class(theme::Text::Color(style.muted)),
);
}
if let Some(error) = self.status_error.as_ref().and_then(|error| kind.action_error(error)) {
rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(style.muted)));
}
container(rows)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
text_color: Some(style.primary),
icon_color: Some(style.primary),
..Default::default()
}))
.into()
}
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else { return text("").into() };
let Some(menu) = &self.menu else {
return self.render.text("").into();
};
motion::frame_driven(
menu.motion.revision(),
|now| self.view_status_menu_at(now),
@@ -186,7 +331,7 @@ impl FereseShell {
fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else {
return text("").into();
return self.render.text("").into();
};
if menu.kind == Menu::Notifications && self.notifications.ready {
return cosmic::widget::autosize::autosize(
@@ -217,9 +362,9 @@ impl FereseShell {
.into(),
color(theme.accent),
p,
motion::radius(20.),
self.render.radius(20.),
);
let mut heading = ferese_theme::menus::heading(badge, kind.title(), shell_font());
let mut heading = ferese_theme::menus::heading(badge, kind.title(), self.render.font);
let style = MenuStyle {
theme,
primary,
@@ -237,19 +382,24 @@ impl FereseShell {
rows = rows.push(heading);
}
rows = match kind {
Menu::System => system::view(self, rows, style),
Menu::System => system::view(self, rows, style, false),
Menu::Network => network::view(self, rows, style),
Menu::Bluetooth => bluetooth::view(self, rows, style),
Menu::Bluetooth => bluetooth::view(self, rows, style, false),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
Menu::Calendar => rows.push(calendar::view(self.render, self.calendar_offset, theme, p)),
Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Recording => rows,
Menu::Recording | Menu::Workspaces => rows,
Menu::Overflow => self.view_panel_overflow(rows),
Menu::Media => media::view(self, rows, style),
};
if kind != Menu::Calendar && !kind.available(&self.status) {
rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted)));
rows = rows.push(
self.render
.text("Service unavailable")
.size(13)
.class(theme::Text::Color(muted)),
);
}
if let Some(error) = self
@@ -257,7 +407,7 @@ impl FereseShell {
.as_ref()
.and_then(|error| menu.kind.action_error(error))
{
rows = rows.push(text(error).size(12).class(theme::Text::Color(Color {
rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(Color {
a: p,
..Color::from_rgb8(230, 172, 90)
})));
@@ -322,9 +472,9 @@ fn notification_controls<'a>(
opacity: p,
} = style;
if let Some(n) = &shell.status.notifications {
let toggle = toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p);
let toggle = toggle_row(shell.render, "Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p);
rows = rows.push(if combined {
control_card(toggle, primary, p)
control_card(shell.render, toggle, primary, p)
} else {
toggle
});
@@ -332,17 +482,20 @@ fn notification_controls<'a>(
if !combined {
rows = rows
.push(
text(if n.count == 0 {
"No notifications".to_owned()
} else if n.count == 1 {
"1 notification".to_owned()
} else {
format!("{} notifications", n.count)
})
.size(13)
.class(theme::Text::Color(muted)),
shell
.render
.text(if n.count == 0 {
"No notifications".to_owned()
} else if n.count == 1 {
"1 notification".to_owned()
} else {
format!("{} notifications", n.count)
})
.size(13)
.class(theme::Text::Color(muted)),
)
.push(menu_button(
shell.render,
"Open notification center",
Message::Control(Action::Notifications),
primary,
+6 -4
View File
@@ -4,8 +4,8 @@ use cosmic::widget::{column, row};
use super::bar::status_icon;
use super::controls::{level_meter, shell_switch, status_summary};
use super::{Menu, MenuRows, MenuStyle};
use crate::FereseShell;
use crate::status::Action;
use crate::{FereseShell, text};
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
let MenuStyle {
@@ -14,6 +14,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
if let Some(n) = &shell.status.network {
let connected = n.enabled && n.connection.is_some();
rows = rows.push(status_summary(
shell.render,
status_icon(Menu::Network, &shell.status).0,
if !n.enabled {
"Wi-Fi is off"
@@ -41,10 +42,10 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
rows = rows.push(
column![
row![
text("Signal strength").size(12).width(Length::Fill),
text(strength).size(12)
shell.render.text("Signal strength").size(12).width(Length::Fill),
shell.render.text(strength).size(12)
],
level_meter(n.signal, primary, p),
level_meter(shell.render, n.signal, primary, p),
]
.spacing(8),
);
@@ -61,6 +62,7 @@ pub(super) fn heading<'a>(
let MenuStyle { theme, opacity: p, .. } = style;
if let Some(network) = &shell.status.network {
heading = heading.push(shell_switch(
shell.render,
"Wi-Fi",
network.enabled,
Action::Wifi(!network.enabled),
+67 -24
View File
@@ -6,9 +6,14 @@ use super::bar::status_icon;
use super::controls::control_card;
use super::{Menu, MenuRows, MenuStyle, audio, battery, notification_controls};
use crate::status::{self, Action};
use crate::{FereseShell, Message, ShellTheme, accented_icon, color, color_with_opacity, motion, shell_font, text};
use crate::{FereseShell, Message, accented_icon, color, color_with_opacity, motion};
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn view<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
show_settings_link: bool,
) -> MenuRows<'a> {
let MenuStyle {
theme,
primary,
@@ -19,7 +24,8 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
let mut modes = row([]).spacing(6);
for (mode, label) in [(Mode::Light, "☀ Light"), (Mode::Dark, "☾ Dark"), (Mode::Auto, "◐ Auto")] {
modes = modes.push(motion::button(
button::custom(text(label).font(shell_font()).size(13))
shell.render,
button::custom(shell.render.text(label).font(shell.render.font).size(13))
.width(Length::Fill)
.padding(9)
.on_press(cosmic::Action::App(Message::ThemeMode(mode))),
@@ -28,23 +34,54 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
p,
));
}
rows = rows.push(modes);
if show_settings_link
&& shell
.config
.status
.settings_command
.as_ref()
.is_some_and(|command| !command.is_empty())
{
let settings = motion::button(
shell.render,
button::custom(
ferese_theme::icons::tinted(ferese_theme::icons::SETTINGS, 18, color(theme.text_primary)).opacity(p),
)
.name("Open Ferese Settings")
.width(30)
.height(28)
.padding([5, 3])
.on_press(cosmic::Action::App(Message::Control(Action::Settings))),
primary,
false,
p,
);
modes = modes.push(cosmic::widget::tooltip(
settings,
shell.render.text("Open Ferese Settings").size(12),
cosmic::widget::tooltip::Position::Left,
));
}
rows = rows.push(modes.align_y(Alignment::Center));
if shell.config.theme_mode == Mode::Auto {
rows = rows.push(
text(if theme.appearance == Appearance::Light {
"Auto · currently light"
} else {
"Auto · currently dark"
})
.font(shell_font())
.size(12)
.class(cosmic::theme::Text::Color(muted)),
shell
.render
.text(if theme.appearance == Appearance::Light {
"Auto · currently light"
} else {
"Auto · currently dark"
})
.font(shell.render.font)
.size(12)
.class(cosmic::theme::Text::Color(muted)),
);
}
let mut connections = row::with_capacity(3).spacing(8).width(Length::Fill);
if let Some(n) = &shell.status.network {
connections = connections.push(connection_control(
shell.render,
"Wi-Fi",
if n.enabled {
n.connection.as_deref().unwrap_or("Not connected")
@@ -54,13 +91,13 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
status_icon(Menu::Network, &shell.status).0,
n.enabled,
Some(Action::Wifi(!n.enabled)),
theme,
p,
style,
));
}
if let Some(b) = &shell.status.bluetooth {
connections = connections.push(connection_control(
shell.render,
"Bluetooth",
if b.enabled {
b.devices.first().map(String::as_str).unwrap_or("On")
@@ -70,25 +107,24 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
status_icon(Menu::Bluetooth, &shell.status).0,
b.enabled,
Some(Action::Bluetooth(!b.enabled)),
theme,
p,
style,
));
}
if let Some(b) = &shell.status.battery {
connections = connections.push(connection_control(
shell.render,
"Battery",
&format!("{}%", b.percent),
status_icon(Menu::Battery, &shell.status).0,
b.status == "Charging",
None,
theme,
p,
style,
));
}
if shell.status.network.is_some() || shell.status.bluetooth.is_some() || shell.status.battery.is_some() {
rows = rows.push(control_card(connections.into(), primary, p));
rows = rows.push(control_card(shell.render, connections.into(), primary, p));
}
rows = audio::view(shell, rows, style, true);
rows = battery::brightness(shell, rows, style);
@@ -109,14 +145,19 @@ fn connection_caption(detail: &str) -> String {
}
fn connection_control<'a>(
render: crate::rendering::Rendering,
label: &'static str,
detail: &str,
source: &'static [u8],
enabled: bool,
action: Option<Action>,
palette: ShellTheme,
opacity: f32,
style: MenuStyle,
) -> Element<'a, cosmic::Action<Message>> {
let MenuStyle {
theme: palette,
opacity,
..
} = style;
let selected = enabled && action.is_some();
let (fill, on_accent) = ferese_theme::accent_pair(
ferese_theme::composite(color(palette.accent), color(palette.surface_base)),
@@ -143,7 +184,8 @@ fn connection_control<'a>(
.align_y(alignment::Vertical::Center);
let content = column![icon].spacing(2).width(Length::Fill).align_x(Alignment::Center);
let content = content.push(
text(connection_caption(detail))
render
.text(connection_caption(detail))
.size(11)
.width(Length::Fill)
.height(16)
@@ -167,12 +209,12 @@ fn connection_control<'a>(
.class(ferese_theme::controls::filled_button(
fill,
foreground,
motion::radius(14.),
render.radius(14.),
opacity,
))
.into()
} else {
motion::button(button, foreground, false, opacity)
motion::button(render, button, foreground, false, opacity)
};
container(tile).width(Length::FillPortion(1)).into()
}
@@ -197,6 +239,7 @@ pub(super) fn heading<'a>(
.is_some_and(|program| status::available(program))
{
heading = heading.push(motion::button(
shell.render,
button::custom(accented_icon(
ferese_theme::icons::SETTINGS,
20,
+139 -16
View File
@@ -3,14 +3,14 @@ use super::{
EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin,
IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific,
Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop,
destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, text,
theme, wayland, window, wl_output, wl_registry, wl_surface,
destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, theme,
wayland, window, wl_output, wl_registry, wl_surface,
};
pub(super) fn panel_placement(edge: ferese_config::panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
let (anchor, top, bottom) = match edge {
ferese_config::panel::Edge::Top => (Anchor::TOP, margin, 0),
ferese_config::panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin),
ferese_panel::Edge::Top => (Anchor::TOP, margin, 0),
ferese_panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin),
};
(
anchor,
@@ -81,15 +81,36 @@ impl FereseShell {
} else {
Task::none()
};
return Task::batch([margin, popup, tooltip]);
return Task::batch([
margin,
popup,
tooltip,
window::scale_factor(bar).map(move |scale| cosmic::Action::App(Message::PanelScale(bar, scale))),
]);
}
let bar_surface_id = window::Id::unique();
let wallpaper_surface_id = window::Id::unique();
let geometry = self.config.panels[0].geometry;
let panel_surface = self.config.panels[0].surface;
let runtime = super::panel_runtime::Runtime::new(&self.config.panels[0], &self.snapshot, name.as_deref());
let panel = runtime.panel(&self.config.panels[0]);
let geometry = panel.resolved_geometry();
let panel_surface = panel.surface();
let exclusive_zone = panel.exclusive_zone(output_bar_hidden(
&self.snapshot,
self.snapshot
.outputs
.iter()
.find(|output| Some(output.name.as_str()) == name.as_deref())
.map(|output| output.id),
));
let (edge_anchor, margin) = panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
let bar = BarMetrics::from(geometry);
let hub_size = runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
let bar_anchor = if hub_size.is_some() {
edge_anchor
} else {
edge_anchor | Anchor::LEFT | Anchor::RIGHT
};
let wallpaper_output = output.clone();
let bar_output = output.clone();
let hidden = output_bar_hidden(
@@ -101,6 +122,8 @@ impl FereseShell {
.map(|output| output.id),
);
self.outputs.push(OutputSurfaces {
runtime,
scale: 1.,
output,
name,
bar: bar_surface_id,
@@ -141,15 +164,17 @@ impl FereseShell {
input_zone: super::bar::input_region(panel_surface, hidden, &[]),
layer: Layer::Top,
keyboard_interactivity: KeyboardInteractivity::None,
anchor: edge_anchor | Anchor::LEFT | Anchor::RIGHT,
anchor: bar_anchor,
output: IcedOutput::Output(bar_output.clone()),
namespace: "ferese-shell-top-bar".to_owned(),
// Measure at full width before applying the requested side inset.
margin,
size: Some((None, Some(bar.height.round() as u32))),
size: Some(hub_size.map_or((None, Some(bar.height.round() as u32)), |size| {
(Some(size[0]), Some(size[1]))
})),
size_limits: Limits::NONE,
// Reserve space toward the desktop; layer-shell accounts for the edge margin.
exclusive_zone: (bar.height.round() as i32).saturating_add(geometry.window_clearance),
exclusive_zone,
},
Some(Box::new(move |app| app.view_layer(bar_surface_id))),
);
@@ -186,8 +211,11 @@ impl FereseShell {
}
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) {
return self.hub_hover(id, true);
}
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorLeft)) {
return self.dismiss_workspace_tooltip_for_bar(id);
return Task::batch([self.dismiss_workspace_tooltip_for_bar(id), self.hub_hover(id, false)]);
}
if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event
&& let Some(modal) = &mut self.system_modal
@@ -230,6 +258,53 @@ impl FereseShell {
{
return self.display_mode_key(key);
}
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(key),
modifiers,
..
}) = &event
&& !modifiers.control()
&& !modifiers.alt()
&& self.hub_mode(id)
== Some(ferese_panel::morph::Mode::Expanded(
ferese_panel::morph::Content::Workspaces,
))
&& matches!(
key,
cosmic::iced::keyboard::key::Named::ArrowLeft
| cosmic::iced::keyboard::key::Named::ArrowRight
| cosmic::iced::keyboard::key::Named::ArrowUp
| cosmic::iced::keyboard::key::Named::ArrowDown
)
{
return self.hub_workspace_key(id, *key);
}
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab),
modifiers,
..
}) = &event
&& !modifiers.control()
&& self.hub_mode(id).is_some_and(|mode| mode.expanded())
{
use cosmic::iced::advanced::widget::{Id, operation};
let target = Id::new("ferese-panel-hub");
let focus: Box<dyn cosmic::iced::advanced::widget::Operation<cosmic::Action<Message>>> =
if modifiers.shift() {
Box::new(operation::scoped(target, operation::focusable::focus_previous()))
} else {
Box::new(operation::scoped(target, operation::focusable::focus_next()))
};
let task = cosmic::iced::advanced::widget::operate(focus);
return if self.hub_mode(id)
== Some(ferese_panel::morph::Mode::Expanded(
ferese_panel::morph::Content::Workspaces,
)) {
task.chain(self.query_hub_workspace_focus(id, None))
} else {
task
};
}
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Tab),
modifiers,
@@ -247,6 +322,9 @@ impl FereseShell {
..
})
) {
if self.hub_mode(id).is_some() {
return self.close_hub(id);
}
if self.system_modal.is_some() {
return self.close_system_modal();
}
@@ -255,10 +333,22 @@ impl FereseShell {
}
}
if let Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
wayland::LayerEvent::Unfocused,
_,
layer,
))) = &event
&& self.hub_mode(*layer).is_some_and(|mode| mode.expanded())
{
return self.close_hub(*layer);
}
match event {
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => {
self.output_event(event, output)
}
Event::Window(window::Event::Rescaled(scale)) => {
cosmic::task::message(cosmic::Action::App(Message::PanelScale(id, scale)))
}
Event::Window(window::Event::Opened { .. }) if !self.owns_surface(id) => {
// Creation is asynchronous. A close can arrive before the
// surface exists, so dispose of any late opening once its
@@ -278,6 +368,7 @@ impl FereseShell {
.is_some_and(|surface| surface.id == id) =>
{
Task::batch([
window::scale_factor(id).map(move |scale| cosmic::Action::App(Message::PanelScale(id, scale))),
self.focus_system_modal(id),
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))),
@@ -367,7 +458,8 @@ impl FereseShell {
{
self.attach_effects(layer_id, &surface);
self.attach_power_material(layer_id, &surface);
Task::none()
window::scale_factor(layer_id)
.map(move |scale| cosmic::Action::App(Message::PanelScale(layer_id, scale)))
}
_ => Task::none(),
}
@@ -375,7 +467,7 @@ impl FereseShell {
pub(super) fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
let hidden = self.bar_hidden(id);
let islands = self.config.panels[0].surface == ferese_config::panel::PanelSurface::None;
let islands = self.panel_for_bar(id).surface() == ferese_panel::PanelSurface::None;
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else {
return;
};
@@ -401,7 +493,7 @@ impl FereseShell {
pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(handle) = self.wallpaper.as_ref() else {
return container(text(""))
return container(self.render.text(""))
.width(Length::Fill)
.height(Length::Fill)
.class(theme::Container::custom(wallpaper_fallback_style))
@@ -488,6 +580,7 @@ pub(super) struct EffectsBinding {
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>,
presentation: std::cell::RefCell<Option<EffectPresentation>>,
panel_presentation: std::cell::RefCell<Option<ferese_panel::scene::Scene>>,
}
impl EffectsBinding {
@@ -511,7 +604,7 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 5..=5, ())?;
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 6..=6, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
@@ -531,9 +624,39 @@ impl EffectsBinding {
regions: Default::default(),
opacity: std::cell::Cell::new(Some(opacity)),
presentation: Default::default(),
panel_presentation: Default::default(),
})
}
pub(super) fn set_panel_presentation(
&self,
scene: &ferese_panel::scene::Scene,
) -> Result<(), Box<dyn std::error::Error>> {
if self.panel_presentation.borrow().as_ref() == Some(scene) {
return Ok(());
}
let regions: Vec<_> = scene
.damage
.iter()
.map(|r| [r.x as f32, r.y as f32, r.width as f32, r.height as f32, 0.])
.collect();
validate_region_counts(regions.len(), 0)?;
if scene.paint.len() > ferese_panel::scene::MAX_PRIMITIVES {
return Err("too many panel primitives".into());
}
let shapes: Vec<_> = scene
.packets()
.into_iter()
.flatten()
.flat_map(f32::to_ne_bytes)
.collect();
self.surface
.set_panel_presentation(encode_regions(&regions), shapes, scene.fusion, 1000);
self.connection.flush()?;
*self.panel_presentation.borrow_mut() = Some(scene.clone());
Ok(())
}
pub(super) fn set_presentation(
&self,
regions: &[[f32; 5]],
+26 -18
View File
@@ -468,10 +468,10 @@ impl FereseShell {
fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else {
return text("").into();
return self.render.text("").into();
};
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
return text("").into();
return self.render.text("").into();
};
motion::frame_driven(
modal.motion.revision(),
@@ -496,10 +496,10 @@ impl FereseShell {
fn view_system_modal_at(&self, id: window::Id, now: Instant) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else {
return text("").into();
return self.render.text("").into();
};
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
return text("").into();
return self.render.text("").into();
};
let progress = modal.motion.progress_at(now);
let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
@@ -510,8 +510,9 @@ impl FereseShell {
let rows = match &modal.content {
Content::Power(action) => {
let mut rows = column![
text(action.title()).size(17),
text(action.description())
self.render.text(action.title()).size(17),
self.render
.text(action.description())
.size(13)
.class(theme::Text::Color(palette.muted)),
]
@@ -520,13 +521,14 @@ impl FereseShell {
&& !inhibitors.reasons.is_empty()
{
rows = rows.push(
text("Applications requested that this action be prevented:")
self.render
.text("Applications requested that this action be prevented:")
.size(13)
.class(theme::Text::Color(palette.muted)),
);
let items = inhibitors.reasons.iter().fold(
column::with_capacity(inhibitors.reasons.len()).spacing(6),
|rows, reason| rows.push(text(reason).size(13)),
|rows, reason| rows.push(self.render.text(reason).size(13)),
);
rows = rows
.push(container(cosmic::widget::scrollable(items).height(Length::Shrink)).max_height(160));
@@ -537,13 +539,13 @@ impl FereseShell {
action.label().to_owned()
};
if let Some(error) = &modal.error {
rows = rows.push(text(error).size(13));
rows = rows.push(self.render.text(error).size(13));
}
rows.push(
container(
row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button("Cancel", shell_font(), palette, false)
ferese_theme::controls::text_button("Cancel", self.render.font, palette, false)
.class(ferese_theme::controls::material_button_style_with_focus(
palette,
false,
@@ -552,7 +554,7 @@ impl FereseShell {
))
.id("ferese-modal-cancel".into())
.on_press(cosmic::Action::App(Message::CancelPower)),
ferese_theme::controls::text_button(label, shell_font(), palette, true)
ferese_theme::controls::text_button(label, self.render.font, palette, true)
.class(ferese_theme::controls::button_style_with_focus(
palette,
true,
@@ -576,8 +578,11 @@ impl FereseShell {
for entry in entries {
bindings = bindings.push(
row![
text(&entry.keys).size(13).width(Length::FillPortion(1)),
text(&entry.description).size(13).width(Length::FillPortion(1)),
self.render.text(&entry.keys).size(13).width(Length::FillPortion(1)),
self.render
.text(&entry.description)
.size(13)
.width(Length::FillPortion(1)),
]
.spacing(16),
);
@@ -592,16 +597,19 @@ impl FereseShell {
.saturating_sub(360)
.clamp(40, 400) as f32;
column![
text("Welcome to Ferese").size(23),
ferese_theme::menus::section_label("Your active shortcuts", shell_font(), palette.muted,)
self.render.text("Welcome to Ferese").size(23),
ferese_theme::menus::section_label("Your active shortcuts", self.render.font, palette.muted,)
.size(14),
container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height),
text("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.")
self.render
.text(
"Disable this guide in Settings → Shortcuts. It appears at each login until disabled."
)
.size(12)
.class(theme::Text::Color(palette.muted)),
container(row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button("Got it", shell_font(), palette, true,)
ferese_theme::controls::text_button("Got it", self.render.font, palette, true,)
.class(ferese_theme::controls::button_style_with_focus(
palette,
true,
@@ -641,7 +649,7 @@ impl FereseShell {
}))
.into()
} else {
text("").into()
self.render.text("").into()
};
let content = if surface.primary {
motion::animated(
+14 -9
View File
@@ -2,7 +2,7 @@ use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::{Element, Message, container, text};
use crate::{Element, Message, container};
use cosmic::app::Task;
use cosmic::iced::advanced::widget::{Id, Operation, operation};
use cosmic::iced::{Rectangle, window};
@@ -26,7 +26,7 @@ impl FereseShell {
&& matches!(
item.kind,
crate::panel::ItemKind::Workspaces {
style: ferese_config::panel::WorkspaceStyle::AppIcons
style: ferese_panel::WorkspaceStyle::AppIcons
}
)
});
@@ -43,7 +43,7 @@ impl FereseShell {
} else {
Task::none()
};
Task::batch([icons, tooltip])
Task::batch([icons, tooltip, self.sync_hub_workspaces()])
}
pub(super) fn workspace_icons_loaded(&mut self, result: Result<IconIndex, String>) -> Task<Message> {
@@ -259,8 +259,8 @@ impl FereseShell {
let id = window::Id::unique();
let parent = hover.bar;
let (anchor, gravity, gap) = match self.config.panels[0].edge {
ferese_config::panel::Edge::Top => (2u32, 2u32, 6),
ferese_config::panel::Edge::Bottom => (1u32, 1u32, -6),
ferese_panel::Edge::Top => (2u32, 2u32, 6),
ferese_panel::Edge::Bottom => (1u32, 1u32, -6),
};
self.workspace_ui.tooltip = Some(id);
let action = cosmic::surface::action::app_popup::<Self>(
@@ -299,7 +299,7 @@ impl FereseShell {
.iter()
.find(|workspace| workspace.id == hover.workspace)
}) else {
return text("").into();
return self.render.text("").into();
};
let active = self
.snapshot
@@ -315,7 +315,8 @@ impl FereseShell {
});
cosmic::widget::autosize::autosize(
container(
text(label)
self.render
.text(label)
.size(11)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
@@ -502,7 +503,9 @@ mod tests {
focused: false,
urgent: false,
fullscreen: false,
maximized: false,
floating: false,
geometry: None,
});
}
@@ -553,7 +556,7 @@ mod tests {
for item in &mut group.items {
if matches!(item.kind, crate::panel::ItemKind::Workspaces { .. }) {
item.kind = crate::panel::ItemKind::Workspaces {
style: ferese_config::panel::WorkspaceStyle::AppIcons,
style: ferese_panel::WorkspaceStyle::AppIcons,
};
}
}
@@ -657,7 +660,9 @@ mod tests {
focused: false,
urgent: false,
fullscreen: false,
maximized: false,
floating: false,
geometry: None,
})
.collect();
let fingerprint = |handles: Vec<icon::Handle>| {
@@ -705,7 +710,7 @@ mod tests {
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget};
use cosmic::iced::{Event, Font, Pixels, Point, Size};
use ferese_config::panel::WorkspaceStyle;
use ferese_panel::WorkspaceStyle;
#[derive(Clone, Copy, Debug)]
enum Pointer {