From 086f352ea3b809223bb8ba4a883dc44ef0e83b8d Mon Sep 17 00:00:00 2001 From: Kiryl Shutsemau Date: Mon, 11 May 2026 16:11:38 +0100 Subject: [PATCH 1/3] refactor(compositor): box state inside CompositorService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompositorService used to embed CompositorState by value, so every ServiceEvent variant carried ~280 bytes. Modules that included ServiceEvent in their own Message enum (e.g. window_title) had to box it themselves to keep clippy's large_enum_variant lint happy — boilerplate scattered across consumers for a problem that lives at the source. Move the indirection into CompositorService itself: state is now Box. The handle stays ~16 bytes, ServiceEvent becomes small, and consumers don't need to box anything. window_title sheds its Box> as a result. App::Message is a top-level dispatcher whose variant size disparity is intrinsic to its role (network/bluetooth service messages are naturally big; unit variants are tiny). Box-ing every "big" sub- message there would just push verbosity onto call sites without runtime benefit, so it gets a documented allow(clippy::large_enum_variant). --- src/app.rs | 5 +++++ src/modules/window_title.rs | 6 +++--- src/services/compositor/mod.rs | 4 ++-- src/services/compositor/types.rs | 5 ++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6e44e8bf4..74949c7ec 100644 --- a/src/app.rs +++ b/src/app.rs @@ -74,6 +74,11 @@ pub struct App { pub visible: bool, } +// App::Message is an aggregator enum dispatching to many sub-modules. Sub- +// messages naturally vary in size (network/bluetooth service events are +// large; transient unit variants are tiny). Boxing every "big" variant would +// just shift verbosity to call sites without meaningful runtime benefit. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum Message { ConfigChanged(Box), diff --git a/src/modules/window_title.rs b/src/modules/window_title.rs index 147bd529a..64ee5b323 100644 --- a/src/modules/window_title.rs +++ b/src/modules/window_title.rs @@ -11,7 +11,7 @@ use iced::{ #[derive(Debug, Clone)] pub enum Message { - ServiceEvent(Box>), + ServiceEvent(ServiceEvent), ConfigReloaded(WindowTitleConfig), } @@ -32,7 +32,7 @@ impl WindowTitle { pub fn update(&mut self, message: Message) { match message { - Message::ServiceEvent(event) => match *event { + Message::ServiceEvent(event) => match event { ServiceEvent::Init(service) => { self.service = Some(service); self.recalculate_value(); @@ -103,6 +103,6 @@ impl WindowTitle { } pub fn subscription(&self) -> Subscription { - CompositorService::subscribe().map(|event| Message::ServiceEvent(Box::new(event))) + CompositorService::subscribe().map(Message::ServiceEvent) } } diff --git a/src/services/compositor/mod.rs b/src/services/compositor/mod.rs index 28c143077..dbd5e9db2 100644 --- a/src/services/compositor/mod.rs +++ b/src/services/compositor/mod.rs @@ -79,7 +79,7 @@ impl ReadOnlyService for CompositorService { fn update(&mut self, event: Self::UpdateEvent) { match event { CompositorEvent::StateChanged(new_state) => { - self.state = *new_state; + self.state = new_state; } CompositorEvent::ActionPerformed => {} } @@ -94,7 +94,7 @@ impl ReadOnlyService for CompositorService { // - assumes detect_backend is cheap if let Some(backend) = detect_backend() { let empty_init = CompositorService { - state: CompositorState::default(), + state: Box::new(CompositorState::default()), backend, }; if output.send(ServiceEvent::Init(empty_init)).await.is_err() { diff --git a/src/services/compositor/types.rs b/src/services/compositor/types.rs index 498bb7ca5..0652a594b 100644 --- a/src/services/compositor/types.rs +++ b/src/services/compositor/types.rs @@ -88,7 +88,10 @@ pub enum CompositorChoice { #[derive(Debug, Clone)] pub struct CompositorService { - pub state: CompositorState, + /// State is boxed so `ServiceEvent` stays small — + /// otherwise `Message` enums embedding it trip clippy's + /// `large_enum_variant` lint. + pub state: Box, pub backend: CompositorChoice, } From 4927aaf917359bbc269c214ca83075bcad52d6bc Mon Sep 17 00:00:00 2001 From: Kiryl Shutsemau Date: Thu, 28 May 2026 21:30:14 +0100 Subject: [PATCH 2/3] refactor(compositor): track per-window state in compositor abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a per-window record (CompositorWindow) carrying workspace membership, focus/floating/urgent flags, and tile geometry (grid index and size), plus a window list in the generic compositor state. The Niri backend populates it from its event stream; the Hyprland backend leaves the list empty for now (placeholder), so window-level features are Niri-only until it's filled in. No consumer yet — the new state intentionally warns as unread until the minimap module is added. --- src/services/compositor/hyprland.rs | 1 + src/services/compositor/niri.rs | 30 +++++++++++++++++++++++++---- src/services/compositor/types.rs | 18 +++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/services/compositor/hyprland.rs b/src/services/compositor/hyprland.rs index 2372e1a20..6e4ac8395 100644 --- a/src/services/compositor/hyprland.rs +++ b/src/services/compositor/hyprland.rs @@ -258,6 +258,7 @@ fn fetch_full_state(internal_state: &HyprInternalState) -> Result CompositorState { .collect(); // INFO: this is how niri sorts the outpus internally (niri msg outputs - in client.rs) - let outputs = output_to_active_ws + let outputs_sorted = output_to_active_ws .keys() .sorted_unstable() .collect::>(); @@ -194,7 +194,7 @@ fn map_state(niri: &EventStreamState) -> CompositorState { monitor: w.output.clone().unwrap_or_default(), // niri does not have an output index monitor_id: w.output.as_ref().map(|wo| { - outputs + outputs_sorted .iter() .position(|o| *o == wo) .map_or(-1, |i| i as i32) as i128 @@ -222,7 +222,7 @@ fn map_state(niri: &EventStreamState) -> CompositorState { } } - let monitors: Vec = outputs + let monitors: Vec = outputs_sorted .iter() .enumerate() .map(|(i, name)| CompositorMonitor { @@ -253,6 +253,27 @@ fn map_state(niri: &EventStreamState) -> CompositorState { }) }); + let windows: Vec = niri + .windows + .windows + .values() + .map(|w| CompositorWindow { + id: w.id, + workspace_id: w + .workspace_id + .and_then(|wid| niri.workspaces.workspaces.get(&wid).map(|ws| ws.id as i32)), + is_focused: w.is_focused, + is_floating: w.is_floating, + is_urgent: w.is_urgent, + tile_position: w + .layout + .pos_in_scrolling_layout + .map(|(c, r)| (c as u32, r as u32)), + tile_width: w.layout.tile_size.0 as f32, + tile_height: w.layout.tile_size.1 as f32, + }) + .collect(); + let keyboard_layout = niri.keyboard_layouts.keyboard_layouts.as_ref().map_or_else( || "Unknown".to_string(), |k| { @@ -268,6 +289,7 @@ fn map_state(niri: &EventStreamState) -> CompositorState { monitors, active_workspace_id, active_window, + windows, keyboard_layout, submap: None, } diff --git a/src/services/compositor/types.rs b/src/services/compositor/types.rs index 0652a594b..3e68dc48b 100644 --- a/src/services/compositor/types.rs +++ b/src/services/compositor/types.rs @@ -70,12 +70,30 @@ impl ActiveWindow { } } +#[derive(Debug, Clone, PartialEq)] +pub struct CompositorWindow { + pub id: u64, + pub workspace_id: Option, + pub is_focused: bool, + pub is_floating: bool, + pub is_urgent: bool, + /// Position in a tile grid (column, row), if the compositor lays this + /// window out in a grid. Only relative ordering is meaningful; the + /// origin index is compositor-defined. + pub tile_position: Option<(u32, u32)>, + /// Tile dimensions in compositor pixels (including decorations), used + /// for proportional minimap sizing. + pub tile_width: f32, + pub tile_height: f32, +} + #[derive(Debug, Clone, Default)] pub struct CompositorState { pub workspaces: Vec, pub monitors: Vec, pub active_workspace_id: Option, pub active_window: Option, + pub windows: Vec, pub keyboard_layout: String, pub submap: Option, } From 9ccc272631e2079cd47b9a9dc012bf0502c9fd1c Mon Sep 17 00:00:00 2001 From: Kiryl Shutsemau Date: Thu, 28 May 2026 21:30:57 +0100 Subject: [PATCH 3/3] feat(minimap): add workspace window minimap module A compact visualisation of the active workspace's tiled window layout in the status bar. Tiled windows are laid out by their column/row index with proportional sizes and scaled to fit; the focused window is highlighted and urgent windows use the danger color. Floating windows have no grid position, so they aren't rendered; showing them is something to explore later, once niri exposes enough information to place them. Hyprland doesn't expose per-window layout, so the widget stays hidden there. Adds a `Minimap` module name to config; opt-in via the user's module order list. --- src/app.rs | 8 + src/config.rs | 2 + src/modules/minimap.rs | 257 ++++++++++++++++++++ src/modules/mod.rs | 6 + src/services/compositor/mod.rs | 1 + website/docs/configuration/modules/index.md | 9 + 6 files changed, 283 insertions(+) create mode 100644 src/modules/minimap.rs diff --git a/src/app.rs b/src/app.rs index 74949c7ec..66c674476 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::{ keyboard_layout::KeyboardLayout, keyboard_submap::KeyboardSubmap, media_player::MediaPlayer, + minimap::Minimap, notifications::Notifications, privacy::Privacy, settings::{self, Settings, audio}, @@ -61,6 +62,7 @@ pub struct App { pub updates: Option, pub workspaces: Workspaces, pub window_title: WindowTitle, + pub minimap: Minimap, pub system_info: SystemInfo, pub keyboard_layout: KeyboardLayout, pub keyboard_submap: KeyboardSubmap, @@ -88,6 +90,7 @@ pub enum Message { Updates(modules::updates::Message), Workspaces(modules::workspaces::Message), WindowTitle(modules::window_title::Message), + Minimap(modules::minimap::Message), SystemInfo(modules::system_info::Message), KeyboardLayout(modules::keyboard_layout::Message), KeyboardSubmap(modules::keyboard_submap::Message), @@ -149,6 +152,7 @@ impl App { updates: config.updates.map(Updates::new), workspaces: Workspaces::new(config.workspaces), window_title: WindowTitle::new(config.window_title), + minimap: Minimap::new(), system_info: SystemInfo::new(config.system_info), keyboard_layout: KeyboardLayout::new(config.keyboard_layout), keyboard_submap: KeyboardSubmap::default(), @@ -423,6 +427,10 @@ impl App { self.window_title.update(msg); Task::none() } + Message::Minimap(msg) => { + self.minimap.update(msg); + Task::none() + } Message::SystemInfo(msg) => { self.system_info.update(msg); Task::none() diff --git a/src/config.rs b/src/config.rs index c37a83c7e..fe509a56a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1007,6 +1007,7 @@ pub enum ModuleName { Updates, Workspaces, WindowTitle, + Minimap, SystemInfo, KeyboardLayout, KeyboardSubmap, @@ -1038,6 +1039,7 @@ impl<'de> Deserialize<'de> for ModuleName { "Updates" => ModuleName::Updates, "Workspaces" => ModuleName::Workspaces, "WindowTitle" => ModuleName::WindowTitle, + "Minimap" => ModuleName::Minimap, "SystemInfo" => ModuleName::SystemInfo, "KeyboardLayout" => ModuleName::KeyboardLayout, "KeyboardSubmap" => ModuleName::KeyboardSubmap, diff --git a/src/modules/minimap.rs b/src/modules/minimap.rs new file mode 100644 index 000000000..c50949d58 --- /dev/null +++ b/src/modules/minimap.rs @@ -0,0 +1,257 @@ +use crate::services::{ + ReadOnlyService, ServiceEvent, + compositor::{CompositorService, CompositorWindow}, +}; +use iced::{ + Alignment, Color, Element, Length, Point, Rectangle, Renderer, Size, Subscription, Theme, + mouse::Cursor, + widget::{ + canvas, + canvas::{Frame, Geometry, Program}, + container, + }, +}; +use std::collections::BTreeMap; + +// Target minimap size in pixels; the layout is scaled to fit within it. +const MAX_H: f32 = 16.0; +const MAX_W: f32 = 80.0; +const MIN_TILE: f32 = 2.0; +// Gap carved out of a tile's edge where it abuts a neighbour, so adjacent +// same-colour tiles don't merge into one block. +const TILE_GAP: f32 = 1.0; +// Tolerance for "shared edge" — coordinates come from compositor floats, so +// allow sub-pixel slack. +const EPS: f32 = 0.5; + +/// An axis-aligned rectangle `(x, y, w, h)` in some 2D space. +type Rect = (f32, f32, f32, f32); + +#[derive(Debug, Clone)] +pub enum Message { + ServiceEvent(ServiceEvent), +} + +pub struct Minimap { + service: Option, +} + +impl Minimap { + pub fn new() -> Self { + Self { service: None } + } + + pub fn update(&mut self, message: Message) { + match message { + Message::ServiceEvent(event) => match event { + ServiceEvent::Init(service) => { + self.service = Some(service); + } + ServiceEvent::Update(event) => { + if let Some(service) = &mut self.service { + service.update(event); + } + } + _ => {} + }, + } + } + + pub fn view(&self) -> Option> { + let service = self.service.as_ref()?; + let active_id = service.active_workspace_id?; + let windows: Vec<&CompositorWindow> = service + .windows + .iter() + .filter(|w| w.workspace_id == Some(active_id)) + .collect(); + + let minimap = build_canvas(&windows)?; + + let (w, h) = (minimap.width, minimap.height); + Some( + container( + canvas(minimap) + .width(Length::Fixed(w)) + .height(Length::Fixed(h)), + ) + .align_y(Alignment::Center) + .into(), + ) + } + + pub fn subscription(&self) -> Subscription { + CompositorService::subscribe().map(Message::ServiceEvent) + } +} + +#[derive(Debug, Clone, Copy)] +enum TileRole { + Normal, + Focused, + Urgent, +} + +fn role_of(w: &CompositorWindow) -> TileRole { + if w.is_focused { + TileRole::Focused + } else if w.is_urgent { + TileRole::Urgent + } else { + TileRole::Normal + } +} + +fn tile_color(role: TileRole, theme: &Theme) -> Color { + match role { + TileRole::Focused => theme.palette().primary, + TileRole::Urgent => theme.palette().danger, + TileRole::Normal => theme.extended_palette().background.strong.color, + } +} + +#[derive(Debug, Clone, Copy)] +struct CanvasTile { + x: f32, + y: f32, + w: f32, + h: f32, + inset_r: f32, + inset_b: f32, + role: TileRole, +} + +/// A fully positioned minimap, in canvas (post-scale) pixel coordinates. +struct MinimapCanvas { + tiles: Vec, + width: f32, + height: f32, +} + +impl Program for MinimapCanvas { + type State = (); + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + _cursor: Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + + // Tiled windows. Insets shrink a tile only on sides with a neighbour, + // so adjacent tiles stay separated while outer edges stay flush. + for t in &self.tiles { + frame.fill_rectangle( + Point::new(t.x, t.y), + Size::new( + (t.w - t.inset_r).max(MIN_TILE), + (t.h - t.inset_b).max(MIN_TILE), + ), + tile_color(t.role, theme), + ); + } + + vec![frame.into_geometry()] + } +} + +fn has_neighbor_right(rects: &[Rect], x: f32, y: f32, w: f32, h: f32) -> bool { + rects + .iter() + .any(|&(ox, oy, _, oh)| (ox - (x + w)).abs() < EPS && oy < y + h - EPS && oy + oh > y + EPS) +} + +fn has_neighbor_below(rects: &[Rect], x: f32, y: f32, w: f32, h: f32) -> bool { + rects + .iter() + .any(|&(ox, oy, ow, _)| (oy - (y + h)).abs() < EPS && ox < x + w - EPS && ox + ow > x + EPS) +} + +/// Lay tiled windows out in workspace-layout space (column 1 at x = 0): +/// columns left to right by cumulative max width, tiles top to bottom within +/// a column by cumulative height. Positions are derived from the grid index. +fn layout_tiled<'a>(tiled: &[&'a CompositorWindow]) -> Vec<(Rect, &'a CompositorWindow)> { + let mut by_col: BTreeMap> = BTreeMap::new(); + for w in tiled { + let (col, _) = w.tile_position.unwrap_or((0, 0)); + by_col.entry(col).or_default().push(w); + } + for tiles in by_col.values_mut() { + tiles.sort_by_key(|w| w.tile_position.map(|(_, r)| r).unwrap_or(0)); + } + + let mut placed = Vec::new(); + let mut x = 0.0_f32; + for tiles in by_col.values() { + let col_w = tiles + .iter() + .map(|w| w.tile_width) + .fold(0.0_f32, f32::max) + .max(1.0); + let mut y = 0.0_f32; + for w in tiles { + let h = w.tile_height.max(1.0); + placed.push(((x, y, col_w, h), *w)); + y += h; + } + x += col_w; + } + placed +} + +/// Build the minimap from the workspace's tiled windows, laid out by grid +/// index and scaled to fit. Floating windows have no grid position and aren't +/// rendered; showing them may be explored later, once niri exposes enough +/// information to place them. Returns `None` when there are no tiled windows +/// to draw. +fn build_canvas(windows: &[&CompositorWindow]) -> Option { + let tiled: Vec<&CompositorWindow> = + windows.iter().copied().filter(|w| !w.is_floating).collect(); + let placed = layout_tiled(&tiled); + if placed.is_empty() { + return None; + } + + let rects: Vec = placed.iter().map(|(r, _)| *r).collect(); + let layout_w = rects + .iter() + .map(|r| r.0 + r.2) + .fold(0.0_f32, f32::max) + .max(1.0); + let layout_h = rects + .iter() + .map(|r| r.1 + r.3) + .fold(0.0_f32, f32::max) + .max(1.0); + let scale = (MAX_H / layout_h).min(MAX_W / layout_w); + + let tiles: Vec = placed + .iter() + .map(|&((x, y, w, h), win)| CanvasTile { + x: x * scale, + y: y * scale, + w: (w * scale).max(MIN_TILE), + h: (h * scale).max(MIN_TILE), + inset_r: if has_neighbor_right(&rects, x, y, w, h) { + TILE_GAP + } else { + 0.0 + }, + inset_b: if has_neighbor_below(&rects, x, y, w, h) { + TILE_GAP + } else { + 0.0 + }, + role: role_of(win), + }) + .collect(); + + Some(MinimapCanvas { + tiles, + width: (layout_w * scale).max(1.0), + height: (layout_h * scale).max(1.0), + }) +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index f27f6f997..5f1010dd2 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -12,6 +12,7 @@ pub mod custom_module; pub mod keyboard_layout; pub mod keyboard_submap; pub mod media_player; +pub mod minimap; pub mod notifications; pub mod privacy; pub mod settings; @@ -196,6 +197,10 @@ impl App { None, ) }), + ModuleName::Minimap => self + .minimap + .view() + .map(|view| (view.map(Message::Minimap), None)), ModuleName::SystemInfo => Some(( self.system_info.view().map(Message::SystemInfo), Some(OnModulePress::ToggleMenu(MenuType::SystemInfo)), @@ -265,6 +270,7 @@ impl App { ModuleName::WindowTitle => { Some(self.window_title.subscription().map(Message::WindowTitle)) } + ModuleName::Minimap => Some(self.minimap.subscription().map(Message::Minimap)), ModuleName::SystemInfo => { Some(self.system_info.subscription().map(Message::SystemInfo)) } diff --git a/src/services/compositor/mod.rs b/src/services/compositor/mod.rs index dbd5e9db2..3fd0e8387 100644 --- a/src/services/compositor/mod.rs +++ b/src/services/compositor/mod.rs @@ -4,6 +4,7 @@ pub mod types; pub use self::types::{ CompositorChoice, CompositorCommand, CompositorEvent, CompositorService, CompositorState, + CompositorWindow, }; use crate::services::{ReadOnlyService, Service, ServiceEvent}; diff --git a/website/docs/configuration/modules/index.md b/website/docs/configuration/modules/index.md index 749ee74a0..5331b9842 100644 --- a/website/docs/configuration/modules/index.md +++ b/website/docs/configuration/modules/index.md @@ -54,6 +54,15 @@ Provides information about the current workspaces and allows switching between t Displays the title of the currently focused window. +### Minimap + +Shows a small map of the tiled windows on the active workspace, laid out +by column and row with proportional sizes, with the focused tile +highlighted and urgent windows shown in the danger color. Floating +windows have no grid position and aren't shown. On Hyprland the widget +stays hidden because the compositor doesn't expose enough per-window +layout info. + ### SystemInfo Displays system information such as CPU usage, memory usage, and disk space.