From 91c0806ae0df6fa29ce56a885736ee58369be9ff Mon Sep 17 00:00:00 2001 From: Riccardo Canalicchio Date: Tue, 14 Apr 2026 22:01:02 +0200 Subject: [PATCH 1/3] feat(dock): add dedicated dot_layer for running app indicator Replace the inline draw_content closure with a proper dot_layer in the scene graph, absolute-positioned at the bottom of each app entry. Move display_entries logic from DockView to DockModel with unit tests. --- src/workspaces/dock/model.rs | 115 ++++++++++++++++++++++++++++++++++ src/workspaces/dock/render.rs | 1 - src/workspaces/dock/view.rs | 108 ++++++++++++++++--------------- 3 files changed, 172 insertions(+), 52 deletions(-) diff --git a/src/workspaces/dock/model.rs b/src/workspaces/dock/model.rs index 746c9a56..fd4f384a 100644 --- a/src/workspaces/dock/model.rs +++ b/src/workspaces/dock/model.rs @@ -29,4 +29,119 @@ impl DockModel { ..Default::default() } } + + /// Merge launchers with running apps into a display list. + /// Each entry is `(app, is_running)`. Launchers matched by `match_id` + /// to a running app get `is_running = true`. Running apps not in + /// launchers are appended at the end. + pub fn display_entries(&self) -> Vec<(Application, bool)> { + let mut entries: Vec<(Application, bool)> = self + .launchers + .iter() + .map(|launcher| (launcher.clone(), false)) + .collect(); + + for running in self.running_apps.iter() { + if let Some(entry) = entries + .iter_mut() + .find(|(app, _)| app.match_id == running.match_id) + { + let override_name = entry.0.override_name.clone(); + let mut combined = running.clone(); + if override_name.is_some() { + combined.override_name = override_name; + } + entry.0 = combined; + entry.1 = true; + } else { + entries.push((running.clone(), true)); + } + } + + entries + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_app(id: &str) -> Application { + Application::test_new(id) + } + + #[test] + fn no_running_apps_all_launchers_not_running() { + let model = DockModel { + launchers: vec![make_app("firefox"), make_app("terminal")], + running_apps: vec![], + ..DockModel::new() + }; + let entries = model.display_entries(); + assert_eq!(entries.len(), 2); + assert!(!entries[0].1, "firefox should not be running"); + assert!(!entries[1].1, "terminal should not be running"); + } + + #[test] + fn running_app_matches_launcher() { + let model = DockModel { + launchers: vec![make_app("firefox"), make_app("terminal")], + running_apps: vec![make_app("firefox")], + ..DockModel::new() + }; + let entries = model.display_entries(); + assert_eq!(entries.len(), 2); + assert!(entries[0].1, "firefox should be running"); + assert!(!entries[1].1, "terminal should not be running"); + } + + #[test] + fn running_app_not_in_launchers_appended() { + let model = DockModel { + launchers: vec![make_app("firefox")], + running_apps: vec![make_app("spotify")], + ..DockModel::new() + }; + let entries = model.display_entries(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].0.match_id, "firefox"); + assert!(!entries[0].1); + assert_eq!(entries[1].0.match_id, "spotify"); + assert!(entries[1].1, "spotify should be running"); + } + + #[test] + fn multiple_running_apps_mixed() { + let model = DockModel { + launchers: vec![make_app("firefox"), make_app("terminal"), make_app("files")], + running_apps: vec![make_app("terminal"), make_app("chromium")], + ..DockModel::new() + }; + let entries = model.display_entries(); + assert_eq!(entries.len(), 4); + assert!(!entries[0].1, "firefox not running"); + assert!(entries[1].1, "terminal running"); + assert!(!entries[2].1, "files not running"); + assert_eq!(entries[3].0.match_id, "chromium"); + assert!(entries[3].1, "chromium running"); + } + + #[test] + fn override_name_preserved_from_launcher() { + let mut launcher = make_app("firefox"); + launcher.override_name = Some("My Browser".to_string()); + let model = DockModel { + launchers: vec![launcher], + running_apps: vec![make_app("firefox")], + ..DockModel::new() + }; + let entries = model.display_entries(); + assert_eq!( + entries[0].0.override_name, + Some("My Browser".to_string()), + "override_name from launcher must be preserved" + ); + assert!(entries[0].1); + } } diff --git a/src/workspaces/dock/render.rs b/src/workspaces/dock/render.rs index bbd11027..5c6b7bbf 100644 --- a/src/workspaces/dock/render.rs +++ b/src/workspaces/dock/render.rs @@ -165,7 +165,6 @@ pub fn setup_app_icon( .size(( Size { width: taffy::Dimension::Length(icon_width), - // Outer container keeps height_padding for the running indicator dot. height: taffy::Dimension::Percent(1.0), }, None, diff --git a/src/workspaces/dock/view.rs b/src/workspaces/dock/view.rs index 43f364cd..1d1ccd3e 100644 --- a/src/workspaces/dock/view.rs +++ b/src/workspaces/dock/view.rs @@ -43,6 +43,7 @@ pub(super) struct AppLayerEntry { /// Mirror layer: replicates the icon stack from `AppIconsManager` (icon + badge + progress). pub(super) icon_mirror: Layer, pub(super) label_layer: Layer, + pub(super) dot_layer: Layer, pub(super) running: bool, pub(super) identifier: String, } @@ -131,9 +132,7 @@ impl DockView { /// Calculate dock bar height based on icon size /// Bar height = app container height + top padding + bottom padding fn calculate_bar_height(icon_size: f32, scale: f32) -> f32 { - let padding_top = 0.0 * scale; - let padding_bottom = 0.0 * scale; - icon_size + padding_top + padding_bottom + icon_size + 3.0 * scale } pub fn new(layers_engine: Arc, app_icons_manager: Arc) -> Self { @@ -198,12 +197,13 @@ impl DockView { let dock_apps_container = layers_engine.new_layer(); let _ = view_layer.add_sublayer(&dock_apps_container); + let dot_area_height = 3.0 * draw_scale; let container_tree = LayerTreeBuilder::default() .key("dock_app_container") .pointer_events(false) .size(Size { width: taffy::Dimension::Auto, - height: taffy::Dimension::Length(scaled_icon_size), + height: taffy::Dimension::Length(scaled_icon_size + dot_area_height), }) .layout_style(taffy::Style { display: taffy::Display::Flex, @@ -221,7 +221,7 @@ impl DockView { .unwrap(); dock_apps_container.build_layer_tree(&container_tree); dock_apps_container.set_position( - Point::new(0.0, (initial_bar_height - scaled_icon_size) / 2.0), + Point::new(0.0, 0.0), None, ); let resize_handle = layers_engine.new_layer(); @@ -398,30 +398,7 @@ impl DockView { self.view_layer.set_position((0.0, 0.0), transition) } fn display_entries(&self, state: &DockModel) -> Vec<(Application, bool)> { - let mut entries: Vec<(Application, bool)> = state - .launchers - .iter() - .map(|launcher| (launcher.clone(), false)) - .collect(); - - for running in state.running_apps.iter() { - if let Some(entry) = entries - .iter_mut() - .find(|(app, _)| app.match_id == running.match_id) - { - let override_name = entry.0.override_name.clone(); - let mut combined = running.clone(); - if override_name.is_some() { - combined.override_name = override_name; - } - entry.0 = combined; - entry.1 = true; - } else { - entries.push((running.clone(), true)); - } - } - - entries + state.display_entries() } fn render_elements_layers(&self, available_icon_width: f32, icon_size: f32) { let draw_scale = Config::with(|config| config.screen_scale) as f32 * 0.8; @@ -529,24 +506,7 @@ impl DockView { self.app_icons_manager.update_app(&match_id, &app_copy); entry.running = *running; - let entry_is_running = entry.running; - - // update main layer render function (running indicator dot) - layer.set_draw_content(move |canvas: &skia::Canvas, w: f32, h: f32| { - if entry_is_running { - let color = theme_colors().text_primary.opacity(0.9).c4f(); - let mut paint = layers::skia::Paint::new(color, None); - paint.set_anti_alias(true); - paint.set_style(layers::skia::paint::Style::Fill); - let radius = 2.0 * draw_scale; - canvas.draw_circle( - (w / 2.0, h - radius + 4.0 * draw_scale), - radius, - &paint, - ); - } - layers::skia::Rect::from_xywh(0.0, 0.0, w, h) - }); + entry.dot_layer.set_hidden(!*running); previous_app_layers.retain(|l| l.id() != layer.id()); } @@ -627,17 +587,55 @@ impl DockView { let label_layer = self.layers_engine.new_layer(); setup_label(&label_layer, app_name); + // Running indicator dot — absolute-positioned at bottom center, + // rendered on top of the icon because it's the last child. + let dot_layer = self.layers_engine.new_layer(); + let dot_radius = 2.0 * draw_scale; + let dot_height = 5.0 * draw_scale; + { + use layers::view::BuildLayerTree; + let dot_tree = layers::view::LayerTreeBuilder::default() + .key("_dot") + .layout_style(taffy::Style { + position: taffy::Position::Absolute, + inset: taffy::Rect { + left: taffy::length(0.0), + right: taffy::length(0.0), + top: taffy::LengthPercentageAuto::Auto, + bottom: taffy::length(0.0), + }, + ..Default::default() + }) + .size(Size { + width: taffy::Dimension::Percent(1.0), + height: taffy::Dimension::Length(dot_height), + }) + .pointer_events(false) + .build() + .unwrap(); + dot_layer.build_layer_tree(&dot_tree); + } + dot_layer.set_draw_content(move |canvas: &skia::Canvas, w: f32, h: f32| { + let color = theme_colors().text_primary.opacity(0.9).c4f(); + let mut paint = layers::skia::Paint::new(color, None); + paint.set_anti_alias(true); + canvas.draw_circle((w / 2.0, h / 2.0), dot_radius, &paint); + layers::skia::Rect::from_xywh(0.0, 0.0, w, h) + }); + dot_layer.set_hidden(!*running); + let _ = self.dock_apps_container.add_sublayer(&new_layer); let _ = new_layer.add_sublayer(&icon_scaler); let _ = icon_scaler.add_sublayer(&icon_mirror); - // label is a direct child of new_layer, NOT inside icon_mirror let _ = new_layer.add_sublayer(&label_layer); + let _ = new_layer.add_sublayer(&dot_layer); vac.insert(AppLayerEntry { layer: new_layer.clone(), icon_scaler: icon_scaler.clone(), icon_mirror: icon_mirror.clone(), label_layer: label_layer.clone(), + dot_layer: dot_layer.clone(), running: *running, identifier: app.identifier.clone(), }); @@ -823,6 +821,7 @@ impl DockView { }; if let Some(workspace) = event { + tracing::info!(target: "otto::dock", "dock event: {} running apps in application_list", workspace.application_list.len()); let mut app_set = HashSet::new(); let mut apps: Vec = Vec::new(); @@ -838,6 +837,7 @@ impl DockView { let state = dock.get_state(); + tracing::info!(target: "otto::dock", "dock update_state: {} resolved apps, running={:?}", apps.len(), apps.iter().map(|a| &a.match_id).collect::>()); dock.update_state(&DockModel { running_apps: apps, minimized_windows, @@ -1089,9 +1089,12 @@ impl DockView { scale_override.unwrap_or_else(|| self.dock_config.read().unwrap().genie_scale); let genie_span = self.dock_config.read().unwrap().genie_span; { + let draw_scale = Config::with(|config| config.screen_scale) as f32 * 0.8; + let dot_area_height = 3.0 * draw_scale; + let container_height = icon_size + dot_area_height; let change = self.dock_apps_container.change_size(Size { width: taffy::Dimension::Auto, - height: taffy::Dimension::Length(icon_size), + height: taffy::Dimension::Length(container_height), }); changes.push(change); let position_change = self @@ -1108,8 +1111,12 @@ impl DockView { 1.0 + magnify_function(focus - icon_pos, genie_span) * genie_scale; let focused_icon_size = icon_size * icon_focus as f32; + // Width = focused icon size (for magnification); + // Height = icon + dot area so the running indicator dot + // sits below the icon inside the container. + let app_height = focused_icon_size + dot_area_height; let change = - layer.change_size(Size::points(focused_icon_size, focused_icon_size)); + layer.change_size(Size::points(focused_icon_size, app_height)); changes.push(change); let change = entry @@ -1123,7 +1130,6 @@ impl DockView { let scaler_change_position = entry.icon_scaler.change_position(Point { x: focused_icon_size / 2.0, y: focused_icon_size / 2.0, - // y: focused_icon_size, }); changes.push(scaler_change_position); let scaler_change = entry.icon_scaler.change_scale(Point { From c0dbf01198929ea3ca1ad9a410cd276aedc8e35d Mon Sep 17 00:00:00 2001 From: Riccardo Canalicchio Date: Tue, 14 Apr 2026 23:33:31 +0200 Subject: [PATCH 2/3] style: apply cargo fmt --- src/workspaces/dock/view.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/workspaces/dock/view.rs b/src/workspaces/dock/view.rs index 1d1ccd3e..01d3c916 100644 --- a/src/workspaces/dock/view.rs +++ b/src/workspaces/dock/view.rs @@ -220,10 +220,7 @@ impl DockView { .build() .unwrap(); dock_apps_container.build_layer_tree(&container_tree); - dock_apps_container.set_position( - Point::new(0.0, 0.0), - None, - ); + dock_apps_container.set_position(Point::new(0.0, 0.0), None); let resize_handle = layers_engine.new_layer(); let _ = view_layer.add_sublayer(&resize_handle); @@ -1115,8 +1112,7 @@ impl DockView { // Height = icon + dot area so the running indicator dot // sits below the icon inside the container. let app_height = focused_icon_size + dot_area_height; - let change = - layer.change_size(Size::points(focused_icon_size, app_height)); + let change = layer.change_size(Size::points(focused_icon_size, app_height)); changes.push(change); let change = entry From b7f9f09bc2cfdbe3ad436b84a8c105d7a5b809d2 Mon Sep 17 00:00:00 2001 From: Riccardo Canalicchio Date: Tue, 14 Apr 2026 23:41:23 +0200 Subject: [PATCH 3/3] test: add Application::test_new helper for dock model tests --- src/workspaces/apps_info.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/workspaces/apps_info.rs b/src/workspaces/apps_info.rs index 51398968..c0451301 100644 --- a/src/workspaces/apps_info.rs +++ b/src/workspaces/apps_info.rs @@ -21,6 +21,20 @@ pub struct Application { } impl Application { + #[cfg(test)] + pub fn test_new(id: &str) -> Self { + Self { + identifier: id.to_string(), + match_id: id.to_string(), + icon_path: None, + icon: None, + picture: None, + override_name: None, + desktop_file_id: None, + app_info: None, + } + } + pub fn desktop_name(&self) -> Option { if let Some(name) = &self.override_name { return Some(name.clone());