From 4ad9bdc27a00a51159c27af0f7d53d67819e1b55 Mon Sep 17 00:00:00 2001 From: Robert Lillack Date: Wed, 11 Feb 2026 13:04:42 +0100 Subject: [PATCH 1/5] Add basic support for minimized icons --- src/binding/action.rs | 70 +++++++++ src/canoe/context.rs | 210 ++++++++++++++++++++++++- src/canoe/desktop.rs | 349 ++++++++++++++++++++++++++++++++++++++++-- src/canoe/mod.rs | 2 +- src/canoe/seat.rs | 4 + src/canoe/window.rs | 3 + src/config.rs | 1 + src/main.rs | 242 ++++++++++++++++++++++++++--- 8 files changed, 839 insertions(+), 42 deletions(-) diff --git a/src/binding/action.rs b/src/binding/action.rs index 1b6b693..2598aca 100644 --- a/src/binding/action.rs +++ b/src/binding/action.rs @@ -101,6 +101,19 @@ pub enum Action { /// Restore keyboard focus to the last focused window RestoreFocus, + /// Move desktop icon selection to next icon + IconSelectNext, + /// Move desktop icon selection to previous icon + IconSelectPrev, + /// Move desktop icon selection up one row + IconSelectUp, + /// Move desktop icon selection down one row + IconSelectDown, + /// Activate (restore) the selected desktop icon + IconActivate, + /// Cancel desktop icon selection and exit icon mode + IconCancel, + /// Custom function action CustomFn { func: CustomFn, arg: Arg }, } @@ -274,6 +287,63 @@ pub fn default_xkb_bindings( Action::HideFocused, super::BindingEvent::Pressed, ), + // Desktop icon navigation (DesktopIcons mode) + ( + Mode::DesktopIcons, + Keysym::Right.raw(), + NONE, + Action::IconSelectNext, + super::BindingEvent::Pressed, + ), + ( + Mode::DesktopIcons, + Keysym::Tab.raw(), + NONE, + Action::IconSelectNext, + super::BindingEvent::Pressed, + ), + ( + Mode::DesktopIcons, + Keysym::Left.raw(), + NONE, + Action::IconSelectPrev, + super::BindingEvent::Pressed, + ), + ( + Mode::DesktopIcons, + Keysym::Tab.raw(), + shift, + Action::IconSelectPrev, + super::BindingEvent::Pressed, + ), + ( + Mode::DesktopIcons, + Keysym::Up.raw(), + NONE, + Action::IconSelectUp, + super::BindingEvent::Pressed, + ), + ( + Mode::DesktopIcons, + Keysym::Down.raw(), + NONE, + Action::IconSelectDown, + super::BindingEvent::Pressed, + ), + ( + Mode::DesktopIcons, + Keysym::Return.raw(), + NONE, + Action::IconActivate, + super::BindingEvent::Pressed, + ), + ( + Mode::DesktopIcons, + Keysym::Escape.raw(), + NONE, + Action::IconCancel, + super::BindingEvent::Pressed, + ), ] } diff --git a/src/canoe/context.rs b/src/canoe/context.rs index 682a9d5..4848231 100644 --- a/src/canoe/context.rs +++ b/src/canoe/context.rs @@ -5,6 +5,7 @@ use crate::config::{load_config, Config, WindowDecoration}; use crate::protocol::river_window_management_v1::client::river_window_v1::Edges; use crate::protocol::*; use crate::rule; +use resvg::tiny_skia; use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as CursorShape; use std::cell::RefCell; @@ -44,6 +45,7 @@ pub struct Context { next_window_id: WindowId, next_output_id: OutputId, next_seat_id: SeatId, + next_minimize_seq: u64, // Configuration pub config: Config, @@ -53,6 +55,13 @@ pub struct Context { pub session_locked: bool, startup_unminimize_done: bool, + /// Output with active icon keyboard focus + pub icon_focus_output: Option, + + /// Cache for desktop icon images, keyed by (app_id, size_px). + /// `None` value means "looked up but not found" — avoids repeated disk lookups. + icon_cache: HashMap<(String, i32), Option>, + /// Window menu surface and state pub window_menu: Option, pub window_menu_mode: Option, @@ -87,6 +96,7 @@ impl Context { next_window_id: 0, next_output_id: 0, next_seat_id: 0, + next_minimize_seq: 0, config: load_config(), @@ -94,6 +104,10 @@ impl Context { session_locked: false, startup_unminimize_done: false, + icon_focus_output: None, + + icon_cache: HashMap::new(), + window_menu: None, window_menu_mode: None, window_menu_shield: None, @@ -466,6 +480,24 @@ impl Context { Action::RestoreFocus => { self.restore_focus_from_stack(); } + Action::IconSelectNext => { + self.icon_navigate(1, 0); + } + Action::IconSelectPrev => { + self.icon_navigate(-1, 0); + } + Action::IconSelectUp => { + self.icon_navigate(0, -1); + } + Action::IconSelectDown => { + self.icon_navigate(0, 1); + } + Action::IconActivate => { + self.icon_activate(seat_id); + } + Action::IconCancel => { + self.exit_icon_focus(seat_id); + } Action::CustomFn { func, ref arg } => { let state = self.get_state(); func(&state, arg); @@ -1151,7 +1183,10 @@ impl Context { pub(crate) fn hide_window(&mut self, window_id: WindowId) { let was_focused = self.focused_window == Some(window_id); if let Some(window) = self.windows.get(&window_id) { - window.borrow_mut().hide(); + let mut w = window.borrow_mut(); + w.minimize_seq = self.next_minimize_seq; + self.next_minimize_seq += 1; + w.hide(); } if !was_focused { return; @@ -1906,6 +1941,80 @@ impl Context { items } + /// Look up a cached desktop icon image, loading from disk on first access. + fn get_icon(&mut self, app_id: &str, size_px: i32) -> Option<&tiny_skia::Pixmap> { + let key = (app_id.to_string(), size_px); + self.icon_cache + .entry(key) + .or_insert_with(|| super::desktop::load_icon_for_app(app_id, size_px)) + .as_ref() + } + + /// Collect minimized windows for an output, sorted by minimize order. + pub fn collect_minimized_icons( + &mut self, + output_id: OutputId, + ) -> Vec { + let output = match self.outputs.get(&output_id) { + Some(o) => o, + None => return Vec::new(), + }; + let output_ref = output.borrow(); + + let scale = output_ref.scale.max(1); + let icon_size_px = super::desktop::ICON_SIZE * scale; + + // Collect window data into a temp vec to avoid holding borrow of self.windows + // while calling self.get_icon. + let mut entries: Vec<(u64, WindowId, String, Option)> = self + .windows + .iter() + .filter_map(|(&window_id, window)| { + let w = window.borrow(); + if !w.hidden { + return None; + } + let matches = w + .output + .as_ref() + .and_then(|o| o.upgrade()) + .map(|o| o.borrow().id == output_ref.id) + .unwrap_or(false); + if !matches { + return None; + } + let title = w + .title + .as_ref() + .filter(|t| !t.is_empty()) + .cloned() + .or_else(|| w.app_id.clone()) + .unwrap_or_else(|| format!("Window {}", window_id)); + let app_id = w.app_id.clone(); + Some((w.minimize_seq, window_id, title, app_id)) + }) + .collect(); + drop(output_ref); + + entries.sort_by_key(|(seq, _, _, _)| *seq); + + entries + .into_iter() + .map(|(_, window_id, title, app_id)| { + let icon: Option = app_id + .as_deref() + .and_then(|id| self.get_icon(id, icon_size_px)) + .cloned(); + super::desktop::DesktopIcon { + window_id, + title, + app_id, + icon, + } + }) + .collect() + } + /// Update menu hover based on surface-local pointer position. pub fn update_menu_hover(&mut self, x: i32, y: i32) -> bool { if let Some(menu) = self.window_menu.as_mut() { @@ -2044,6 +2153,105 @@ impl Context { self.window_menu_alt_tab_preview_was_hidden = false; } + /// Enter desktop icon keyboard focus mode. + pub fn enter_icon_focus(&mut self, output_id: OutputId, window_id: WindowId, seat_id: SeatId) { + // Set the selected icon on the desktop surface + if let Some(output) = self.outputs.get(&output_id) { + if let Some(desktop) = output.borrow_mut().desktop_surface.as_mut() { + desktop.selected_icon = Some(window_id); + } + } + self.icon_focus_output = Some(output_id); + self.clear_keyboard_focus(); + if let Some(seat) = self.seats.get(&seat_id) { + seat.borrow_mut() + .switch_mode(crate::config::Mode::DesktopIcons); + } + } + + /// Exit desktop icon keyboard focus mode. + pub fn exit_icon_focus(&mut self, seat_id: SeatId) { + if let Some(output_id) = self.icon_focus_output.take() { + if let Some(output) = self.outputs.get(&output_id) { + if let Some(desktop) = output.borrow_mut().desktop_surface.as_mut() { + desktop.selected_icon = None; + } + } + } + if let Some(seat) = self.seats.get(&seat_id) { + seat.borrow_mut().switch_mode(crate::config::Mode::Default); + } + } + + /// Navigate icon selection by (dx, dy) in the icon grid. + pub fn icon_navigate(&mut self, dx: i32, dy: i32) { + let Some(output_id) = self.icon_focus_output else { + return; + }; + let Some(output) = self.outputs.get(&output_id) else { + return; + }; + let mut out = output.borrow_mut(); + let Some(desktop) = out.desktop_surface.as_mut() else { + return; + }; + let count = desktop.icon_count() as i32; + if count == 0 { + return; + } + let cols = desktop.icon_cols.max(1); + let current_idx = desktop.selected_icon_index().unwrap_or(0) as i32; + let col = current_idx % cols; + let row = current_idx / cols; + + let new_idx = if dy != 0 { + // Move up/down by row + let new_row = row + dy; + let candidate = new_row * cols + col; + if candidate < 0 || candidate >= count { + // Stay at current position if out of bounds + current_idx + } else { + candidate + } + } else { + // Move left/right linearly with wrapping + let candidate = current_idx + dx; + ((candidate % count) + count) % count + }; + + if let Some(window_id) = desktop.icon_window_at_index(new_idx as usize) { + desktop.selected_icon = Some(window_id); + } + } + + /// Activate (restore) the selected desktop icon window. + pub fn icon_activate(&mut self, seat_id: SeatId) { + let Some(output_id) = self.icon_focus_output else { + return; + }; + let selected = self.outputs.get(&output_id).and_then(|o| { + o.borrow() + .desktop_surface + .as_ref() + .and_then(|d| d.selected_icon) + }); + let Some(window_id) = selected else { + self.exit_icon_focus(seat_id); + return; + }; + // Show and focus the window + if let Some(window) = self.windows.get(&window_id) { + { + let mut w = window.borrow_mut(); + w.show(); + w.place_top(); + } + } + self.exit_icon_focus(seat_id); + self.focus(window_id); + } + /// Update the cursor shape based on resize state or border hover. pub fn update_cursor_for_seat(&mut self, seat_id: SeatId) { if self.window_menu_mode == Some(WindowMenuMode::AltTab) { diff --git a/src/canoe/desktop.rs b/src/canoe/desktop.rs index 84d2263..872d285 100644 --- a/src/canoe/desktop.rs +++ b/src/canoe/desktop.rs @@ -1,8 +1,9 @@ -//! Desktop background surface for pointer input. +//! Desktop background surface for pointer input and minimized window icons. #![allow(dead_code)] use memmap2::MmapMut; +use resvg::{tiny_skia, usvg}; use std::fs::File; use std::os::fd::AsFd; use wayland_client::protocol::{ @@ -11,9 +12,32 @@ use wayland_client::protocol::{ use wayland_client::QueueHandle; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1::ZwlrLayerSurfaceV1; -use super::OutputId; +use super::render::Renderer; +use super::{OutputId, WindowId}; -/// Transparent desktop surface for receiving pointer input. +// Icon layout constants (logical pixels, scaled by output scale) +pub const ICON_SIZE: i32 = 32; +const ICON_LABEL_HEIGHT: i32 = 14; +const ICON_CELL_WIDTH: i32 = 64; +const ICON_CELL_HEIGHT: i32 = 50; +const ICON_MARGIN: i32 = 4; + +/// Data for a minimized window icon. +pub struct DesktopIcon { + pub window_id: WindowId, + pub title: String, + pub app_id: Option, + pub icon: Option, +} + +/// Computed icon position on the desktop surface. +struct DesktopIconLayout { + window_id: WindowId, + x: i32, + y: i32, +} + +/// Desktop surface with minimized window icons. pub struct DesktopSurface { pub surface: wl_surface::WlSurface, pub layer_surface: ZwlrLayerSurfaceV1, @@ -23,8 +47,13 @@ pub struct DesktopSurface { pub mmap: Option, pub width: i32, pub height: i32, + buf_width: i32, + buf_height: i32, pub configured: bool, pub output_id: OutputId, + pub selected_icon: Option, + pub icon_cols: i32, + icons: Vec, } impl DesktopSurface { @@ -42,8 +71,13 @@ impl DesktopSurface { mmap: None, width: 0, height: 0, + buf_width: 0, + buf_height: 0, configured: false, output_id, + selected_icon: None, + icon_cols: 1, + icons: Vec::new(), } } @@ -64,22 +98,27 @@ impl DesktopSurface { self.mmap = None; } - pub fn ensure_buffer(&mut self, shm: &wl_shm::WlShm, qh: &QueueHandle) + pub fn ensure_buffer(&mut self, shm: &wl_shm::WlShm, qh: &QueueHandle, scale: i32) where D: 'static + wayland_client::Dispatch + wayland_client::Dispatch, { + let scale = scale.max(1); if self.width <= 0 || self.height <= 0 { return; } - if self.buffer.is_some() { + let buf_w = self.width * scale; + let buf_h = self.height * scale; + + if self.buffer.is_some() && self.buf_width == buf_w && self.buf_height == buf_h { return; } + self.reset_buffer(); - let stride = self.width * 4; - let size = stride * self.height; + let stride = buf_w * 4; + let size = stride * buf_h; let memfd = match memfd::MemfdOptions::default() .close_on_exec(true) .create("canoe-desktop") @@ -102,20 +141,15 @@ impl DesktopSurface { }; let pool = shm.create_pool(memfd.as_file().as_fd(), size, qh, ()); - let buffer = pool.create_buffer( - 0, - self.width, - self.height, - stride, - wl_shm::Format::Argb8888, - qh, - (), - ); + let buffer = pool.create_buffer(0, buf_w, buf_h, stride, wl_shm::Format::Argb8888, qh, ()); self.memfile = Some(memfd.into_file()); self.mmap = Some(mmap); self.pool = Some(pool); self.buffer = Some(buffer); + self.buf_width = buf_w; + self.buf_height = buf_h; + self.surface.set_buffer_scale(scale); } pub fn render(&mut self, rgba: u32) { @@ -128,6 +162,206 @@ impl DesktopSurface { } } + /// Render the desktop background with minimized window icons. + #[allow(clippy::too_many_arguments)] + pub fn render_with_icons( + &mut self, + bg_rgba: u32, + desktop_icons: &[DesktopIcon], + theme: &IconTheme, + scale: i32, + font_name: Option<&str>, + font_size: f32, + ) { + // Fill background + self.render(bg_rgba); + + if desktop_icons.is_empty() { + self.icons.clear(); + return; + } + + let scale = scale.max(1); + let buf_w = self.buf_width; + let buf_h = self.buf_height; + if buf_w <= 0 || buf_h <= 0 { + return; + } + + // Compute icon grid layout (bottom-left, left-to-right, then bottom-to-top) + // All coordinates in logical pixels first, then multiply by scale for rendering + let logical_w = buf_w / scale; + let logical_h = buf_h / scale; + let cols = ((logical_w - ICON_MARGIN * 2) / ICON_CELL_WIDTH).max(1); + self.icon_cols = cols; + + let mut layouts = Vec::with_capacity(desktop_icons.len()); + for (i, icon) in desktop_icons.iter().enumerate() { + let col = i as i32 % cols; + let row = i as i32 / cols; + let x = ICON_MARGIN + col * ICON_CELL_WIDTH; + let y = logical_h - ICON_MARGIN - ICON_CELL_HEIGHT - row * ICON_CELL_HEIGHT; + layouts.push(DesktopIconLayout { + window_id: icon.window_id, + x, + y, + }); + } + self.icons = layouts; + + // Now render each icon + let Some(ref mut mmap) = self.mmap else { + return; + }; + let pixels = mmap.as_mut(); + let Some(mut renderer) = Renderer::new(pixels, buf_w, buf_h) else { + return; + }; + + let selected = self.selected_icon; + let label_font_size = font_size * 0.67; + + for (i, icon) in desktop_icons.iter().enumerate() { + let layout = &self.icons[i]; + let is_selected = selected == Some(icon.window_id); + + let icon_bg = if is_selected { + rgba_to_argb(theme.highlight_bg) + } else { + rgba_to_argb(theme.titlebar_bg) + }; + let icon_text = if is_selected { + rgba_to_argb(theme.highlight_text) + } else { + rgba_to_argb(theme.titlebar_text) + }; + let label_bg = if is_selected { + rgba_to_argb(theme.highlight_bg) + } else { + 0 // transparent - no label background unless selected + }; + let label_text = if is_selected { + rgba_to_argb(theme.highlight_text) + } else { + rgba_to_argb(theme.text) + }; + + let ix = layout.x * scale; + let iy = layout.y * scale; + let icon_px = ICON_SIZE * scale; + let cell_w = ICON_CELL_WIDTH * scale; + + // Center the 32x32 icon square within the cell width + let icon_offset_x = (cell_w - icon_px) / 2; + + let has_icon_image = icon.icon.is_some(); + + if !has_icon_image { + // Draw icon background (32x32 area) + renderer.fill_rect(ix + icon_offset_x, iy, icon_px, icon_px, icon_bg); + + // Draw 1px border around icon square + let border_color = rgba_to_argb(theme.border); + let b = scale.max(1); + renderer.fill_rect(ix + icon_offset_x, iy, icon_px, b, border_color); + renderer.fill_rect( + ix + icon_offset_x, + iy + icon_px - b, + icon_px, + b, + border_color, + ); + renderer.fill_rect(ix + icon_offset_x, iy, b, icon_px, border_color); + renderer.fill_rect( + ix + icon_offset_x + icon_px - b, + iy, + b, + icon_px, + border_color, + ); + + // Render first character centered in the icon + let first_char: String = icon + .title + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .collect(); + renderer.render_text( + &first_char, + ix + icon_offset_x, + iy, + icon_px, + icon_px, + scale, + icon_text, + font_size, + font_name, + 0, + ); + } else { + renderer.blit_pixmap(icon.icon.as_ref().unwrap(), ix + icon_offset_x, iy); + } + + // Draw label background if selected + let label_y = iy + icon_px; + let label_h = ICON_LABEL_HEIGHT * scale; + if is_selected { + renderer.fill_rect(ix, label_y, cell_w, label_h, label_bg); + } + + // Render window title centered below icon + let scaled_label_size = label_font_size * scale as f32; + let text_w = super::font::measure_text(font_name, scaled_label_size, &icon.title) + .unwrap_or(0.0) as i32; + let pad = (cell_w - text_w).max(0) / 2; + renderer.render_text( + &icon.title, + ix, + label_y, + cell_w, + label_h, + scale, + label_text, + label_font_size, + font_name, + pad, + ); + } + } + + /// Hit test: return the window id of the icon at the given surface-local coordinates. + /// Coordinates are in logical (surface-local) pixels; layouts are stored in logical pixels. + pub fn icon_at(&self, x: i32, y: i32, _scale: i32) -> Option { + for layout in &self.icons { + if x >= layout.x + && x < layout.x + ICON_CELL_WIDTH + && y >= layout.y + && y < layout.y + ICON_CELL_HEIGHT + { + return Some(layout.window_id); + } + } + None + } + + /// Find the index of the currently selected icon. + pub fn selected_icon_index(&self) -> Option { + let selected = self.selected_icon?; + self.icons.iter().position(|l| l.window_id == selected) + } + + /// Get the window id at a given index in the icons list. + pub fn icon_window_at_index(&self, idx: usize) -> Option { + self.icons.get(idx).map(|l| l.window_id) + } + + /// Get the number of icons. + pub fn icon_count(&self) -> usize { + self.icons.len() + } + pub fn update_input_region( &self, compositor: &wl_compositor::WlCompositor, @@ -148,12 +382,24 @@ impl DesktopSurface { pub fn commit(&self) { if let Some(ref buffer) = self.buffer { self.surface.attach(Some(buffer), 0, 0); - self.surface.damage_buffer(0, 0, self.width, self.height); + self.surface + .damage_buffer(0, 0, self.buf_width, self.buf_height); self.surface.commit(); } } } +/// Theme colors for desktop icons (extracted from UiConfig). +pub struct IconTheme { + pub bg: u32, + pub text: u32, + pub highlight_bg: u32, + pub highlight_text: u32, + pub titlebar_bg: u32, + pub titlebar_text: u32, + pub border: u32, +} + fn rgba_to_argb(rgba: u32) -> u32 { let r = (rgba >> 24) & 0xff; let g = (rgba >> 16) & 0xff; @@ -162,6 +408,75 @@ fn rgba_to_argb(rgba: u32) -> u32 { (a << 24) | (r << 16) | (g << 8) | b } +/// Load an icon for the given app_id from `~/.config/canoe/icons/`. +/// Tries `.svg` first, then `.png`. Returns `None` if neither exists. +pub fn load_icon_for_app(app_id: &str, size_px: i32) -> Option { + let home = std::env::var("HOME").ok()?; + let dir = std::path::PathBuf::from(home) + .join(".config") + .join("canoe") + .join("icons"); + let size = size_px.max(1) as u32; + + // Try SVG first + let svg_path = dir.join(format!("{}.svg", app_id)); + if let Ok(svg_data) = std::fs::read_to_string(&svg_path) { + let opt = usvg::Options::default(); + if let Ok(tree) = usvg::Tree::from_str(&svg_data, &opt) { + if let Some(mut pixmap) = tiny_skia::Pixmap::new(size, size) { + let tree_size = tree.size(); + let scale_x = size as f32 / tree_size.width(); + let scale_y = size as f32 / tree_size.height(); + let scale = scale_x.min(scale_y); + let scaled_w = tree_size.width() * scale; + let scaled_h = tree_size.height() * scale; + let tx = (size as f32 - scaled_w) * 0.5; + let ty = (size as f32 - scaled_h) * 0.5; + let transform = + tiny_skia::Transform::from_scale(scale, scale).post_translate(tx, ty); + let mut pixmap_mut = pixmap.as_mut(); + resvg::render(&tree, transform, &mut pixmap_mut); + return Some(pixmap); + } + } + } + + // Try PNG + let png_path = dir.join(format!("{}.png", app_id)); + if let Ok(png_data) = std::fs::read(&png_path) { + if let Ok(src) = tiny_skia::Pixmap::decode_png(&png_data) { + return scale_pixmap(&src, size); + } + } + + None +} + +/// Scale a pixmap to the target size, preserving aspect ratio and centering. +fn scale_pixmap(src: &tiny_skia::Pixmap, size: u32) -> Option { + if src.width() == size && src.height() == size { + return Some(src.clone()); + } + let mut dst = tiny_skia::Pixmap::new(size, size)?; + let scale_x = size as f32 / src.width() as f32; + let scale_y = size as f32 / src.height() as f32; + let scale = scale_x.min(scale_y); + let scaled_w = src.width() as f32 * scale; + let scaled_h = src.height() as f32 * scale; + let tx = (size as f32 - scaled_w) * 0.5; + let ty = (size as f32 - scaled_h) * 0.5; + let transform = tiny_skia::Transform::from_scale(scale, scale).post_translate(tx, ty); + dst.draw_pixmap( + 0, + 0, + src.as_ref(), + &tiny_skia::PixmapPaint::default(), + transform, + None, + ); + Some(dst) +} + impl Drop for DesktopSurface { fn drop(&mut self) { if let Some(buffer) = self.buffer.take() { diff --git a/src/canoe/mod.rs b/src/canoe/mod.rs index 53c4bb5..1a6a897 100644 --- a/src/canoe/mod.rs +++ b/src/canoe/mod.rs @@ -12,7 +12,7 @@ pub mod titlebar; pub mod window; pub use context::Context; -pub use desktop::DesktopSurface; +pub use desktop::{DesktopSurface, IconTheme}; pub use menu::{MenuItem, MenuTheme, WindowMenu}; pub use output::{Output, OutputId}; pub use seat::{PointerTarget, Seat, SeatId}; diff --git a/src/canoe/seat.rs b/src/canoe/seat.rs index e92248e..c4fb02b 100644 --- a/src/canoe/seat.rs +++ b/src/canoe/seat.rs @@ -66,6 +66,9 @@ pub struct Seat { /// Last close-button click for double-click detection pub last_close_click: Option<(WindowId, Instant)>, + /// Last desktop icon click for double-click detection + pub last_icon_click: Option<(WindowId, Instant)>, + /// Pending actions to execute during manage phase pub unhandled_actions: VecDeque, @@ -101,6 +104,7 @@ impl Seat { window_below_pointer: None, menu_click_button: None, last_close_click: None, + last_icon_click: None, unhandled_actions: VecDeque::new(), xkb_bindings: Vec::new(), pointer_bindings: Vec::new(), diff --git a/src/canoe/window.rs b/src/canoe/window.rs index 8afa89e..1763828 100644 --- a/src/canoe/window.rs +++ b/src/canoe/window.rs @@ -144,6 +144,8 @@ pub struct Window { pub floating: bool, /// Hidden state pub hidden: bool, + /// Sequence number assigned when minimized (for ordering icons) + pub minimize_seq: u64, /// Clip state pub clip_state: ClipState, @@ -202,6 +204,7 @@ impl Window { pending_unfullscreen_restore: false, floating: false, hidden: false, + minimize_seq: 0, clip_state: ClipState::Unknown, decoration: None, decoration_hint: None, diff --git a/src/config.rs b/src/config.rs index 0e3d56a..e974ff9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,6 +53,7 @@ pub enum Mode { Default, Floating, Passthrough, + DesktopIcons, } /// Window decoration style diff --git a/src/main.rs b/src/main.rs index beac911..beee053 100644 --- a/src/main.rs +++ b/src/main.rs @@ -782,6 +782,80 @@ fn ensure_desktop_surface( )); } +fn render_desktop_surface( + state: &mut AppState, + output_id: canoe::OutputId, + qh: &QueueHandle, +) { + let (Some(shm), Some(compositor)) = ( + state.globals.shm.as_ref(), + state.globals.compositor.as_ref(), + ) else { + return; + }; + + let (bg_color, icons, icon_theme, font_name, font_size, scale) = { + let mut context = state.context.borrow_mut(); + let bg_color = context.config.ui.desktop_background; + let icon_theme = canoe::IconTheme { + bg: context.config.ui.menu_bg, + text: context.config.ui.menu_text, + highlight_bg: context.config.ui.menu_highlight_bg, + highlight_text: context.config.ui.menu_highlight_text, + titlebar_bg: context.config.ui.titlebar_bg_inactive, + titlebar_text: context.config.ui.titlebar_text_inactive, + border: 0x404040FF, + }; + let font_name = context.config.ui.font_name.clone(); + let font_size = context.config.ui.font_size; + let scale = context + .outputs + .get(&output_id) + .map(|o| o.borrow().scale) + .unwrap_or(1); + let icons = context.collect_minimized_icons(output_id); + (bg_color, icons, icon_theme, font_name, font_size, scale) + }; + + let output = { + let context = state.context.borrow(); + context.outputs.get(&output_id).cloned() + }; + let Some(output) = output else { + return; + }; + + let mut out = output.borrow_mut(); + let Some(desktop) = out.desktop_surface.as_mut() else { + return; + }; + if !desktop.configured { + return; + } + + desktop.ensure_buffer(shm, qh, scale); + desktop.render_with_icons( + bg_color, + &icons, + &icon_theme, + scale, + font_name.as_deref(), + font_size, + ); + desktop.update_input_region(compositor, qh); + desktop.commit(); +} + +fn render_all_desktop_surfaces(state: &mut AppState, qh: &QueueHandle) { + let output_ids: Vec = { + let context = state.context.borrow(); + context.outputs.keys().copied().collect() + }; + for output_id in output_ids { + render_desktop_surface(state, output_id, qh); + } +} + impl Dispatch for AppState { fn event( state: &mut Self, @@ -931,7 +1005,36 @@ impl Dispatch for AppState { } Event::ManageStart => { state.context.borrow_mut().handle_manage_start(); + // If in icon focus mode, validate that the selected icon still exists + { + let mut context = state.context.borrow_mut(); + if let Some(output_id) = context.icon_focus_output { + let selected_still_valid = context + .outputs + .get(&output_id) + .and_then(|o| { + o.borrow() + .desktop_surface + .as_ref() + .and_then(|d| d.selected_icon) + }) + .map(|wid| { + context + .windows + .get(&wid) + .map(|w| w.borrow().hidden) + .unwrap_or(false) + }) + .unwrap_or(false); + if !selected_still_valid { + if let Some(seat_id) = context.current_seat { + context.exit_icon_focus(seat_id); + } + } + } + } state.context.borrow().finish_manage(); + render_all_desktop_surfaces(state, qh); } Event::RenderStart => { state.context.borrow_mut().handle_render_start(); @@ -1488,6 +1591,9 @@ impl Dispatch for AppState { drop(context); state.context.borrow_mut().close_window_menu(); let mut context = state.context.borrow_mut(); + if context.icon_focus_output.is_some() { + context.exit_icon_focus(*seat_id); + } context.focus(wid); context.handle_window_interaction(*seat_id, wid); } @@ -1590,24 +1696,17 @@ impl Dispatch for AppState { let Some(output) = output else { return; }; - let mut out = output.borrow_mut(); - let Some(desktop) = out.desktop_surface.as_mut() else { - return; - }; - if (desktop.width, desktop.height) != (width as i32, height as i32) { - desktop.reset_buffer(); - } - desktop.configure(width as i32, height as i32); - if let (Some(shm), Some(compositor)) = ( - state.globals.shm.as_ref(), - state.globals.compositor.as_ref(), - ) { - desktop.ensure_buffer(shm, qh); - let bg_color = state.context.borrow().config.ui.desktop_background; - desktop.render(bg_color); - desktop.update_input_region(compositor, qh); - desktop.commit(); + { + let mut out = output.borrow_mut(); + let Some(desktop) = out.desktop_surface.as_mut() else { + return; + }; + if (desktop.width, desktop.height) != (width as i32, height as i32) { + desktop.reset_buffer(); + } + desktop.configure(width as i32, height as i32); } + render_desktop_surface(state, *output_id, qh); } canoe::LayerSurfaceKind::Menu => { let mut context = state.context.borrow_mut(); @@ -1806,6 +1905,22 @@ impl Dispatch for AppState { binding::Action::WindowMenuCycleApp => { handle_window_menu_cycle_app(state, qh); } + binding::Action::IconSelectNext + | binding::Action::IconSelectPrev + | binding::Action::IconSelectUp + | binding::Action::IconSelectDown => { + state.context.borrow_mut().execute_action(action, *seat_id); + render_all_desktop_surfaces(state, qh); + } + binding::Action::IconActivate => { + state.context.borrow_mut().execute_action(action, *seat_id); + render_all_desktop_surfaces(state, qh); + request_manage_dirty(state); + } + binding::Action::IconCancel => { + state.context.borrow_mut().execute_action(action, *seat_id); + render_all_desktop_surfaces(state, qh); + } _ => { seat.borrow_mut().queue_action(action); } @@ -2267,11 +2382,89 @@ impl Dispatch for AppState { } match target { canoe::PointerTarget::Desktop(output_id) => { - if button == crate::config::button::RIGHT { - let (px, py) = { - let seat_ref = seat.borrow(); - (seat_ref.last_surface_x, seat_ref.last_surface_y) + let (px, py) = { + let seat_ref = seat.borrow(); + (seat_ref.last_surface_x, seat_ref.last_surface_y) + }; + if button == crate::config::button::LEFT { + // Hit-test desktop icons + let hit = { + let context = state.context.borrow(); + let scale = context + .outputs + .get(&output_id) + .map(|o| o.borrow().scale) + .unwrap_or(1); + context.outputs.get(&output_id).and_then(|o| { + o.borrow() + .desktop_surface + .as_ref() + .and_then(|d| d.icon_at(px, py, scale)) + }) }; + if let Some(icon_window_id) = hit { + // Check for double-click to restore + let now = Instant::now(); + let is_double = { + let seat_ref = seat.borrow(); + seat_ref + .last_icon_click + .map(|(last_wid, when)| { + last_wid == icon_window_id + && now.duration_since(when) + <= CLOSE_DOUBLE_CLICK + }) + .unwrap_or(false) + }; + seat.borrow_mut().last_icon_click = + Some((icon_window_id, now)); + if is_double { + // Double-click: restore the window + { + let context = state.context.borrow(); + if let Some(window) = + context.windows.get(&icon_window_id) + { + let mut w = window.borrow_mut(); + w.show(); + w.place_top(); + } + } + { + let seat_id = *seat_id; + state + .context + .borrow_mut() + .exit_icon_focus(seat_id); + } + state.context.borrow_mut().focus(icon_window_id); + seat.borrow_mut().last_icon_click = None; + render_all_desktop_surfaces(state, _qh); + request_manage_dirty(state); + } else { + // Single click: select icon and enter icon focus mode + let seat_id = *seat_id; + state.context.borrow_mut().enter_icon_focus( + output_id, + icon_window_id, + seat_id, + ); + render_desktop_surface(state, output_id, _qh); + request_manage_dirty(state); + } + } else { + // Clicked empty space: deselect and exit icon mode + seat.borrow_mut().last_icon_click = None; + { + let seat_id = *seat_id; + state.context.borrow_mut().exit_icon_focus(seat_id); + } + render_desktop_surface(state, output_id, _qh); + seat.borrow_mut() + .queue_action(binding::Action::ClearFocus); + request_manage_dirty(state); + } + } else if button == crate::config::button::RIGHT { let mut context = state.context.borrow_mut(); if context.window_menu.is_some() { context.close_window_menu(); @@ -2289,9 +2482,12 @@ impl Dispatch for AppState { ); update_menu_hover_from_global(state, *seat_id, _qh); seat.borrow_mut().menu_click_button = Some(button); + seat.borrow_mut().queue_action(binding::Action::ClearFocus); + request_manage_dirty(state); + } else { + seat.borrow_mut().queue_action(binding::Action::ClearFocus); + request_manage_dirty(state); } - seat.borrow_mut().queue_action(binding::Action::ClearFocus); - request_manage_dirty(state); } canoe::PointerTarget::Menu => { seat.borrow_mut().menu_click_button = Some(button); From 76397313d20b402bf8b1796cec3479be95c227e7 Mon Sep 17 00:00:00 2001 From: Robert Lillack Date: Wed, 11 Feb 2026 13:12:34 +0100 Subject: [PATCH 2/5] Add support for XDG icon lookup --- src/canoe/desktop.rs | 220 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 190 insertions(+), 30 deletions(-) diff --git a/src/canoe/desktop.rs b/src/canoe/desktop.rs index 872d285..bb2974e 100644 --- a/src/canoe/desktop.rs +++ b/src/canoe/desktop.rs @@ -408,50 +408,210 @@ fn rgba_to_argb(rgba: u32) -> u32 { (a << 24) | (r << 16) | (g << 8) | b } -/// Load an icon for the given app_id from `~/.config/canoe/icons/`. -/// Tries `.svg` first, then `.png`. Returns `None` if neither exists. +/// Load an icon for the given app_id. +/// +/// Priority: user override (`~/.config/canoe/icons/`) > XDG desktop icon > None (first-char). pub fn load_icon_for_app(app_id: &str, size_px: i32) -> Option { - let home = std::env::var("HOME").ok()?; - let dir = std::path::PathBuf::from(home) - .join(".config") - .join("canoe") - .join("icons"); let size = size_px.max(1) as u32; - // Try SVG first - let svg_path = dir.join(format!("{}.svg", app_id)); - if let Ok(svg_data) = std::fs::read_to_string(&svg_path) { - let opt = usvg::Options::default(); - if let Ok(tree) = usvg::Tree::from_str(&svg_data, &opt) { - if let Some(mut pixmap) = tiny_skia::Pixmap::new(size, size) { - let tree_size = tree.size(); - let scale_x = size as f32 / tree_size.width(); - let scale_y = size as f32 / tree_size.height(); - let scale = scale_x.min(scale_y); - let scaled_w = tree_size.width() * scale; - let scaled_h = tree_size.height() * scale; - let tx = (size as f32 - scaled_w) * 0.5; - let ty = (size as f32 - scaled_h) * 0.5; - let transform = - tiny_skia::Transform::from_scale(scale, scale).post_translate(tx, ty); - let mut pixmap_mut = pixmap.as_mut(); - resvg::render(&tree, transform, &mut pixmap_mut); + // 1. User override from ~/.config/canoe/icons/ + if let Ok(home) = std::env::var("HOME") { + let dir = std::path::PathBuf::from(home) + .join(".config") + .join("canoe") + .join("icons"); + if let Some(pixmap) = load_icon_file(&dir, app_id, size) { + return Some(pixmap); + } + } + + // 2. XDG desktop file icon lookup + if let Some(pixmap) = load_xdg_icon(app_id, size) { + return Some(pixmap); + } + + None +} + +/// Try loading `.svg` or `.png` from the given directory. +fn load_icon_file(dir: &std::path::Path, name: &str, size: u32) -> Option { + let svg_path = dir.join(format!("{}.svg", name)); + if let Some(pixmap) = rasterize_svg_file(&svg_path, size) { + return Some(pixmap); + } + let png_path = dir.join(format!("{}.png", name)); + load_png_file(&png_path, size) +} + +fn rasterize_svg_file(path: &std::path::Path, size: u32) -> Option { + let svg_data = std::fs::read_to_string(path).ok()?; + rasterize_svg(&svg_data, size) +} + +fn rasterize_svg(svg_data: &str, size: u32) -> Option { + let opt = usvg::Options::default(); + let tree = usvg::Tree::from_str(svg_data, &opt).ok()?; + let mut pixmap = tiny_skia::Pixmap::new(size, size)?; + let tree_size = tree.size(); + let scale_x = size as f32 / tree_size.width(); + let scale_y = size as f32 / tree_size.height(); + let scale = scale_x.min(scale_y); + let scaled_w = tree_size.width() * scale; + let scaled_h = tree_size.height() * scale; + let tx = (size as f32 - scaled_w) * 0.5; + let ty = (size as f32 - scaled_h) * 0.5; + let transform = tiny_skia::Transform::from_scale(scale, scale).post_translate(tx, ty); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + Some(pixmap) +} + +fn load_png_file(path: &std::path::Path, size: u32) -> Option { + let data = std::fs::read(path).ok()?; + let src = tiny_skia::Pixmap::decode_png(&data).ok()?; + scale_pixmap(&src, size) +} + +/// Look up an icon via XDG desktop files and the hicolor icon theme. +fn load_xdg_icon(app_id: &str, size: u32) -> Option { + let icon_name = read_desktop_icon_name(app_id)?; + + // If the Icon value is an absolute path, load it directly. + let icon_path = std::path::Path::new(&icon_name); + if icon_path.is_absolute() { + return load_icon_by_path(icon_path, size); + } + + // Search hicolor icon theme across XDG data directories. + find_hicolor_icon(&icon_name, size) +} + +/// Find `.desktop` in XDG data dirs and return the `Icon=` value. +fn read_desktop_icon_name(app_id: &str) -> Option { + let filename = format!("{}.desktop", app_id); + for dir in xdg_data_dirs() { + let path = dir.join("applications").join(&filename); + if let Some(name) = parse_desktop_icon(&path) { + return Some(name); + } + } + None +} + +/// Parse a .desktop file and return the Icon= value from [Desktop Entry]. +fn parse_desktop_icon(path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let mut in_entry = false; + for line in content.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_entry = line == "[Desktop Entry]"; + continue; + } + if in_entry { + if let Some(value) = line.strip_prefix("Icon=") { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + None +} + +/// Load an icon from an absolute file path (SVG or PNG). +fn load_icon_by_path(path: &std::path::Path, size: u32) -> Option { + match path.extension().and_then(|e| e.to_str()) { + Some("svg") => rasterize_svg_file(path, size), + Some("png") => load_png_file(path, size), + _ => None, + } +} + +/// Search the hicolor icon theme for the given icon name, returning the +/// largest available rasterized to `size`. +fn find_hicolor_icon(icon_name: &str, size: u32) -> Option { + // Prefer scalable SVG, then the largest fixed-size icon. + // Standard hicolor sizes, largest first. + const SIZES: &[u32] = &[512, 256, 128, 96, 72, 64, 48, 36, 32, 24, 22, 16]; + + for dir in xdg_data_dirs() { + let theme_dir = dir.join("icons").join("hicolor"); + + // Try scalable first. + let svg = theme_dir + .join("scalable") + .join("apps") + .join(format!("{}.svg", icon_name)); + if let Some(pixmap) = rasterize_svg_file(&svg, size) { + return Some(pixmap); + } + + // Try fixed sizes, largest first. + for &s in SIZES { + let subdir = format!("{}x{}", s, s); + let png = theme_dir + .join(&subdir) + .join("apps") + .join(format!("{}.png", icon_name)); + if let Some(pixmap) = load_png_file(&png, size) { + return Some(pixmap); + } + let svg = theme_dir + .join(&subdir) + .join("apps") + .join(format!("{}.svg", icon_name)); + if let Some(pixmap) = rasterize_svg_file(&svg, size) { return Some(pixmap); } } } - // Try PNG - let png_path = dir.join(format!("{}.png", app_id)); - if let Ok(png_data) = std::fs::read(&png_path) { - if let Ok(src) = tiny_skia::Pixmap::decode_png(&png_data) { - return scale_pixmap(&src, size); + // Last resort: check pixmaps directories. + for dir in xdg_data_dirs() { + let pixmaps = dir.join("pixmaps"); + let png = pixmaps.join(format!("{}.png", icon_name)); + if let Some(pixmap) = load_png_file(&png, size) { + return Some(pixmap); + } + let svg = pixmaps.join(format!("{}.svg", icon_name)); + if let Some(pixmap) = rasterize_svg_file(&svg, size) { + return Some(pixmap); } } None } +/// Return XDG data directories: $XDG_DATA_HOME then $XDG_DATA_DIRS. +fn xdg_data_dirs() -> Vec { + let mut dirs = Vec::new(); + + // $XDG_DATA_HOME (default: $HOME/.local/share) + if let Ok(v) = std::env::var("XDG_DATA_HOME") { + if !v.is_empty() { + dirs.push(std::path::PathBuf::from(v)); + } + } else if let Ok(home) = std::env::var("HOME") { + dirs.push(std::path::PathBuf::from(home).join(".local").join("share")); + } + + // $XDG_DATA_DIRS (default: /usr/local/share:/usr/share) + let data_dirs = std::env::var("XDG_DATA_DIRS").unwrap_or_default(); + if data_dirs.is_empty() { + dirs.push(std::path::PathBuf::from("/usr/local/share")); + dirs.push(std::path::PathBuf::from("/usr/share")); + } else { + for p in data_dirs.split(':') { + if !p.is_empty() { + dirs.push(std::path::PathBuf::from(p)); + } + } + } + + dirs +} + /// Scale a pixmap to the target size, preserving aspect ratio and centering. fn scale_pixmap(src: &tiny_skia::Pixmap, size: u32) -> Option { if src.width() == size && src.height() == size { From f799630e3160a7c9335a3ade753650ab7b9140c0 Mon Sep 17 00:00:00 2001 From: Robert Lillack Date: Wed, 11 Feb 2026 13:22:46 +0100 Subject: [PATCH 3/5] Improve desktop rendering performance --- src/canoe/context.rs | 32 +++++++++++++++++++++++++------- src/canoe/desktop.rs | 7 ++++++- src/main.rs | 15 +++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/canoe/context.rs b/src/canoe/context.rs index 4848231..fccd307 100644 --- a/src/canoe/context.rs +++ b/src/canoe/context.rs @@ -60,7 +60,7 @@ pub struct Context { /// Cache for desktop icon images, keyed by (app_id, size_px). /// `None` value means "looked up but not found" — avoids repeated disk lookups. - icon_cache: HashMap<(String, i32), Option>, + icon_cache: HashMap<(String, i32), Option>>, /// Window menu surface and state pub window_menu: Option, @@ -1188,6 +1188,7 @@ impl Context { self.next_minimize_seq += 1; w.hide(); } + self.mark_all_desktops_dirty(); if !was_focused { return; } @@ -1941,13 +1942,22 @@ impl Context { items } + /// Mark all desktop surfaces as needing re-render. + pub fn mark_all_desktops_dirty(&self) { + for output in self.outputs.values() { + if let Some(desktop) = output.borrow_mut().desktop_surface.as_mut() { + desktop.dirty = true; + } + } + } + /// Look up a cached desktop icon image, loading from disk on first access. - fn get_icon(&mut self, app_id: &str, size_px: i32) -> Option<&tiny_skia::Pixmap> { + fn get_icon(&mut self, app_id: &str, size_px: i32) -> Option> { let key = (app_id.to_string(), size_px); self.icon_cache .entry(key) - .or_insert_with(|| super::desktop::load_icon_for_app(app_id, size_px)) - .as_ref() + .or_insert_with(|| super::desktop::load_icon_for_app(app_id, size_px).map(Rc::new)) + .clone() } /// Collect minimized windows for an output, sorted by minimize order. @@ -2001,10 +2011,9 @@ impl Context { entries .into_iter() .map(|(_, window_id, title, app_id)| { - let icon: Option = app_id + let icon = app_id .as_deref() - .and_then(|id| self.get_icon(id, icon_size_px)) - .cloned(); + .and_then(|id| self.get_icon(id, icon_size_px)); super::desktop::DesktopIcon { window_id, title, @@ -2042,13 +2051,18 @@ impl Context { }; if let Some(window) = self.windows.get(&window_id) { + let was_hidden; { let mut w = window.borrow_mut(); + was_hidden = w.hidden; if w.hidden { w.show(); } w.place_top(); } + if was_hidden { + self.mark_all_desktops_dirty(); + } self.focus(window_id); } } @@ -2159,6 +2173,7 @@ impl Context { if let Some(output) = self.outputs.get(&output_id) { if let Some(desktop) = output.borrow_mut().desktop_surface.as_mut() { desktop.selected_icon = Some(window_id); + desktop.dirty = true; } } self.icon_focus_output = Some(output_id); @@ -2175,6 +2190,7 @@ impl Context { if let Some(output) = self.outputs.get(&output_id) { if let Some(desktop) = output.borrow_mut().desktop_surface.as_mut() { desktop.selected_icon = None; + desktop.dirty = true; } } } @@ -2222,6 +2238,7 @@ impl Context { if let Some(window_id) = desktop.icon_window_at_index(new_idx as usize) { desktop.selected_icon = Some(window_id); + desktop.dirty = true; } } @@ -2248,6 +2265,7 @@ impl Context { w.place_top(); } } + self.mark_all_desktops_dirty(); self.exit_icon_focus(seat_id); self.focus(window_id); } diff --git a/src/canoe/desktop.rs b/src/canoe/desktop.rs index bb2974e..0f004f2 100644 --- a/src/canoe/desktop.rs +++ b/src/canoe/desktop.rs @@ -6,6 +6,7 @@ use memmap2::MmapMut; use resvg::{tiny_skia, usvg}; use std::fs::File; use std::os::fd::AsFd; +use std::rc::Rc; use wayland_client::protocol::{ wl_buffer, wl_compositor, wl_region, wl_shm, wl_shm_pool, wl_surface, }; @@ -27,7 +28,7 @@ pub struct DesktopIcon { pub window_id: WindowId, pub title: String, pub app_id: Option, - pub icon: Option, + pub icon: Option>, } /// Computed icon position on the desktop surface. @@ -54,6 +55,7 @@ pub struct DesktopSurface { pub selected_icon: Option, pub icon_cols: i32, icons: Vec, + pub dirty: bool, } impl DesktopSurface { @@ -78,6 +80,7 @@ impl DesktopSurface { selected_icon: None, icon_cols: 1, icons: Vec::new(), + dirty: true, } } @@ -85,6 +88,7 @@ impl DesktopSurface { self.width = width.max(1); self.height = height.max(1); self.configured = true; + self.dirty = true; } pub fn reset_buffer(&mut self) { @@ -96,6 +100,7 @@ impl DesktopSurface { } self.memfile = None; self.mmap = None; + self.dirty = true; } pub fn ensure_buffer(&mut self, shm: &wl_shm::WlShm, qh: &QueueHandle, scale: i32) diff --git a/src/main.rs b/src/main.rs index beee053..ff53883 100644 --- a/src/main.rs +++ b/src/main.rs @@ -787,6 +787,19 @@ fn render_desktop_surface( output_id: canoe::OutputId, qh: &QueueHandle, ) { + // Check dirty flag early to skip all work when nothing changed. + { + let context = state.context.borrow(); + let is_dirty = context + .outputs + .get(&output_id) + .and_then(|o| o.borrow().desktop_surface.as_ref().map(|d| d.dirty)) + .unwrap_or(false); + if !is_dirty { + return; + } + } + let (Some(shm), Some(compositor)) = ( state.globals.shm.as_ref(), state.globals.compositor.as_ref(), @@ -844,6 +857,7 @@ fn render_desktop_surface( ); desktop.update_input_region(compositor, qh); desktop.commit(); + desktop.dirty = false; } fn render_all_desktop_surfaces(state: &mut AppState, qh: &QueueHandle) { @@ -2430,6 +2444,7 @@ impl Dispatch for AppState { w.place_top(); } } + state.context.borrow().mark_all_desktops_dirty(); { let seat_id = *seat_id; state From b1d0412d79e4e0c893292c4987c1bbdab49835d6 Mon Sep 17 00:00:00 2001 From: Robert Lillack Date: Fri, 20 Feb 2026 22:26:21 +0100 Subject: [PATCH 4/5] Improve icon font --- src/canoe/desktop.rs | 45 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/canoe/desktop.rs b/src/canoe/desktop.rs index 0f004f2..4322aa8 100644 --- a/src/canoe/desktop.rs +++ b/src/canoe/desktop.rs @@ -224,7 +224,30 @@ impl DesktopSurface { }; let selected = self.selected_icon; - let label_font_size = font_size * 0.67; + let label_font_size = font_size * 0.80; + // Use a non-bold variant for icon labels by stripping bold-related + // fontconfig properties and forcing regular weight. + let label_font_query = { + let base = font_name.unwrap_or("sans"); + let parts: Vec<&str> = base + .split(':') + .filter(|part| { + let lower = part.to_ascii_lowercase(); + !lower.starts_with("style=") + && !lower.starts_with("weight=") + && lower != "bold" + }) + .collect(); + let family = if parts.is_empty() { "sans" } else { parts[0] }; + let mut query = String::from(family); + for &part in &parts[1..] { + query.push(':'); + query.push_str(part); + } + query.push_str(":weight=regular"); + query + }; + let label_font_name: Option<&str> = Some(label_font_query.as_str()); for (i, icon) in desktop_icons.iter().enumerate() { let layout = &self.icons[i]; @@ -309,18 +332,22 @@ impl DesktopSurface { renderer.blit_pixmap(icon.icon.as_ref().unwrap(), ix + icon_offset_x, iy); } - // Draw label background if selected + // Render window title centered below icon let label_y = iy + icon_px; let label_h = ICON_LABEL_HEIGHT * scale; - if is_selected { - renderer.fill_rect(ix, label_y, cell_w, label_h, label_bg); - } - - // Render window title centered below icon let scaled_label_size = label_font_size * scale as f32; - let text_w = super::font::measure_text(font_name, scaled_label_size, &icon.title) + let text_w = super::font::measure_text(label_font_name, scaled_label_size, &icon.title) .unwrap_or(0.0) as i32; let pad = (cell_w - text_w).max(0) / 2; + + // Draw label background if selected, sized to the text + if is_selected { + let margin = 2 * scale; + let bg_w = (text_w + margin * 2).min(cell_w); + let bg_x = ix + (cell_w - bg_w) / 2; + renderer.fill_rect(bg_x, label_y, bg_w, label_h, label_bg); + } + renderer.render_text( &icon.title, ix, @@ -330,7 +357,7 @@ impl DesktopSurface { scale, label_text, label_font_size, - font_name, + label_font_name, pad, ); } From 76a26ab1c85695dd269a92ee9242d0a66ee94f4c Mon Sep 17 00:00:00 2001 From: Robert Lillack Date: Fri, 20 Feb 2026 22:48:39 +0100 Subject: [PATCH 5/5] Fix restoring minimized windows during Super-Tab --- src/canoe/context.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/canoe/context.rs b/src/canoe/context.rs index fccd307..a1469ed 100644 --- a/src/canoe/context.rs +++ b/src/canoe/context.rs @@ -2100,10 +2100,12 @@ impl Context { return; } + let mut visibility_changed = false; if let Some(prev_id) = self.window_menu_alt_tab_preview.take() { if self.window_menu_alt_tab_preview_was_hidden { if let Some(prev_window) = self.windows.get(&prev_id) { prev_window.borrow_mut().hide(); + visibility_changed = true; } } } @@ -2120,6 +2122,7 @@ impl Context { if w.hidden { was_hidden = true; w.show(); + visibility_changed = true; } w.place_top(); } @@ -2127,13 +2130,18 @@ impl Context { self.window_menu_alt_tab_preview = Some(window_id); self.window_menu_alt_tab_preview_was_hidden = was_hidden; } + if visibility_changed { + self.mark_all_desktops_dirty(); + } } fn restore_alt_tab_state(&mut self) { + let mut visibility_changed = false; if let Some(prev_id) = self.window_menu_alt_tab_preview.take() { if self.window_menu_alt_tab_preview_was_hidden { if let Some(prev_window) = self.windows.get(&prev_id) { prev_window.borrow_mut().hide(); + visibility_changed = true; } } } @@ -2150,6 +2158,9 @@ impl Context { } self.clear_alt_tab_state(); + if visibility_changed { + self.mark_all_desktops_dirty(); + } } fn restore_stack_order(&self, order: &[WindowId]) {