diff --git a/Cargo.lock b/Cargo.lock index 1309b06..bd63bcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,7 +705,7 @@ dependencies = [ [[package]] name = "build_helpers" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "cfg_aliases", ] @@ -1124,7 +1124,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "1.0.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1145,7 +1145,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "1.0.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "quote", "syn 2.0.119", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "1.0.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "almost", "configparser", @@ -2740,7 +2740,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "build_helpers", "dnd", @@ -2762,7 +2762,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "accesskit", "accesskit_winit", @@ -2771,7 +2771,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "bitflags 2.13.2", "build_helpers", @@ -2799,7 +2799,7 @@ dependencies = [ [[package]] name = "iced_debug" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "iced_core", "iced_futures", @@ -2809,7 +2809,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "futures", "iced_core", @@ -2823,7 +2823,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "bitflags 2.13.2", "bytemuck", @@ -2844,7 +2844,7 @@ dependencies = [ [[package]] name = "iced_program" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "iced_graphics", "iced_runtime", @@ -2853,7 +2853,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -2865,7 +2865,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "build_helpers", "bytes", @@ -2882,7 +2882,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "bytemuck", "cosmic-text", @@ -2899,7 +2899,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "as-raw-xcb-connection", "bitflags 2.13.2", @@ -2931,7 +2931,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.2" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "build_helpers", "cosmic-client-toolkit", @@ -2951,7 +2951,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "build_helpers", "cosmic-client-toolkit", @@ -3488,7 +3488,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libcosmic" version = "1.0.0" -source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.1#891a4e821687543f5c0debfa385b0b16cf7d9b2c" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.2#ae75e94131417d33fb91054e4f20b864b5f601fb" dependencies = [ "apply", "ashpd 0.12.3", diff --git a/Cargo.toml b/Cargo.toml index 8f918e9..0869e3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ bitflags = "2.9.4" calloop = { version = "0.14.4", features = ["signals"] } jiff = "0.2.37" libc = "0.2.176" -libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1", default-features = false, features = ["multi-window", "tokio", "wayland"] } +libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.2", default-features = false, features = ["multi-window", "tokio", "wayland"] } smithay = { version = "0.7.0", default-features = false, features = [ "backend_drm", "backend_gbm", diff --git a/crates/ferese-config/src/panel/layout.rs b/crates/ferese-config/src/panel/layout.rs index 1ef5f50..7edb39d 100644 --- a/crates/ferese-config/src/panel/layout.rs +++ b/crates/ferese-config/src/panel/layout.rs @@ -29,30 +29,34 @@ pub struct Resolution { } fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap) -> f32 { - let mut groups = Vec::new(); + let mut width = 0.0; + let mut group_count: usize = 0; for group in &zone.groups { - let mut widths = Vec::new(); + let mut content_width = 0.0; + let mut occupied = false; for item in &group.items { if let Some(Placement::Visible { width, .. }) = items.get(&item.id) && *width > 0.0 { - if !widths.is_empty() { - widths.push(item.gap_before.unwrap_or(group.spacing)); + if occupied { + content_width += item.gap_before.unwrap_or(group.spacing); } - widths.push(*width); + content_width += *width; + occupied = true; } } - if !widths.is_empty() { + if occupied { let padding = 2.0 * f32::from(group.padding[1]); let island = if panel.resolved_surface(group) == super::GroupSurface::Island { 2.0 * group.island_padding } else { 0.0 }; - groups.push(widths.iter().sum::() + padding + island); + width += content_width + padding + island; + group_count += 1; } } - groups.iter().sum::() + zone.spacing * groups.len().saturating_sub(1) as f32 + width + zone.spacing * group_count.saturating_sub(1) as f32 } impl Resolution { @@ -271,19 +275,14 @@ pub fn resolve_with_gap( let mut high = maximum; for _ in 0..20 { let width = (low + high) * 0.5; - result - .items - .insert(measured.id.clone(), Placement::Visible { representation, width }); + *result.items.get_mut(&measured.id).unwrap() = Placement::Visible { representation, width }; result.update_widths(panel, overflow_width); if fits(&result) { low = width } else { high = width } } - result.items.insert( - measured.id.clone(), - Placement::Visible { - representation, - width: low, - }, - ); + *result.items.get_mut(&measured.id).unwrap() = Placement::Visible { + representation, + width: low, + }; } result .overflow @@ -296,6 +295,55 @@ pub fn resolve_with_gap( mod tests { use super::*; + #[test] + fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() { + use super::super::{Group, GroupSurface, Item, ItemKind}; + let mut panel = Panel::default(); + let mut first = Item::new("first", ItemKind::Overview); + first.gap_before = Some(99.); + let hidden = Item::new("hidden", ItemKind::Network); + let mut last = Item::new("last", ItemKind::Clock); + last.gap_before = Some(7.); + let mut group = Group::new("content", vec![first, hidden, last], [0, 3], 5.); + group.surface = Some(GroupSurface::Island); + group.island_padding = 4.; + let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.); + let trailing = Group::new( + "trailing", + vec![Item::new("trailing", ItemKind::Battery { percentage: false })], + [0, 2], + 5., + ); + panel.start.groups = vec![empty.clone(), group, empty, trailing]; + panel.start.spacing = 11.; + let items = [ + ( + ItemId("first".into()), + Placement::Visible { + representation: Representation::Icon, + width: 20., + }, + ), + (ItemId("hidden".into()), Placement::Overflow), + ( + ItemId("last".into()), + Placement::Visible { + representation: Representation::Wide, + width: 30., + }, + ), + ( + ItemId("trailing".into()), + Placement::Visible { + representation: Representation::Icon, + width: 10., + }, + ), + ] + .into(); + assert_eq!(zone_width(&panel, &panel.start, &items), 96.); + } + #[test] fn a_center_only_item_keeps_its_preferred_size_without_side_gaps() { use super::super::{Group, Item, ItemKind}; diff --git a/crates/ferese-theme/Cargo.toml b/crates/ferese-theme/Cargo.toml index 030919f..bad5f50 100644 --- a/crates/ferese-theme/Cargo.toml +++ b/crates/ferese-theme/Cargo.toml @@ -11,7 +11,7 @@ ferese-theme-model = { path = "../ferese-theme-model" } ferese-config = { path = "../ferese-config" } libcosmic = { workspace = true, features = ["a11y"] } -iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" } +iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.2" } [dev-dependencies] tokio = { version = "1", features = ["rt"] } diff --git a/crates/ferese-theme/src/icons.rs b/crates/ferese-theme/src/icons.rs index 313c0e3..e80da15 100644 --- a/crates/ferese-theme/src/icons.rs +++ b/crates/ferese-theme/src/icons.rs @@ -76,6 +76,69 @@ pub fn symbolic(source: &'static [u8], size: u16) -> icon::Icon { icon::from_svg_bytes(source).symbolic(true).icon().size(size) } +const TINT_CACHE_ENTRIES: usize = 64; +const TINT_CACHE_BYTES: usize = 128 * 1024; + +struct TintedIcon { + source: &'static [u8], + colors: [u32; 8], + handle: icon::Handle, + bytes: usize, +} + +#[derive(Default)] +struct TintCache { + entries: std::collections::VecDeque, + bytes: usize, +} + +impl TintCache { + fn get(&mut self, source: &'static [u8], foreground: Color, accent: Color) -> icon::Handle { + let colors = [ + foreground.r, + foreground.g, + foreground.b, + foreground.a, + accent.r, + accent.g, + accent.b, + accent.a, + ] + .map(f32::to_bits); + if let Some(index) = self + .entries + .iter() + .position(|entry| std::ptr::eq(entry.source, source) && entry.colors == colors) + { + let entry = self.entries.remove(index).unwrap(); + let handle = entry.handle.clone(); + self.entries.push_back(entry); + return handle; + } + let svg = tint_svg(source, foreground, accent); + let bytes = svg.capacity(); + let handle = icon::from_svg_bytes(svg).symbolic(false); + if bytes <= TINT_CACHE_BYTES { + while self.entries.len() >= TINT_CACHE_ENTRIES || self.bytes + bytes > TINT_CACHE_BYTES { + self.bytes -= self.entries.pop_front().unwrap().bytes; + } + self.bytes += bytes; + self.entries.push_back(TintedIcon { + source, + colors, + handle: handle.clone(), + bytes, + }); + } + handle + } +} + +thread_local! { + // Retain shared artwork across view rebuilds, with bounded storage as colors change. + static TINT_CACHE: std::cell::RefCell = std::cell::RefCell::new(TintCache::default()); +} + pub fn accented(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon { let svg = std::str::from_utf8(source).expect("embedded SVG must be UTF-8"); // Battery canvases are wider; reserve that space instead of stretching @@ -85,8 +148,8 @@ pub fn accented(source: &'static [u8], size: u16, foreground: Color, accent: Col } else { 1.0 }; - icon::from_svg_bytes(tint_svg(source, foreground, accent)) - .symbolic(false) + TINT_CACHE + .with(|cache| cache.borrow_mut().get(source, foreground, accent)) .icon() .size(size) .width(Length::Fixed(f32::from(size) * aspect)) @@ -131,6 +194,45 @@ pub fn outline(path: &str, tint: Color, size: u16) -> icon::Icon { #[cfg(test)] mod tests { use cosmic::iced::Color; + #[test] + fn tinted_icons_share_artwork_and_distinguish_accent_and_alpha() { + let mut cache = super::TintCache::default(); + let foreground = Color::from_rgb8(205, 214, 244); + let accent = Color::from_rgb8(203, 166, 247); + let bytes = |handle: &cosmic::widget::icon::Handle| { + let cosmic::widget::icon::Data::Svg(svg) = &handle.data else { + panic!("expected SVG"); + }; + let cosmic::iced::advanced::svg::Data::Bytes(bytes) = svg.data() else { + panic!("expected SVG bytes"); + }; + bytes.as_ptr() + }; + let first = cache.get(super::BATTERY_50, foreground, accent); + let second = cache.get(super::BATTERY_50, foreground, accent); + assert_eq!(bytes(&first), bytes(&second), "rebuilds must reuse the same allocation"); + let changed_accent = cache.get(super::BATTERY_50, foreground, Color::from_rgb8(42, 90, 180)); + assert_ne!(bytes(&first), bytes(&changed_accent)); + let translucent = cache.get(super::BATTERY_50, Color { a: 0.5, ..foreground }, accent); + assert_ne!(bytes(&first), bytes(&translucent)); + assert_eq!(cache.entries.len(), 3); + } + + #[test] + fn icon_tint_cache_evicts_old_colors_without_growing() { + let mut cache = super::TintCache::default(); + for value in 0..512 { + let _ = cache.get( + super::FERESE, + Color::from_rgba(0.5, 0.5, 0.5, value as f32 / 512.), + Color::BLACK, + ); + assert!(cache.entries.len() <= super::TINT_CACHE_ENTRIES); + assert!(cache.bytes <= super::TINT_CACHE_BYTES); + } + assert_eq!(cache.entries.back().unwrap().colors[3], (511_f32 / 512.).to_bits()); + } + #[test] fn icons_use_theme_accent_and_preserve_battery_status_colors() { for (source, accent) in [ diff --git a/crates/ferese-theme/src/panel.rs b/crates/ferese-theme/src/panel.rs index 5918793..3866fd5 100644 --- a/crates/ferese-theme/src/panel.rs +++ b/crates/ferese-theme/src/panel.rs @@ -139,16 +139,19 @@ impl Widget for Adaptive<'_, M> { .find(|measurement| measurement.id.0 == "_overflow") .map_or(0.0, |measurement| measurement.alternatives[0].1); let trigger = trigger.min((limits.max().width - self.overflow_width).max(0.0)); - self.resolution = resolve_with_gap( + let mut resolution = resolve_with_gap( &self.panel, &measurements, limits.max().width, (self.overflow_width + trigger).min(limits.max().width), self.zone_gap, ); - self.resolution.overflow_trigger_width = trigger; - self.content = (self.build)(&self.resolution); - tree.children[0].diff(self.content.as_widget_mut()); + resolution.overflow_trigger_width = trigger; + if self.resolution != resolution { + self.resolution = resolution; + self.content = (self.build)(&self.resolution); + tree.children[0].diff(self.content.as_widget_mut()); + } self.content .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) @@ -383,12 +386,15 @@ mod tests { })) .collect(); let id = id.clone(); + let builds = std::cell::Cell::new(0); + let build_count = &builds; let mut view = frame( Cow::Borrowed(&panel), samples, 0., Resolution::default(), move |resolution| { + build_count.set(build_count.get() + 1); let width = match resolution.items.get(&id) { Some(Placement::Visible { width, .. }) => *width, _ => resolution.overflow_trigger_width, @@ -406,6 +412,10 @@ mod tests { let viewport = Rectangle::new(Point::ORIGIN, Size::new(width, 28.)); let limits = layout::Limits::new(Size::ZERO, viewport.size()); let node = view.as_widget_mut().layout(tree, &renderer, &limits); + let built = builds.get(); + assert_eq!(built, 2, "initial measurement must replace the unallocated content"); + view.as_widget_mut().layout(tree, &renderer, &limits); + assert_eq!(builds.get(), built, "unchanged allocation must reuse its controls"); let mut messages = Vec::new(); for _ in 0..4 { view.as_widget_mut().update( diff --git a/scripts/tests/test_shell_motion_isolated.py b/scripts/tests/test_shell_motion_isolated.py index 3d7773c..97de966 100644 --- a/scripts/tests/test_shell_motion_isolated.py +++ b/scripts/tests/test_shell_motion_isolated.py @@ -89,10 +89,15 @@ class ShellMotionTest(unittest.TestCase): env=ipc_environment(env), text=True, capture_output=True, timeout=5, check=check) def layer_surface(namespace): - matches = list(re.finditer(r'get_layer_surface\([^\n]*?wl_surface[@#](\d+)[^\n]*"' + namespace + '"', log_path.read_text())) + matches = layer_surfaces(namespace) # Wayland object IDs can be reused after destruction. Restrict # assertions to this surface's lifetime in the trace. - return (matches[-1].group(1), matches[-1].start()) if matches else None + return matches[-1] if matches else None + + def layer_surfaces(namespace): + return [(match.group(1), match.start()) for match in re.finditer( + r'get_layer_surface\([^\n]*?wl_surface[@#](\d+)[^\n]*"' + namespace + '"', + log_path.read_text())] def frames(surface): identity, start = surface @@ -200,6 +205,48 @@ class ShellMotionTest(unittest.TestCase): self.assertNotIn("panicked at", log_path.read_text()) print(f"Modal: {frames(modal)} callbacks; interrupted opening closed and destroyed", flush=True) + # A toast can resize or start closing before its previous + # software buffer has caught up with the new viewport. + for index in range(40): + reply = dbus("Notify", "Ferese motion test", "0", "", "Rapid toast", + "Short body", "[]", "{}", "0").stdout + notice = re.search(r'uint32 (\d+)', reply).group(1) + if index % 2 == 0: + dbus("Notify", "Ferese motion test", notice, "", "Updated toast", + "A longer notification body that wraps over several lines. " * 6, + "[]", "{}", "0") + dbus("CloseNotification", notice) + time.sleep(.015) + time.sleep(.5) + self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) + self.assertEqual(dbus("GetServerInformation", check=False).returncode, 0) + self.assertNotIn("panicked at", log_path.read_text()) + print("Toast: 40 rapid open/update/close cycles completed", flush=True) + + for _ in range(40): + subprocess.run([str(ctl), "toggle-keybinding-guide"], + env=ipc_environment(env), check=True, capture_output=True) + time.sleep(.03) + subprocess.run([str(ctl), "toggle-keybinding-guide"], + env=ipc_environment(env), check=True, capture_output=True) + time.sleep(.06) + # A final toggle may leave the owned modal closing. Let + # that motion finish before deciding whether a live modal + # needs another toggle; toggling during closing reopens it. + deadline = time.monotonic() + 5 + while True: + remaining = [surface for surface in layer_surfaces("ferese-system-modal") + if not destroyed(surface)] + if not remaining or time.monotonic() >= deadline: + break + time.sleep(.05) + self.assertLessEqual(len(remaining), 1, "cancelled modals retained native surfaces") + if remaining: + subprocess.run([str(ctl), "toggle-keybinding-guide"], + env=ipc_environment(env), check=True, capture_output=True) + wait_for(lambda: destroyed(remaining[0])) + print("Modal: rapid toggles released all native surfaces", flush=True) + if bar_layout == "islands": for layout_name, expected_count in [("continuous", 1), ("islands", None)]: config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name)) diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml index 8fc098c..340f8fd 100644 --- a/shell/ferese-settings/Cargo.toml +++ b/shell/ferese-settings/Cargo.toml @@ -10,7 +10,7 @@ ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } -iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" } +iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.2" } ferese-ipc = { path = "../../crates/ferese-ipc" } serde_json.workspace = true tempfile.workspace = true diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index bc74821..1a63301 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -18,7 +18,7 @@ libc.workspace = true # tiny-skia to avoid a second GPU initialization during desktop startup. libcosmic = { workspace = true, features = ["wgpu", "a11y"] } freedesktop-desktop-entry = { version = "0.8.3", default-features = false } -iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.1" } +iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.2" } serde.workspace = true serde_json.workspace = true tempfile.workspace = true diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index c91a2f9..f5393a0 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -131,16 +131,13 @@ impl FereseShell { .unwrap_or_default(), move |resolution| { let zone = |zone: &Zone| { - let mut zone = zone.clone(); - for group in &mut zone.groups { - group.items.retain(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0)); - } view_zone( panel, - &zone, + 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) else { @@ -171,6 +168,7 @@ impl FereseShell { availability, shell_theme, compositor_material, + |_| true, |item, surface| { self.view_panel_item( id, @@ -566,6 +564,7 @@ fn view_zone<'a>( availability: Availability, shell_theme: ShellTheme, compositor_material: bool, + visible: impl Fn(&Item) -> bool, render: impl Fn(&Item, GroupSurface) -> (Element<'a, cosmic::Action>, bool), ) -> Element<'a, cosmic::Action> { let bar = BarMetrics::from(panel.geometry); @@ -577,7 +576,11 @@ fn view_zone<'a>( 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().filter(|item| item.available(availability)) { + for item in group + .items + .iter() + .filter(|item| item.available(availability) && visible(item)) + { if count > 0 { controls = controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing))); @@ -1006,24 +1009,32 @@ mod island_tests { Some("tiny-skia"), )) .unwrap(); - let view = view_zone(panel, zone, availability, theme, true, |item, _| { - let width = match item.kind { - ItemKind::Overview => 28., - ItemKind::Workspaces { .. } => 72., - ItemKind::FocusedWindow => 80., - ItemKind::Clock => 60., - _ => 20., - }; - let height = if matches!(item.kind, ItemKind::FocusedWindow) { - 24. - } else { - 20. - }; - ( - container(cosmic::iced::widget::Space::new().width(width).height(height)).into(), - !matches!(item.kind, ItemKind::FocusedWindow) || title_content, - ) - }); + let view = view_zone( + panel, + zone, + availability, + theme, + true, + |_| true, + |item, _| { + let width = match item.kind { + ItemKind::Overview => 28., + ItemKind::Workspaces { .. } => 72., + ItemKind::FocusedWindow => 80., + ItemKind::Clock => 60., + _ => 20., + }; + let height = if matches!(item.kind, ItemKind::FocusedWindow) { + 24. + } else { + 20. + }; + ( + container(cosmic::iced::widget::Space::new().width(width).height(height)).into(), + !matches!(item.kind, ItemKind::FocusedWindow) || title_content, + ) + }, + ); let mut view: Element<'_, cosmic::Action> = container(view) .height(panel.geometry.height) .align_y(alignment::Vertical::Center) diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 5a852ab..3466ba0 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1378,6 +1378,36 @@ mod tests { } } + #[test] + fn late_open_checks_preserve_owned_desktop_widgets_and_tooltips() { + let mut shell = shell_with_measured_panel(); + let wallpaper = window::Id::unique(); + let clock = window::Id::unique(); + let note = window::Id::unique(); + let tooltip = window::Id::unique(); + shell.outputs[0].wallpaper = Some(wallpaper); + shell.outputs[0].clock = Some(clock); + shell.outputs[0].notes.push(("note".into(), note)); + shell.workspace_ui.tooltip = Some(tooltip); + for id in [wallpaper, clock, note, tooltip] { + assert!(shell.owns_surface(id)); + assert_eq!( + shell + .handle_event( + Event::Window(window::Event::Opened { + position: None, + size: cosmic::iced::Size::new(100., 100.), + }), + id, + ) + .units(), + 0, + "an owned surface must not be destroyed on opening" + ); + } + assert!(!shell.owns_surface(window::Id::unique())); + } + #[test] fn an_empty_measured_panel_is_not_an_unmeasured_panel() { let mut shell = shell_with_measured_panel(); diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index d754c93..40a960e 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -203,6 +203,22 @@ impl FereseShell { ]) } + pub(super) fn owns_surface(&self, id: window::Id) -> bool { + self.outputs.iter().any(|entry| { + entry.bar == id + || entry.wallpaper == Some(id) + || entry.clock == Some(id) + || entry.notes.iter().any(|(_, surface)| *surface == id) + }) || self.menu.as_ref().is_some_and(|menu| menu.id == id) + || self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) + || self + .notification_surface + .as_ref() + .is_some_and(|surface| surface.id == id) + || self.workspace_ui.tooltip == Some(id) + || self.note_drag.as_ref().is_some_and(|drag| drag.overlay == id) + } + pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task { if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorLeft)) { return self.dismiss_workspace_tooltip_for_bar(id); @@ -338,6 +354,15 @@ impl FereseShell { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => { self.output_event(event, output) } + 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 + // owner has gone away. IDs are never reused by the shell. + Task::batch([ + destroy_layer_surface(id), + cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(id))), + ]) + } Event::Window(window::Event::Opened { .. }) if self.outputs.iter().any(|entry| entry.bar == id) || self.menu.as_ref().is_some_and(|menu| menu.id == id) diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index 58c5cc7..2b26452 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -683,6 +683,33 @@ fn modal_focus_operation(backwards: bool) -> Box>>, app_ids: Vec(None) + .entries::<&str>(Some(&[])) .filter(|entry| seen.insert(entry.appid.clone())) .filter(|entry| !entry.hidden()) .collect(), @@ -402,7 +402,14 @@ impl WorkspaceUi { } fn load_icons(&mut self, snapshot: &ShellSnapshot, needed: bool) -> Task { - if !needed || self.icons_loading { + if !needed { + self.entries = None; + self.icons = HashMap::new(); + return Task::none(); + } + self.icons + .retain(|id, _| snapshot.windows.iter().any(|window| &window.app_id == id)); + if self.icons_loading { return Task::none(); } let app_ids: Vec<_> = snapshot @@ -517,6 +524,28 @@ mod tests { assert!(ui.icons.is_empty()); } + #[test] + fn workspace_icon_storage_follows_running_apps_and_selected_style() { + let mut ui = WorkspaceUi::default(); + let mut snapshot = snapshot(1); + add_window(&mut snapshot, "running"); + ui.entries = Some(Arc::new(Vec::new())); + for id in ["running", "closed"] { + ui.icons + .insert(id.into(), icon::from_svg_bytes(ferese_theme::icons::APPLICATION)); + } + assert_eq!(ui.load_icons(&snapshot, true).units(), 0); + assert!(ui.icons.contains_key("running")); + assert!(!ui.icons.contains_key("closed")); + assert!(ui.entries.is_some()); + ui.icons_loading = true; + assert_eq!(ui.load_icons(&snapshot, false).units(), 0); + assert!(ui.icons.is_empty()); + assert_eq!(ui.icons.capacity(), 0); + assert!(ui.entries.is_none()); + assert!(ui.icons_loading, "an in-flight worker still owns its discovery request"); + } + #[test] fn icon_publication_preserves_existing_icons_and_queues_new_windows() { let mut shell = crate::tests::shell_with_measured_panel();