Fix panel review findings and polkit library lifetime
This commit is contained in:
1 parent
f7e5efb245
commit
305f49a62f
13 files changed
+436
-676
No files matched your search
@@ -178,9 +178,9 @@ impl Hub {
|
||||
pub fn pin_radii(&mut self, pins: Option<[f64; 4]>) -> Result<(), &'static str> {
|
||||
if pins.is_some_and(|pins| {
|
||||
pins.iter()
|
||||
.any(|value| !value.is_finite() || !(0. ..=128.).contains(value))
|
||||
.any(|value| !value.is_finite() || !(0. ..=f64::from(f32::MAX)).contains(value))
|
||||
}) {
|
||||
return Err("hub radius pins must be finite values between 0 and 128");
|
||||
return Err("hub radius pins must be finite, nonnegative pixel values representable as f32");
|
||||
}
|
||||
self.pins = pins;
|
||||
let mut target = self.motion.target();
|
||||
@@ -307,7 +307,7 @@ impl Hub {
|
||||
let mut geometry = self.motion.current();
|
||||
geometry.bounds.width = geometry.bounds.width.clamp(0., self.available[0]);
|
||||
geometry.bounds.height = geometry.bounds.height.clamp(0., self.available[1]);
|
||||
geometry.radii = geometry.radii.map(|radius| radius.max(0.));
|
||||
geometry.radii = geometry.radii.map(|radius| radius.clamp(0., f64::from(f32::MAX)));
|
||||
Sample {
|
||||
geometry,
|
||||
previous: self.previous,
|
||||
|
||||
@@ -136,6 +136,16 @@ pub enum ItemKind {
|
||||
}
|
||||
|
||||
impl ItemKind {
|
||||
/// Smallest useful allocation before a flexible control moves into overflow.
|
||||
pub fn minimum_width(self) -> Option<f32> {
|
||||
match self {
|
||||
Self::FocusedWindow => Some(48.),
|
||||
Self::Workspaces { .. } => Some(24.),
|
||||
Self::Media => Some(112.),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overview => "Overview",
|
||||
|
||||
@@ -222,6 +222,41 @@ pub fn resolve_with_gap(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Removing the larger side may make an earlier eviction unnecessary. Give
|
||||
// mandatory items first refusal, then restore by descending priority.
|
||||
for mandatory in [true, false] {
|
||||
for (_, item) in candidates.iter().rev() {
|
||||
if item.overflow == OverflowPolicy::Always
|
||||
|| (item.overflow == OverflowPolicy::Never) != mandatory
|
||||
|| !matches!(result.items.get(&item.id), Some(Placement::Overflow))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(measured) = measurements.get(&item.id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(&(representation, width)) = measured.alternatives.iter().min_by(|a, b| a.1.total_cmp(&b.1)) else {
|
||||
continue;
|
||||
};
|
||||
result.items.insert(
|
||||
item.id.clone(),
|
||||
Placement::Visible {
|
||||
representation,
|
||||
width: measured.minimum.map_or(width, |minimum| minimum.min(width)),
|
||||
},
|
||||
);
|
||||
result.overflow.retain(|id| id != &item.id);
|
||||
result.update_widths(panel, overflow_width);
|
||||
if !fits(&result) {
|
||||
result.items.insert(item.id.clone(), Placement::Overflow);
|
||||
result.overflow.push(item.id.clone());
|
||||
result.update_widths(panel, overflow_width);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.forced_overflow = ordered.iter().any(|item| {
|
||||
item.overflow == OverflowPolicy::Never && matches!(result.items.get(&item.id), Some(Placement::Overflow))
|
||||
});
|
||||
// A reduction on the shorter side can leave unused room after the other
|
||||
// side shrinks. Restore preferred representations before expanding titles.
|
||||
for (_, item) in candidates.iter().rev() {
|
||||
@@ -294,6 +329,46 @@ pub fn resolve_with_gap(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn asymmetric_overflow_restores_items_that_fit_after_the_larger_side_yields() {
|
||||
use super::super::{Group, Item, ItemKind};
|
||||
for mirrored in [false, true] {
|
||||
for policy in [OverflowPolicy::Auto, OverflowPolicy::Never, OverflowPolicy::Always] {
|
||||
let group = |id: &str, priority, overflow| {
|
||||
let mut item = Item::new(id, ItemKind::Clock);
|
||||
item.priority = priority;
|
||||
item.overflow = overflow;
|
||||
let mut group = Group::new(id, vec![item], [0, 0], 0.);
|
||||
group.island_padding = 0.;
|
||||
group
|
||||
};
|
||||
let mut panel = Panel::default();
|
||||
panel.start.groups = vec![group("small", 0, policy)];
|
||||
panel.center.groups = vec![group("center", 100, OverflowPolicy::Never)];
|
||||
panel.end.groups = vec![group("large", 50, policy)];
|
||||
if mirrored {
|
||||
std::mem::swap(&mut panel.start, &mut panel.end);
|
||||
}
|
||||
let measurements: Vec<_> = [("small", 40.), ("center", 40.), ("large", 200.)]
|
||||
.into_iter()
|
||||
.map(|(id, width)| Measurement {
|
||||
id: ItemId(id.into()),
|
||||
alternatives: vec![(Representation::Wide, width)],
|
||||
minimum: None,
|
||||
})
|
||||
.collect();
|
||||
let resolution = resolve(&panel, &measurements, 300., 32.);
|
||||
assert!(resolution.fits(300.));
|
||||
assert_eq!(
|
||||
matches!(resolution.items[&ItemId("small".into())], Placement::Visible { .. }),
|
||||
policy != OverflowPolicy::Always
|
||||
);
|
||||
assert!(resolution.overflow.contains(&ItemId("large".into())));
|
||||
assert_eq!(resolution.forced_overflow, policy == OverflowPolicy::Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() {
|
||||
use super::super::{Group, Item, ItemKind};
|
||||
|
||||
@@ -349,6 +349,105 @@ mod tests {
|
||||
use cosmic::iced::{Font, Pixels};
|
||||
use ferese_panel::shape::Style;
|
||||
|
||||
#[test]
|
||||
fn shared_surface_publishes_layout_changes_and_leaves_native_fill_to_the_compositor() {
|
||||
use cosmic::iced::advanced::clipboard;
|
||||
use std::cell::RefCell;
|
||||
let mut renderer = cosmic::iced::futures::executor::block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let ids = [widget::Id::unique(), widget::Id::unique()];
|
||||
let mut tree = None;
|
||||
let viewport = Rectangle::with_size(Size::new(200., 32.));
|
||||
for (style, width, native) in [
|
||||
(Style::Floating, 40., false),
|
||||
(Style::Floating, 40., true),
|
||||
(Style::Floating, 60., true),
|
||||
(Style::Flat, 60., true),
|
||||
] {
|
||||
let content = cosmic::widget::row::with_children([
|
||||
cosmic::widget::container(cosmic::widget::Space::new().width(width).height(32))
|
||||
.id(ids[0].clone())
|
||||
.into(),
|
||||
cosmic::widget::container(cosmic::widget::Space::new().width(40).height(32))
|
||||
.id(ids[1].clone())
|
||||
.into(),
|
||||
])
|
||||
.spacing(20);
|
||||
let published = RefCell::new(Vec::new());
|
||||
let mut view = frame(
|
||||
cosmic::widget::container(content).width(200).height(32).into(),
|
||||
Options {
|
||||
parameters: Parameters {
|
||||
style,
|
||||
height: 32.,
|
||||
..Default::default()
|
||||
},
|
||||
bounds: None,
|
||||
groups: vec![
|
||||
(ids[0].clone(), ZonePosition::Start),
|
||||
(ids[1].clone(), ZonePosition::End),
|
||||
],
|
||||
scale: 1.25,
|
||||
high_contrast: false,
|
||||
background: Color::WHITE,
|
||||
border: Color::TRANSPARENT,
|
||||
divider: Color::TRANSPARENT,
|
||||
native,
|
||||
},
|
||||
|scene| published.borrow_mut().push(scene.clone()),
|
||||
Some(Box::new(Clone::clone)),
|
||||
);
|
||||
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
||||
tree.diff(&mut view);
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let mut messages = Vec::new();
|
||||
for _ in 0..3 {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
assert_eq!(published.borrow().len(), 3);
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
usize::from(!(style == Style::Floating && width == 40. && native))
|
||||
);
|
||||
let scene = published.borrow()[0].clone();
|
||||
assert_eq!(scene.input.len(), if style == Style::Floating { 2 } else { 1 });
|
||||
if style == Style::Floating {
|
||||
assert!(scene.distance([width as f64 + 10., 16.]) > 0., "the gap stays empty");
|
||||
}
|
||||
renderer.reset(viewport);
|
||||
view.as_widget().draw(
|
||||
tree,
|
||||
&mut renderer,
|
||||
&Theme::default(),
|
||||
&renderer::Style {
|
||||
text_color: Color::WHITE,
|
||||
icon_color: Color::WHITE,
|
||||
scale_factor: 1.25,
|
||||
},
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let pixels = renderer.screenshot(Size::new(250, 40), 1.25, Color::TRANSPARENT);
|
||||
assert_eq!(pixels.chunks_exact(4).any(|pixel| pixel[3] > 0), !native);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_coverage_tracks_physical_scale_and_high_contrast() {
|
||||
let mut renderer = cosmic::iced::futures::executor::block_on(<cosmic::Renderer as Headless>::new(
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::sync::{Arc, OnceLock, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use glib::gobject_ffi;
|
||||
@@ -22,8 +22,6 @@ enum SessionEvent {
|
||||
type Object = *mut c_void;
|
||||
|
||||
struct Api {
|
||||
_agent: Library,
|
||||
_polkit: Library,
|
||||
user_new: unsafe extern "C" fn(c_int) -> Object,
|
||||
session_new: unsafe extern "C" fn(Object, *const c_char) -> Object,
|
||||
initiate: unsafe extern "C" fn(Object),
|
||||
@@ -34,8 +32,17 @@ struct Api {
|
||||
impl Api {
|
||||
fn load() -> Result<Self, String> {
|
||||
unsafe {
|
||||
let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
|
||||
let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
|
||||
// GLib keeps registered types and their function pointers for the
|
||||
// lifetime of the process, including between authentication sessions.
|
||||
static LIBRARIES: OnceLock<Result<(Library, Library), String>> = OnceLock::new();
|
||||
let (agent, polkit) = LIBRARIES
|
||||
.get_or_init(|| {
|
||||
let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
|
||||
let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
|
||||
Ok((agent, polkit))
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)?;
|
||||
let user_new = *polkit.get(b"polkit_unix_user_new\0").map_err(|e| e.to_string())?;
|
||||
let session_new = *agent.get(b"polkit_agent_session_new\0").map_err(|e| e.to_string())?;
|
||||
let initiate = *agent
|
||||
@@ -47,8 +54,6 @@ impl Api {
|
||||
let cancel = *agent.get(b"polkit_agent_session_cancel\0").map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
_agent: agent,
|
||||
_polkit: polkit,
|
||||
user_new,
|
||||
session_new,
|
||||
initiate,
|
||||
@@ -460,6 +465,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_identity_types_survive_between_api_loads() {
|
||||
for _ in 0..3 {
|
||||
let api = Api::load().unwrap();
|
||||
// SAFETY: the identity constructor accepts the current uid and
|
||||
// returns an owned GObject reference.
|
||||
unsafe {
|
||||
let identity = (api.user_new)(libc::getuid() as c_int);
|
||||
assert!(!identity.is_null());
|
||||
gobject_ffi::g_object_unref(identity.cast());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_stop_after_three_failures_even_when_the_account_changes() {
|
||||
let mut api = Api::load().unwrap();
|
||||
|
||||
@@ -157,11 +157,7 @@ impl App {
|
||||
samples.push(Sample {
|
||||
id: item.id.clone(),
|
||||
representation,
|
||||
minimum: match item.kind {
|
||||
ItemKind::Workspaces { .. } => Some(24.),
|
||||
ItemKind::Media => Some(112.),
|
||||
_ => None,
|
||||
},
|
||||
minimum: item.kind.minimum_width(),
|
||||
view: self.preview_control(item, representation, None),
|
||||
});
|
||||
}
|
||||
@@ -731,7 +727,15 @@ mod tests {
|
||||
.expect("sample title remains visible")
|
||||
.1;
|
||||
assert!((center.center_x() - width / 2.).abs() < 0.1, "{center:?} at {width}");
|
||||
assert!((center.width - natural).abs() < 0.1, "sample title must not be clipped");
|
||||
// Match the shell: titles yield space down to their shared minimum.
|
||||
assert!(center.width >= title.kind.minimum_width().unwrap().min(natural) - 0.1);
|
||||
assert!(center.width <= natural + 0.1);
|
||||
if width == 1200. {
|
||||
assert!(
|
||||
(center.width - natural).abs() < 0.1,
|
||||
"restore the title when space permits"
|
||||
);
|
||||
}
|
||||
for (id, side) in &bounds.items {
|
||||
if id == &title.id {
|
||||
continue;
|
||||
|
||||
@@ -315,9 +315,7 @@ impl FereseShell {
|
||||
let entries = list_entries(&self.activities.snapshot, &self.activities.native, now, name);
|
||||
let workspace_width = self.hub_workspace_width(output.bar) as f32;
|
||||
let panel = self.panel_for_bar(output.bar);
|
||||
let available = output.size.map_or(1280., |size| size.0 as f32)
|
||||
- 2. * panel.resolved_geometry().side_margins as f32
|
||||
- if panel.morph.attached { 24. } else { 0. }
|
||||
let available = super::panel_hub::content_width(panel, output.size) as f32
|
||||
- if workspace_width > 0. {
|
||||
workspace_width + 10.
|
||||
} else {
|
||||
@@ -331,7 +329,7 @@ impl FereseShell {
|
||||
.copied()
|
||||
.filter(|entry| entry.eligible(now, dnd) && entry.data.source != ferese_activities::Source::Media)
|
||||
.collect();
|
||||
let compact = if self.activities_configured(output.bar) {
|
||||
let compact = if self.compact_activities_configured(output.bar) {
|
||||
compact
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -421,7 +419,7 @@ impl FereseShell {
|
||||
routed
|
||||
&& (list
|
||||
|| self.activities.flat.get(&output.bar).is_some_and(|presentation| {
|
||||
self.activities_configured(output.bar)
|
||||
self.compact_activities_configured(output.bar)
|
||||
&& presentation.selection.visible.contains(&activity.id)
|
||||
}))
|
||||
}
|
||||
@@ -484,6 +482,14 @@ impl FereseShell {
|
||||
.any(|entry| entry.data.source != ferese_activities::Source::Media)
|
||||
}
|
||||
pub fn activities_configured(&self, id: window::Id) -> bool {
|
||||
self.has_activities_item(id, false)
|
||||
}
|
||||
|
||||
pub fn compact_activities_configured(&self, id: window::Id) -> bool {
|
||||
self.has_activities_item(id, true)
|
||||
}
|
||||
|
||||
fn has_activities_item(&self, id: window::Id, compact: bool) -> bool {
|
||||
let panel = self.panel_for_bar(id);
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
@@ -492,7 +498,7 @@ impl FereseShell {
|
||||
.any(|item| {
|
||||
item.visible
|
||||
&& item.kind == ferese_panel::ItemKind::Activities
|
||||
&& item.overflow != ferese_panel::OverflowPolicy::Always
|
||||
&& (!compact || item.overflow != ferese_panel::OverflowPolicy::Always)
|
||||
})
|
||||
}
|
||||
pub fn activity_hover(&mut self, id: window::Id, entered: bool) {
|
||||
@@ -612,7 +618,7 @@ impl FereseShell {
|
||||
)
|
||||
}
|
||||
pub fn has_compact_activities(&self, id: window::Id) -> bool {
|
||||
if !self.activities_configured(id) {
|
||||
if !self.compact_activities_configured(id) {
|
||||
return false;
|
||||
}
|
||||
self.outputs
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#[cfg(test)]
|
||||
use super::Background;
|
||||
use super::{
|
||||
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, bar_icon, button,
|
||||
color, container, control, motion, row, status_ui, theme, window,
|
||||
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, alignment, bar_icon, button, color,
|
||||
container, control, motion, row, status_ui, theme, window,
|
||||
};
|
||||
use crate::panel::{Availability, Item, ItemKind, Panel, Zone};
|
||||
#[cfg(test)]
|
||||
@@ -113,12 +111,7 @@ impl FereseShell {
|
||||
samples.push(adaptive::Sample {
|
||||
id: item.id.clone(),
|
||||
representation,
|
||||
minimum: match item.kind {
|
||||
ItemKind::FocusedWindow => Some(48.0),
|
||||
ItemKind::Workspaces { .. } => Some(24.0),
|
||||
ItemKind::Media => Some(112.0),
|
||||
_ => None,
|
||||
},
|
||||
minimum: item.kind.minimum_width(),
|
||||
view,
|
||||
});
|
||||
}
|
||||
@@ -176,8 +169,6 @@ impl FereseShell {
|
||||
panel,
|
||||
zone,
|
||||
availability,
|
||||
shell_theme,
|
||||
compositor_material,
|
||||
|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0),
|
||||
|item, surface| {
|
||||
let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id)
|
||||
@@ -207,8 +198,6 @@ impl FereseShell {
|
||||
spacing: 0.0,
|
||||
},
|
||||
availability,
|
||||
shell_theme,
|
||||
compositor_material,
|
||||
|_| true,
|
||||
|item, surface| {
|
||||
self.view_panel_item(
|
||||
@@ -705,8 +694,6 @@ fn view_zone<'a>(
|
||||
panel: &Panel,
|
||||
zone: &Zone,
|
||||
availability: Availability,
|
||||
_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>> {
|
||||
@@ -728,11 +715,6 @@ fn view_zone<'a>(
|
||||
controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
|
||||
}
|
||||
let (element, _) = render(item, surface);
|
||||
let element: Element<'_, cosmic::Action<Message>> = if matches!(item.kind, ItemKind::FocusedWindow) {
|
||||
element
|
||||
} else {
|
||||
container(element).id(presentation::input_id()).into()
|
||||
};
|
||||
controls = controls.push(container(element).id(format!("panel:{}:item:{}", panel.id.0, item.id.0)));
|
||||
count += 1;
|
||||
}
|
||||
@@ -838,63 +820,6 @@ 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()
|
||||
} else {
|
||||
ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius)
|
||||
};
|
||||
if compositor_material {
|
||||
style.background = None;
|
||||
}
|
||||
style.text_color = Some(color(theme.text_primary));
|
||||
style.icon_color = style.text_color;
|
||||
style.snap = true;
|
||||
style
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn island<'a>(
|
||||
content: Element<'a, cosmic::Action<Message>>,
|
||||
theme: ShellTheme,
|
||||
geometry: crate::panel::PanelGeometry,
|
||||
horizontal_padding: f32,
|
||||
islands: bool,
|
||||
compositor_material: bool,
|
||||
material: Option<(cosmic::iced::advanced::widget::Id, f32, [f32; 4])>,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
if !islands {
|
||||
return content;
|
||||
}
|
||||
|
||||
let bar = BarMetrics::from(geometry);
|
||||
let fallback = ferese_panel::CornerRadii::uniform(theme.bar_radius)
|
||||
.at_top_edge(geometry.edge_margin == 0)
|
||||
.0;
|
||||
let (id, opacity, corners) =
|
||||
material.unwrap_or((presentation::island_id(), color(theme.bar_background).a, fallback));
|
||||
container(content)
|
||||
.id(id)
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: (bar.height - bar.group_height) * 0.5,
|
||||
bottom: (bar.height - bar.group_height) * 0.5,
|
||||
left: horizontal_padding,
|
||||
right: horizontal_padding,
|
||||
})
|
||||
.height(bar.height)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
.class(cosmic::theme::Container::custom(move |_| {
|
||||
let mut style = bar_style(theme, compositor_material, false);
|
||||
style.border.radius = corners.into();
|
||||
if let Some(Background::Color(fill)) = style.background {
|
||||
style.background = Some(Background::Color(Color { a: opacity, ..fill }));
|
||||
}
|
||||
style
|
||||
}))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn input_region(
|
||||
mode: PanelSurface,
|
||||
hidden: bool,
|
||||
@@ -921,11 +846,6 @@ pub(super) fn input_region(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
|
||||
ferese_theme::panel::group_border(color(theme.border), theme.material_radius.min(16.), true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod island_tests {
|
||||
use super::*;
|
||||
@@ -963,7 +883,7 @@ mod island_tests {
|
||||
if self.group_ids.iter().any(|target| Some(target) == id) {
|
||||
self.groups.push(bounds);
|
||||
}
|
||||
if id == Some(&presentation::island_id()) || self.material_ids.iter().any(|target| Some(target) == id) {
|
||||
if self.material_ids.iter().any(|target| Some(target) == id) {
|
||||
self.islands.push(bounds);
|
||||
}
|
||||
if let Some((_, name)) = self.targets.iter().find(|(target, _)| Some(target) == id) {
|
||||
@@ -1094,16 +1014,6 @@ mod island_tests {
|
||||
}
|
||||
|
||||
fn measure_zone(panel: &Panel, zone: &Zone, availability: Availability, title_content: bool) -> Bounds {
|
||||
measure_zone_with_theme(panel, zone, availability, title_content, ShellTheme::default())
|
||||
}
|
||||
|
||||
fn measure_zone_with_theme(
|
||||
panel: &Panel,
|
||||
zone: &Zone,
|
||||
availability: Availability,
|
||||
title_content: bool,
|
||||
theme: ShellTheme,
|
||||
) -> Bounds {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -1119,8 +1029,6 @@ mod island_tests {
|
||||
panel,
|
||||
zone,
|
||||
availability,
|
||||
theme,
|
||||
true,
|
||||
|item| title_content || !matches!(item.kind, ItemKind::FocusedWindow),
|
||||
|item, _| {
|
||||
let width = match item.kind {
|
||||
@@ -1184,12 +1092,11 @@ mod island_tests {
|
||||
fn islands_cover_the_full_panel_height_independently_of_group_decoration() {
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
for margin in [0, 6] {
|
||||
let theme = ShellTheme::default();
|
||||
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);
|
||||
let bounds = measure_zone(&panel, &panel.start, Availability::default(), true);
|
||||
assert_eq!(bounds.islands[0].y, 0.);
|
||||
assert_eq!(bounds.islands[0].height, 36.);
|
||||
assert_eq!(
|
||||
@@ -1315,41 +1222,6 @@ mod island_tests {
|
||||
assert_eq!(input_region(PanelSurface::Solid, true, ®ions), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_groups_have_no_fill_or_outline_in_either_arrangement() {
|
||||
let theme = ShellTheme::default();
|
||||
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());
|
||||
assert_eq!(group.border.width, 0.);
|
||||
let background_style = bar_style(theme, false, false);
|
||||
assert_eq!(
|
||||
background_style.background,
|
||||
Some(Background::Color(color(theme.bar_background)))
|
||||
);
|
||||
assert_eq!(background_style.border.width, 0.);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlined_groups_leave_the_bar_as_the_only_background_owner() {
|
||||
let theme = ShellTheme {
|
||||
bar_background: [12, 24, 36, 153],
|
||||
..Default::default()
|
||||
};
|
||||
let frame = bar_style(theme, false, true);
|
||||
assert!(frame.background.is_none());
|
||||
assert_eq!(frame.border.width, 0.);
|
||||
assert_eq!(frame.shadow.color.a, 0.);
|
||||
let outer = bar_style(theme, false, false);
|
||||
assert_eq!(outer.background, Some(Background::Color(color(theme.bar_background))));
|
||||
assert_eq!(outer.border.radius, theme.bar_radius.into());
|
||||
assert!(bar_style(theme, true, false).background.is_none());
|
||||
let inner = bar_group_style(theme);
|
||||
assert!(inner.background.is_none());
|
||||
assert_eq!(inner.border.width, 1.);
|
||||
}
|
||||
#[test]
|
||||
fn panel_surface_policy_applies_to_every_occupied_group() {
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
|
||||
@@ -1,60 +1,9 @@
|
||||
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_panel::PanelSurface;
|
||||
|
||||
pub(crate) fn island_id() -> widget::Id {
|
||||
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
|
||||
ID.get_or_init(widget::Id::unique).clone()
|
||||
}
|
||||
|
||||
pub(crate) fn input_id() -> widget::Id {
|
||||
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
|
||||
ID.get_or_init(widget::Id::unique).clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn frame<'a, M: 'a>(
|
||||
content: Element<'a, M>,
|
||||
mode: PanelSurface,
|
||||
radius: f32,
|
||||
materials: Vec<(widget::Id, f32)>,
|
||||
hover: Option<HoverTarget<'a, M>>,
|
||||
present: impl Fn(&[[f32; 5]], &[f32]) + 'a,
|
||||
changed: impl Fn(Vec<[f32; 5]>) -> M + 'a,
|
||||
) -> Element<'a, M> {
|
||||
Element::new(Presentation {
|
||||
content,
|
||||
mode,
|
||||
radius,
|
||||
materials,
|
||||
hover,
|
||||
present: Box::new(present),
|
||||
changed: 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,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
mode: Option<PanelSurface>,
|
||||
current: Vec<[f32; 5]>,
|
||||
scratch: Vec<[f32; 5]>,
|
||||
input: Vec<[f32; 5]>,
|
||||
input_scratch: Vec<[f32; 5]>,
|
||||
scroll_starts: Vec<(usize, usize)>,
|
||||
opacity_scratch: Vec<f32>,
|
||||
Element::new(Hover { content, hover })
|
||||
}
|
||||
|
||||
pub(super) struct HoverTarget<'a, M> {
|
||||
@@ -79,29 +28,12 @@ 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> {
|
||||
struct Hover<'a, M> {
|
||||
content: Element<'a, M>,
|
||||
mode: PanelSurface,
|
||||
radius: f32,
|
||||
materials: Vec<(widget::Id, f32)>,
|
||||
hover: Option<HoverTarget<'a, M>>,
|
||||
present: Present<'a>,
|
||||
changed: Option<Changed<'a, M>>,
|
||||
}
|
||||
|
||||
impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State::default())
|
||||
}
|
||||
|
||||
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.content)]
|
||||
}
|
||||
@@ -181,54 +113,6 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
if self.changed.is_none() || !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let mut collector = Collect {
|
||||
regions: std::mem::take(&mut state.scratch),
|
||||
input: std::mem::take(&mut state.input_scratch),
|
||||
scroll_starts: std::mem::take(&mut state.scroll_starts),
|
||||
radius: self.radius,
|
||||
materials: &self.materials,
|
||||
opacities: std::mem::take(&mut state.opacity_scratch),
|
||||
};
|
||||
collector.regions.clear();
|
||||
collector.opacities.clear();
|
||||
collector.input.clear();
|
||||
collector.scroll_starts.clear();
|
||||
if self.mode == PanelSurface::None {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, &mut collector);
|
||||
} else {
|
||||
let bounds = layout.bounds();
|
||||
collector
|
||||
.regions
|
||||
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||
collector.opacities.push(1.);
|
||||
}
|
||||
|
||||
collector.clip_from(0, layout.bounds(), Vector::ZERO);
|
||||
collector.clip_input_from(0, layout.bounds(), Vector::ZERO);
|
||||
collector.discard_empty();
|
||||
(self.present)(&collector.regions, &collector.opacities);
|
||||
if state.mode != Some(self.mode) || state.current != collector.regions || state.input != collector.input {
|
||||
state.mode = Some(self.mode);
|
||||
state.current.clone_from(&collector.regions);
|
||||
state.input.clone_from(&collector.input);
|
||||
shell.publish((self.changed.as_ref().unwrap())(if self.mode == PanelSurface::Solid {
|
||||
collector.regions.clone()
|
||||
} else {
|
||||
collector.input.clone()
|
||||
}));
|
||||
}
|
||||
|
||||
state.opacity_scratch = collector.opacities;
|
||||
state.scratch = collector.regions;
|
||||
state.input_scratch = collector.input;
|
||||
state.scroll_starts = collector.scroll_starts;
|
||||
}
|
||||
|
||||
fn draw(
|
||||
@@ -284,350 +168,3 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Collect<'a> {
|
||||
regions: Vec<[f32; 5]>,
|
||||
input: Vec<[f32; 5]>,
|
||||
scroll_starts: Vec<(usize, usize)>,
|
||||
radius: f32,
|
||||
materials: &'a [(widget::Id, f32)],
|
||||
opacities: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Collect<'_> {
|
||||
fn discard_empty(&mut self) {
|
||||
let mut index = 0;
|
||||
self.opacities.retain(|_| {
|
||||
let keep = self.regions[index][2] > 0. && self.regions[index][3] > 0.;
|
||||
index += 1;
|
||||
keep
|
||||
});
|
||||
self.regions.retain(|region| region[2] > 0. && region[3] > 0.);
|
||||
self.input.retain(|region| region[2] > 0. && region[3] > 0.);
|
||||
}
|
||||
|
||||
fn clip_input_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) {
|
||||
std::mem::swap(&mut self.regions, &mut self.input);
|
||||
self.clip_from(start, viewport, translation);
|
||||
std::mem::swap(&mut self.regions, &mut self.input);
|
||||
}
|
||||
|
||||
fn clip_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) {
|
||||
for region in &mut self.regions[start..] {
|
||||
let bounds = Rectangle {
|
||||
x: region[0] - translation.x,
|
||||
y: region[1] - translation.y,
|
||||
width: region[2],
|
||||
height: region[3],
|
||||
};
|
||||
let bounds = bounds.intersection(&viewport).unwrap_or_default();
|
||||
region[..4].copy_from_slice(&[bounds.x, bounds.y, bounds.width, bounds.height]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl widget::Operation for Collect<'_> {
|
||||
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
children(self);
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
let opacity = self
|
||||
.materials
|
||||
.iter()
|
||||
.find(|(target, _)| Some(target) == id)
|
||||
.map(|(_, opacity)| *opacity);
|
||||
if opacity.is_some() || id == Some(&island_id()) || id == Some(&input_id()) {
|
||||
self.input
|
||||
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||
}
|
||||
if opacity.is_some() || id == Some(&island_id()) {
|
||||
self.regions
|
||||
.push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]);
|
||||
self.opacities.push(opacity.unwrap_or(1.));
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_operation(&mut self, _id: Option<&widget::Id>) {
|
||||
// Iced visits scrollable content before its scrollable callback. Record
|
||||
// the range so only that subtree receives its offset and viewport clip.
|
||||
self.scroll_starts.push((self.regions.len(), self.input.len()));
|
||||
}
|
||||
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_content: Rectangle,
|
||||
translation: Vector,
|
||||
_state: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if let Some((start, input_start)) = self.scroll_starts.pop() {
|
||||
self.clip_from(start, bounds, translation);
|
||||
self.clip_input_from(input_start, bounds, translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::advanced::{clipboard, renderer::Headless};
|
||||
use cosmic::iced::{Font, Pixels, widget as widgets};
|
||||
use cosmic::widget::{container, row};
|
||||
|
||||
#[test]
|
||||
fn clipping_preserves_group_opacity_and_transparent_groups_keep_input() {
|
||||
use widget::Operation;
|
||||
let ids = [widget::Id::unique(), widget::Id::unique(), widget::Id::unique()];
|
||||
let materials = [(ids[0].clone(), 0.2), (ids[1].clone(), 0.), (ids[2].clone(), 0.7)];
|
||||
let mut collector = Collect {
|
||||
materials: &materials,
|
||||
radius: 12.,
|
||||
..Default::default()
|
||||
};
|
||||
for (id, x) in ids.iter().zip([-80., 20., 80.]) {
|
||||
collector.container(Some(id), Rectangle::new((x, 0.).into(), (30., 28.).into()));
|
||||
}
|
||||
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
|
||||
collector.clip_from(0, viewport, Vector::ZERO);
|
||||
collector.clip_input_from(0, viewport, Vector::ZERO);
|
||||
collector.discard_empty();
|
||||
assert_eq!(collector.regions, [[20., 0., 30., 28., 12.], [80., 0., 20., 28., 12.]]);
|
||||
assert_eq!(collector.opacities, [0., 0.7]);
|
||||
assert_eq!(
|
||||
collector.input, collector.regions,
|
||||
"background opacity does not disable controls"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_only_changes_publish_material_without_rebuilding_input() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
|
||||
let id = widget::Id::unique();
|
||||
let mut tree = None;
|
||||
for (index, opacity) in [1., 0.5, 0.].into_iter().enumerate() {
|
||||
let published = std::cell::RefCell::new(Vec::new());
|
||||
let content = container(widgets::Space::new().width(60).height(28))
|
||||
.id(id.clone())
|
||||
.into();
|
||||
let mut view = frame(
|
||||
content,
|
||||
PanelSurface::None,
|
||||
12.,
|
||||
vec![(id.clone(), opacity)],
|
||||
None,
|
||||
|regions, opacities| published.borrow_mut().push((regions.to_vec(), opacities.to_vec())),
|
||||
|regions| regions,
|
||||
);
|
||||
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
||||
tree.diff(&mut view);
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let mut messages = Vec::new();
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
assert_eq!(*published.borrow(), [(vec![[0., 0., 60., 28., 12.]], vec![opacity])]);
|
||||
assert_eq!(messages.len(), usize::from(index == 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_islands_and_live_layout_switches_publish_only_changed_bounds() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let viewport = Rectangle::new((0., 0.).into(), (600., 28.).into());
|
||||
let mut tree = None;
|
||||
let theme = crate::ShellTheme {
|
||||
bar_radius: 14.,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (mode, padding) in [
|
||||
(PanelSurface::None, 12.),
|
||||
(PanelSurface::None, 4.),
|
||||
(PanelSurface::None, 0.),
|
||||
(PanelSurface::Solid, 32.),
|
||||
(PanelSurface::None, 12.),
|
||||
] {
|
||||
let section = |width| {
|
||||
let inner = container(widgets::Space::new().width(width).height(24))
|
||||
.class(cosmic::theme::Container::custom(move |_| {
|
||||
crate::bar::bar_group_style(theme)
|
||||
}))
|
||||
.into();
|
||||
crate::bar::island(
|
||||
inner,
|
||||
theme,
|
||||
crate::panel::PanelGeometry::default(),
|
||||
padding,
|
||||
mode == PanelSurface::None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let islands = row![section(100), section(200),].spacing(60);
|
||||
let content: 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.map(|_| Vec::<[f32; 5]>::new()),
|
||||
mode,
|
||||
14.,
|
||||
vec![],
|
||||
None,
|
||||
|_, _| {},
|
||||
|regions| regions,
|
||||
);
|
||||
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
||||
tree.diff(&mut view);
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for _ in 0..5 {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(messages.len(), 1, "unchanged frames must not rebuild input regions");
|
||||
let expected = if mode == PanelSurface::None {
|
||||
vec![
|
||||
[12., 0., 100. + 2. * padding, 28., 14.],
|
||||
[172. + 2. * padding, 0., 200. + 2. * padding, 28., 14.],
|
||||
]
|
||||
} else {
|
||||
vec![[0., 0., 600., 28., 14.]]
|
||||
};
|
||||
let regions = &messages[0];
|
||||
assert_eq!(*regions, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_clips_only_its_own_islands_and_preserves_fractional_positions() {
|
||||
let mut collector = Collect {
|
||||
regions: vec![[400., 2., 80., 24., 14.], [10.25, 2., 180., 24., 14.]],
|
||||
input: Vec::new(),
|
||||
scroll_starts: Vec::new(),
|
||||
radius: 14.,
|
||||
..Default::default()
|
||||
};
|
||||
collector.clip_from(
|
||||
1,
|
||||
Rectangle::new((20., 0.).into(), (100., 28.).into()),
|
||||
Vector::new(40.5, 0.),
|
||||
);
|
||||
assert_eq!(
|
||||
collector.regions,
|
||||
vec![[400., 2., 80., 24., 14.], [20., 2., 100., 24., 14.]]
|
||||
);
|
||||
collector.clip_from(0, Rectangle::new((0., 0.).into(), (200., 28.).into()), Vector::ZERO);
|
||||
collector.regions.retain(|region| region[2] > 0. && region[3] > 0.);
|
||||
assert_eq!(collector.regions, vec![[20., 2., 100., 24., 14.]]);
|
||||
}
|
||||
#[test]
|
||||
fn undecorated_controls_have_input_regions_without_creating_material_or_gap_regions() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let content = row![
|
||||
container(widgets::Space::new().width(30).height(24)).id(input_id()),
|
||||
container(widgets::Space::new().width(40).height(24)).id(input_id()),
|
||||
]
|
||||
.spacing(20)
|
||||
.into();
|
||||
let materials = std::cell::RefCell::new(Vec::new());
|
||||
let mut view = frame(
|
||||
content,
|
||||
PanelSurface::None,
|
||||
14.,
|
||||
vec![],
|
||||
None,
|
||||
|regions, _| *materials.borrow_mut() = regions.to_vec(),
|
||||
|regions| regions,
|
||||
);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into());
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let mut messages = Vec::new();
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
assert!(materials.borrow().is_empty());
|
||||
assert_eq!(messages, vec![vec![[0., 0., 30., 24., 14.], [50., 0., 40., 24., 14.]]]);
|
||||
let mut collector = Collect {
|
||||
regions: vec![[300., 0., 20., 24., 14.]],
|
||||
input: messages.remove(0),
|
||||
scroll_starts: Vec::new(),
|
||||
radius: 14.,
|
||||
..Default::default()
|
||||
};
|
||||
collector.clip_input_from(
|
||||
1,
|
||||
Rectangle::new((40., 0.).into(), (20., 28.).into()),
|
||||
Vector::new(10., 0.),
|
||||
);
|
||||
assert_eq!(collector.input, vec![[0., 0., 30., 24., 14.], [40., 0., 20., 24., 14.]]);
|
||||
assert_eq!(collector.regions, vec![[300., 0., 20., 24., 14.]]);
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,6 @@ struct BarMetrics {
|
||||
icon_size: u16,
|
||||
overview_icon_size: u16,
|
||||
control_height: f32,
|
||||
#[cfg(test)]
|
||||
group_height: f32,
|
||||
group_item_height: f32,
|
||||
}
|
||||
|
||||
@@ -94,8 +92,6 @@ 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),
|
||||
}
|
||||
}
|
||||
@@ -820,7 +816,7 @@ impl cosmic::Application for FereseShell {
|
||||
{
|
||||
entry.scale = f64::from(scale);
|
||||
}
|
||||
self.sync_hub_workspaces()
|
||||
Task::batch([self.sync_hub_workspaces(), self.resize_hub(id)])
|
||||
}
|
||||
Message::AdaptiveDeadline(id, ticket) => self.adaptive_deadline(id, ticket),
|
||||
Message::ControlReady => {
|
||||
@@ -1502,8 +1498,9 @@ mod tests {
|
||||
text_muted: [255, 255, 255, 255],
|
||||
..theme
|
||||
};
|
||||
let before = bar_group_style(theme);
|
||||
let after = bar_group_style(contrasted);
|
||||
let before = ferese_theme::panel::group_border(color(theme.border), theme.material_radius.min(16.), true);
|
||||
let after =
|
||||
ferese_theme::panel::group_border(color(contrasted.border), contrasted.material_radius.min(16.), true);
|
||||
assert_eq!(before.border, after.border);
|
||||
assert_eq!(before.background, after.background);
|
||||
assert_eq!(before.border.color, color(theme.border));
|
||||
@@ -1526,34 +1523,12 @@ mod tests {
|
||||
fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() {
|
||||
let theme = ShellTheme::default();
|
||||
assert_eq!(shell_surface_style(theme).background_color, Color::TRANSPARENT);
|
||||
let Some(Background::Color(background)) = bar_style(theme.for_bar(), false, false).background else {
|
||||
panic!("first frame needs a fallback fill before material attachment");
|
||||
};
|
||||
let background = color(theme.for_bar().bar_background);
|
||||
assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20);
|
||||
assert!(background.a > 0.9);
|
||||
assert!(theme.bar_text_primary[..3].iter().all(|channel| *channel > 220));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_fallback_uses_the_matching_dark_palette_and_geometry() {
|
||||
let theme = ShellTheme::default().for_bar();
|
||||
let style = bar_style(theme, false, false);
|
||||
assert_eq!(style.background, Some(Background::Color(color(theme.bar_background))));
|
||||
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
||||
assert_eq!(style.border.radius, theme.bar_radius.into());
|
||||
assert_eq!(style.shadow, cosmic::iced::Shadow::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_does_not_cover_the_selected_compositor_material() {
|
||||
let theme = ShellTheme::default().for_bar();
|
||||
let style = bar_style(theme, true, false);
|
||||
assert_eq!(style.background, None);
|
||||
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
||||
assert_eq!(style.border.radius, theme.bar_radius.into());
|
||||
assert_eq!(style.shadow, cosmic::iced::Shadow::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_bar_preserves_logical_text_and_icon_sizes() {
|
||||
for height in [24.0, 26.0, 32.0, 38.0, 44.0] {
|
||||
|
||||
@@ -23,6 +23,20 @@ use ferese_theme::icons;
|
||||
|
||||
use crate::{FereseShell, Message, color_with_opacity, motion, status_ui};
|
||||
|
||||
/// Morph owns its inset; regular-panel side margins and padding stay saved for
|
||||
/// the other styles without reducing the hub's output budget.
|
||||
fn available_size(panel: &Panel, size: Option<(i32, i32)>) -> [f64; 2] {
|
||||
let (width, height) = size.unwrap_or((1280, 720));
|
||||
[
|
||||
f64::from(width).max(24.),
|
||||
(f64::from(height) - f64::from(panel.resolved_geometry().edge_margin)).max(24.),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn content_width(panel: &Panel, size: Option<(i32, i32)>) -> f64 {
|
||||
(available_size(panel, size)[0] - if panel.morph.attached { 24. } else { 0. }).max(24.)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Peek {
|
||||
Recording,
|
||||
@@ -190,7 +204,12 @@ impl Runtime {
|
||||
.min(available[0].ceil() as u32);
|
||||
// Retain the backing surface between opens; resizing after settlement moves its origin.
|
||||
self.minimum_allocation = std::array::from_fn(|i| self.minimum_allocation[i].min(available[i].ceil() as u32));
|
||||
self.allocation = std::array::from_fn(|i| self.allocation[i].max(target[i]).max(self.minimum_allocation[i]));
|
||||
self.allocation = std::array::from_fn(|i| {
|
||||
self.allocation[i]
|
||||
.max(target[i])
|
||||
.max(self.minimum_allocation[i])
|
||||
.min(available[i].ceil() as u32)
|
||||
});
|
||||
self.revision = now;
|
||||
}
|
||||
}
|
||||
@@ -361,24 +380,23 @@ impl FereseShell {
|
||||
self.transition_hub(id, Event::Present(mode, menu))
|
||||
}
|
||||
|
||||
pub(super) fn resize_hub(&mut self, id: window::Id) -> Task<Message> {
|
||||
self.transition_hub(id, Event::Resize)
|
||||
}
|
||||
|
||||
fn transition_hub(&mut self, id: window::Id, event: Event) -> Task<Message> {
|
||||
if self.snapshot.binding_mode.is_some() && !matches!(event, Event::Close | Event::Resize) {
|
||||
return Task::none();
|
||||
}
|
||||
let workspace_width = self.hub_workspace_width(id);
|
||||
let panel = self.panel_for_bar(id);
|
||||
let geometry = panel.resolved_geometry();
|
||||
let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(hub) = &entry.runtime.hub else {
|
||||
return Task::none();
|
||||
};
|
||||
let (width, height) = entry.size.unwrap_or((1280, 720));
|
||||
let available = [
|
||||
(f64::from(width) - 2. * f64::from(geometry.side_margins)).max(24.),
|
||||
(f64::from(height) - f64::from(geometry.edge_margin)).max(24.),
|
||||
];
|
||||
let available = available_size(panel, entry.size);
|
||||
let (resize, mode) = {
|
||||
let mut hub = hub.borrow_mut();
|
||||
hub.motion = self.render.motion;
|
||||
@@ -415,7 +433,7 @@ impl FereseShell {
|
||||
.and_then(|entry| entry.runtime.hub.as_ref())
|
||||
.expect("Morph runtime");
|
||||
let runtime = runtime.borrow();
|
||||
if !runtime.activities.selection.visible.is_empty() && self.activities_configured(id) {
|
||||
if !runtime.activities.selection.visible.is_empty() && self.compact_activities_configured(id) {
|
||||
return runtime.hub.target_size()[0].min(runtime.available[0]) as f32;
|
||||
}
|
||||
let artwork_width = if self.hub_priority().is_some() {
|
||||
@@ -1107,14 +1125,13 @@ impl FereseShell {
|
||||
|
||||
fn hub_shortcuts(&self, id: window::Id) -> Vec<ItemKind> {
|
||||
let panel = self.panel_for_bar(id);
|
||||
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.)
|
||||
});
|
||||
let available = content_width(
|
||||
panel,
|
||||
self.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.bar == id)
|
||||
.and_then(|entry| entry.size),
|
||||
);
|
||||
let width = panel.morph.expanded_width.min(available);
|
||||
// Built-in tabs, battery and Close keep their space through the morph.
|
||||
let tabs = 5. + f64::from(self.media.snapshot.selected.is_some()) + f64::from(self.activities_configured(id));
|
||||
@@ -1237,6 +1254,146 @@ mod tests {
|
||||
shell
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_regular_margins_do_not_constrain_morph() {
|
||||
let mut app = shell();
|
||||
let bar = app.outputs[0].bar;
|
||||
app.outputs[0].size = Some((1280, 720));
|
||||
let baseline = content_width(&app.config.panels[0], app.outputs[0].size);
|
||||
let mut config = app.config.clone();
|
||||
config.panels[0].style = PanelStyle::Flat;
|
||||
config.panels[0].geometry.side_margins = Some(640);
|
||||
drop(app.apply_config(config.clone()));
|
||||
config.panels[0].style = PanelStyle::Morph;
|
||||
drop(app.apply_config(config.clone()));
|
||||
assert_eq!(content_width(&app.config.panels[0], app.outputs[0].size), baseline);
|
||||
drop(app.open_hub(bar, status_ui::Menu::Calendar));
|
||||
assert!(app.outputs[0].runtime.hub.as_ref().unwrap().borrow().hub.target_size()[0] > 300.);
|
||||
config.panels[0].style = PanelStyle::Flat;
|
||||
drop(app.apply_config(config));
|
||||
assert_eq!(app.config.panels[0].geometry.side_margins, Some(640));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_radius_pins_survive_startup_reload_and_style_switches() {
|
||||
for radius in [129., 4096., f32::MAX] {
|
||||
let mut app = shell();
|
||||
let mut config = app.config.clone();
|
||||
config.panels[0].corner_radius = Some(ferese_panel::CornerRadii::uniform(radius));
|
||||
ferese_panel::validate(&config.panels).unwrap();
|
||||
let mut startup = Runtime::new(&config.panels[0]);
|
||||
for _ in 0..60 {
|
||||
startup.hub.advance(Duration::from_millis(8), false);
|
||||
assert!(
|
||||
startup
|
||||
.hub
|
||||
.sample()
|
||||
.geometry
|
||||
.radii
|
||||
.iter()
|
||||
.all(|r| (*r as f32).is_finite())
|
||||
);
|
||||
}
|
||||
drop(app.apply_config(config.clone()));
|
||||
config.panels[0].style = PanelStyle::Flat;
|
||||
drop(app.apply_config(config.clone()));
|
||||
config.panels[0].style = PanelStyle::Morph;
|
||||
drop(app.apply_config(config));
|
||||
assert!(app.outputs[0].runtime.hub.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_destination_remains_available_for_every_overflow_policy() {
|
||||
let mut app = shell();
|
||||
seed_activities(&mut app, 1);
|
||||
let bar = app.outputs[0].bar;
|
||||
for policy in [
|
||||
ferese_panel::OverflowPolicy::Auto,
|
||||
ferese_panel::OverflowPolicy::Always,
|
||||
ferese_panel::OverflowPolicy::Never,
|
||||
ferese_panel::OverflowPolicy::Always,
|
||||
] {
|
||||
let mut config = app.config.clone();
|
||||
let panel = &mut config.panels[0];
|
||||
for item in [&mut panel.start, &mut panel.center, &mut panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &mut zone.groups)
|
||||
.flat_map(|group| &mut group.items)
|
||||
{
|
||||
if item.kind == ItemKind::Activities {
|
||||
item.overflow = policy;
|
||||
}
|
||||
}
|
||||
drop(app.apply_config(config));
|
||||
assert!(app.activities_configured(bar));
|
||||
assert_eq!(
|
||||
app.compact_activities_configured(bar),
|
||||
policy != ferese_panel::OverflowPolicy::Always
|
||||
);
|
||||
drop(app.open_hub(bar, status_ui::Menu::Activities));
|
||||
assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities)));
|
||||
if policy == ferese_panel::OverflowPolicy::Always {
|
||||
assert!(
|
||||
app.outputs[0]
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.borrow()
|
||||
.activities
|
||||
.selection
|
||||
.visible
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_resize_refits_every_mode_and_caps_the_retained_surface() {
|
||||
for edge in [Edge::Top, Edge::Bottom] {
|
||||
for attached in [false, true] {
|
||||
for mode in [Mode::Idle, Mode::Peek, Mode::Expanded(Content::Calendar)] {
|
||||
for settled in [false, true] {
|
||||
let mut app = shell();
|
||||
let panel = &mut app.config.panels[0];
|
||||
panel.edge = edge;
|
||||
panel.morph.attached = attached;
|
||||
app.outputs[0].runtime.reset(panel, &app.snapshot, Some("test"));
|
||||
let bar = app.outputs[0].bar;
|
||||
drop(app.transition_hub(
|
||||
bar,
|
||||
transition::Event::Present(mode, mode.expanded().then_some(status_ui::Menu::Calendar)),
|
||||
));
|
||||
app.outputs[0]
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.borrow_mut()
|
||||
.hub
|
||||
.advance(Duration::from_millis(16), settled);
|
||||
let mut info = cosmic::cctk::sctk::output::OutputData::new(1).with_output_info(Clone::clone);
|
||||
info.logical_size = Some((220, 170));
|
||||
let output = app.outputs[0].output.clone();
|
||||
drop(app.output_event(cosmic::iced::event::wayland::OutputEvent::InfoUpdate(info), output));
|
||||
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
||||
let available = available_size(&app.config.panels[0], app.outputs[0].size);
|
||||
assert_eq!(runtime.hub.mode(), mode);
|
||||
for (i, limit) in available.into_iter().enumerate() {
|
||||
assert!(f64::from(runtime.allocation[i]) <= limit.ceil());
|
||||
assert!(runtime.hub.target_size()[i] <= limit);
|
||||
}
|
||||
assert!(
|
||||
runtime.hub.target_size()[0] <= content_width(&app.config.panels[0], app.outputs[0].size)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_activities(shell: &mut FereseShell, count: usize) {
|
||||
use ferese_activities::{Data, Owner, Source, Store, Template, Value};
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
|
||||
@@ -119,14 +119,13 @@ impl FereseShell {
|
||||
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.)
|
||||
});
|
||||
let available = super::content_width(
|
||||
panel,
|
||||
self.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.bar == id)
|
||||
.and_then(|entry| entry.size),
|
||||
);
|
||||
select(
|
||||
self.snapshot.workspaces_for_output(Some(output.id)).collect(),
|
||||
output.active_workspace,
|
||||
|
||||
@@ -84,7 +84,14 @@ impl FereseShell {
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let hub = if resized {
|
||||
self.sync_activity_presentations();
|
||||
self.resize_hub(bar)
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
return Task::batch([
|
||||
hub,
|
||||
margin,
|
||||
popup,
|
||||
tooltip,
|
||||
|
||||
Reference in new issue
Block a user