diff --git a/README.md b/README.md index 7af9beb..a8898f6 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ menu_highlight_text = "#000000" button_bg = "#202020" button_highlight = "#FFD000" button_shadow = "#000000" +window_shadow_size = 20 +window_shadow_color = "#00000040" font_name = "Sans" font_size = 12.0 desktop_background = "#101010" diff --git a/src/canoe/mod.rs b/src/canoe/mod.rs index 53c4bb5..a1929a3 100644 --- a/src/canoe/mod.rs +++ b/src/canoe/mod.rs @@ -7,6 +7,7 @@ mod menu; mod output; mod render; mod seat; +mod shadow; mod shield; pub mod titlebar; pub mod window; @@ -16,6 +17,7 @@ pub use desktop::DesktopSurface; pub use menu::{MenuItem, MenuTheme, WindowMenu}; pub use output::{Output, OutputId}; pub use seat::{PointerTarget, Seat, SeatId}; +pub use shadow::WindowShadow; pub use shield::ShieldSurface; pub use titlebar::Titlebar; pub use window::{Window, WindowEvent, WindowId}; diff --git a/src/canoe/shadow.rs b/src/canoe/shadow.rs new file mode 100644 index 0000000..20e5ff8 --- /dev/null +++ b/src/canoe/shadow.rs @@ -0,0 +1,354 @@ +//! Window shadow rendering. + +use super::render::Renderer; +use crate::protocol::RiverDecorationV1; +use memmap2::MmapMut; +use std::fs::File; +use std::os::fd::AsFd; +use wayland_client::protocol::{ + wl_buffer, wl_compositor, wl_region, wl_shm, wl_shm_pool, wl_surface, +}; +use wayland_client::QueueHandle; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ShadowKey { + frame_width: i32, + frame_height: i32, + shadow_size: i32, + shadow_color: u32, + scale: i32, +} + +#[derive(Clone, Debug)] +struct ShadowCache { + key: ShadowKey, + pixels: Vec, +} + +/// Shadow surface for a window. +pub struct WindowShadow { + /// The wl_surface for the shadow. + pub surface: wl_surface::WlSurface, + /// The river decoration object. + pub decoration: RiverDecorationV1, + /// Current buffer (if any). + pub buffer: Option, + /// Shared memory pool. + pub pool: Option, + /// Memory-mapped file for the buffer. + pub memfile: Option, + /// Memory map pointer. + pub mmap: Option, + /// Current buffer width. + pub width: i32, + /// Current buffer height. + pub height: i32, + /// Current buffer width in pixels. + pub buffer_width: i32, + /// Current buffer height in pixels. + pub buffer_height: i32, + /// Output scale factor. + pub scale: i32, + /// wl_output names the shadow surface is currently on. + pub output_names: Vec, + cache: Option, +} + +impl WindowShadow { + /// Create a new shadow surface. + pub fn new(surface: wl_surface::WlSurface, decoration: RiverDecorationV1) -> Self { + Self { + surface, + decoration, + buffer: None, + pool: None, + memfile: None, + mmap: None, + width: 0, + height: 0, + buffer_width: 0, + buffer_height: 0, + scale: 1, + output_names: Vec::new(), + cache: None, + } + } + + /// Ensure buffer is allocated for the given frame size. + pub fn ensure_buffer( + &mut self, + frame_width: i32, + frame_height: i32, + shadow_size: i32, + shm: &wl_shm::WlShm, + qh: &QueueHandle, + scale: i32, + ) where + D: 'static + + wayland_client::Dispatch + + wayland_client::Dispatch, + { + if frame_width <= 0 || frame_height <= 0 { + return; + } + + let shadow_size = shadow_size.max(0); + let scale = scale.max(1); + let width = frame_width + shadow_size * 2; + let height = frame_height + shadow_size * 2; + let buffer_width = width * scale; + let buffer_height = height * scale; + if buffer_width <= 0 || buffer_height <= 0 { + return; + } + + if self.width != width + || self.height != height + || self.buffer_width != buffer_width + || self.buffer_height != buffer_height + || self.scale != scale + || self.buffer.is_none() + { + self.width = width; + self.height = height; + self.buffer_width = buffer_width; + self.buffer_height = buffer_height; + self.scale = scale; + self.cache = None; + + if let Some(buffer) = self.buffer.take() { + buffer.destroy(); + } + if let Some(pool) = self.pool.take() { + pool.destroy(); + } + + let stride = buffer_width * 4; + let size = stride * buffer_height; + + let memfd = match memfd::MemfdOptions::default() + .close_on_exec(true) + .create("canoe-shadow") + { + Ok(fd) => fd, + Err(_) => return, + }; + + if memfd.as_file().set_len(size as u64).is_err() { + return; + } + + let mmap = match unsafe { memmap2::MmapMut::map_mut(memfd.as_file()) } { + Ok(m) => m, + Err(_) => return, + }; + + let pool = shm.create_pool(memfd.as_file().as_fd(), size, qh, ()); + let buffer = pool.create_buffer( + 0, + buffer_width, + buffer_height, + 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.surface.set_buffer_scale(scale); + } + + /// Clear input region so the shadow does not intercept clicks. + pub fn update_input_region( + &self, + compositor: &wl_compositor::WlCompositor, + qh: &QueueHandle, + ) where + D: 'static + wayland_client::Dispatch, + { + let region = compositor.create_region(qh, ()); + self.surface.set_input_region(Some(®ion)); + region.destroy(); + } + + /// Render the shadow into the current buffer. + pub fn render( + &mut self, + frame_width: i32, + frame_height: i32, + shadow_size: i32, + shadow_color: u32, + scale: i32, + ) -> bool { + if self.width <= 0 + || self.height <= 0 + || self.buffer_width <= 0 + || self.buffer_height <= 0 + || frame_width <= 0 + || frame_height <= 0 + { + return false; + } + + let key = ShadowKey { + frame_width, + frame_height, + shadow_size: shadow_size.max(0), + shadow_color, + scale: scale.max(1), + }; + let needs_rebuild = match self.cache { + Some(ref cache) => cache.key != key, + None => true, + }; + + if !needs_rebuild { + return false; + } + + let mut pixels = vec![0u8; (self.buffer_width * self.buffer_height * 4) as usize]; + clear_buffer(&mut pixels); + if let Some(mut renderer) = + Renderer::new(&mut pixels, self.buffer_width, self.buffer_height) + { + draw_shadow_soft( + &mut renderer, + frame_width, + frame_height, + shadow_size.max(0), + shadow_size.max(0) / 2, + shadow_color, + scale.max(1), + ); + } + + self.cache = Some(ShadowCache { key, pixels }); + let pixels = match self.cache.as_ref() { + Some(cache) => cache.pixels.as_slice(), + None => return false, + }; + if let Some(ref mut mmap) = self.mmap { + let dst = mmap.as_mut(); + if dst.len() != pixels.len() { + return false; + } + dst.copy_from_slice(pixels); + return true; + } + false + } + + /// Set the offset position relative to the window. + pub fn set_offset(&self, x: i32, y: i32) { + self.decoration.set_offset(x, y); + } + + /// Sync the next commit with render_finish. + pub fn sync_next_commit(&self) { + self.decoration.sync_next_commit(); + } + + /// Commit the shadow surface. + 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.buffer_width, self.buffer_height); + self.surface.commit(); + } + } +} + +fn rgba_to_argb(rgba: u32) -> u32 { + let r = (rgba >> 24) & 0xff; + let g = (rgba >> 16) & 0xff; + let b = (rgba >> 8) & 0xff; + let a = rgba & 0xff; + (a << 24) | (r << 16) | (g << 8) | b +} + +fn clear_buffer(pixels: &mut [u8]) { + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&[0, 0, 0, 0]); + } +} + +fn draw_shadow_soft( + renderer: &mut Renderer, + frame_width: i32, + frame_height: i32, + shadow_size: i32, + corner_radius: i32, + shadow_color: u32, + scale: i32, +) { + if shadow_size <= 0 { + return; + } + + let base_alpha = (shadow_color & 0xff) as u8; + if base_alpha == 0 { + return; + } + + let shadow_size_px = shadow_size * scale; + let frame_width_px = frame_width * scale; + let frame_height_px = frame_height * scale; + if shadow_size_px <= 0 || frame_width_px <= 0 || frame_height_px <= 0 { + return; + } + + let base_rgb = shadow_color & 0xffffff00; + let width = renderer.width(); + let height = renderer.height(); + let inner_x0 = shadow_size_px; + let inner_y0 = shadow_size_px; + let _inner_x1 = inner_x0 + frame_width_px; + let _inner_y1 = inner_y0 + frame_height_px; + + let r_px = (corner_radius * scale).clamp(0, (frame_width_px.min(frame_height_px) / 2).max(0)); + let cx = inner_x0 + frame_width_px / 2; + let cy = inner_y0 + frame_height_px / 2; + let bx = frame_width_px as f32 / 2.0; + let by = frame_height_px as f32 / 2.0; + let r = r_px as f32; + + let pixels = renderer.data_mut(); + let stride = width * 4; + for y in 0..height { + let row = (y * stride) as usize; + for x in 0..width { + let px = (x as f32 + 0.5) - cx as f32; + let py = (y as f32 + 0.5) - cy as f32; + let qx = px.abs() - (bx - r); + let qy = py.abs() - (by - r); + let mx = qx.max(0.0); + let my = qy.max(0.0); + let outside = (mx * mx + my * my).sqrt(); + let inside = qx.max(qy).min(0.0); + let dist = outside + inside - r; + if dist <= 0.0 || dist > shadow_size_px as f32 { + continue; + } + let t = (dist / shadow_size_px as f32).min(1.0); + let falloff = 1.0 - t; + let alpha = (base_alpha as f32 * falloff * falloff) + .round() + .clamp(0.0, 255.0) as u8; + if alpha == 0 { + continue; + } + let color = base_rgb | alpha as u32; + let argb = rgba_to_argb(color).to_ne_bytes(); + let idx = row + (x * 4) as usize; + if idx + 4 <= pixels.len() { + pixels[idx..idx + 4].copy_from_slice(&argb); + } + } + } +} diff --git a/src/canoe/window.rs b/src/canoe/window.rs index 8afa89e..dcf88e8 100644 --- a/src/canoe/window.rs +++ b/src/canoe/window.rs @@ -3,6 +3,7 @@ #![allow(dead_code)] use super::titlebar::TitlebarButton; +use super::WindowShadow; use crate::config::WindowDecoration; use crate::protocol::river_window_management_v1::client::river_window_v1::Edges; use crate::protocol::{RiverNodeV1, RiverOutputV1, RiverWindowV1}; @@ -160,6 +161,8 @@ pub struct Window { pub position_undefined: bool, /// Titlebar for server-side decoration pub titlebar: Option, + /// Shadow decoration surface + pub shadow: Option, /// Hovered titlebar button (if any) pub titlebar_hovered: Option, /// Pressed titlebar button (if any) @@ -209,6 +212,7 @@ impl Window { unhandled_events: VecDeque::new(), position_undefined: true, titlebar: None, + shadow: None, titlebar_hovered: None, titlebar_pressed: None, titlebar_left_down: false, diff --git a/src/config.rs b/src/config.rs index 0e3d56a..6a3ffdb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -123,6 +123,8 @@ pub struct UiConfig { pub button_bg: u32, pub button_highlight: u32, pub button_shadow: u32, + pub window_shadow_size: i32, + pub window_shadow_color: u32, pub font_name: Option, pub font_size: f32, pub desktop_background: u32, @@ -145,6 +147,8 @@ impl Default for UiConfig { button_bg: 0xC0C0C0FF, button_highlight: 0xFFFFFFFF, button_shadow: 0x808080FF, + window_shadow_size: 20, + window_shadow_color: 0x00000040, font_name: None, font_size: 12.0, desktop_background: 0x008080FF, @@ -250,6 +254,9 @@ struct UiConfigFile { button_highlight: Option, #[serde(default, deserialize_with = "deserialize_opt_color")] button_shadow: Option, + window_shadow_size: Option, + #[serde(default, deserialize_with = "deserialize_opt_color")] + window_shadow_color: Option, font_name: Option, #[serde(default, deserialize_with = "deserialize_opt_f32")] font_size: Option, @@ -325,6 +332,12 @@ impl UiConfig { if let Some(color) = overrides.button_shadow { self.button_shadow = color; } + if let Some(size) = overrides.window_shadow_size { + self.window_shadow_size = size.max(0); + } + if let Some(color) = overrides.window_shadow_color { + self.window_shadow_color = color; + } if let Some(font_name) = overrides.font_name { let trimmed = font_name.trim().to_string(); self.font_name = if trimmed.is_empty() { diff --git a/src/main.rs b/src/main.rs index beac911..c0d364b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -936,12 +936,123 @@ impl Dispatch for AppState { Event::RenderStart => { state.context.borrow_mut().handle_render_start(); - // Update titlebars + // Update shadows and titlebars if let Some(ref shm) = state.globals.shm { let context = state.context.borrow(); let focused_window = context.focused_window; let ui = &context.config.ui; + for window in context.windows.values() { + let mut w = window.borrow_mut(); + + if w.hidden { + continue; + } + + let width = w.width; + let height = w.height; + if width <= 0 || height <= 0 { + continue; + } + + let is_fullscreen = + !matches!(w.fullscreen, canoe::window::FullscreenState::None); + let is_dragging = w.titlebar_left_down + || matches!( + w.operator, + canoe::window::Operator::Move { .. } + | canoe::window::Operator::Resize { .. } + ); + let base_shadow_size = ui.window_shadow_size.max(0); + let mut shadow_size = if is_dragging { + base_shadow_size / 2 + } else { + base_shadow_size + }; + if is_fullscreen || w.maximized { + shadow_size = 0; + } + let shadow_color = ui.window_shadow_color; + let use_ssd = w.decoration != Some(crate::config::WindowDecoration::Csd); + let border_width = if use_ssd { ui.border_width } else { 0 }; + let titlebar_height = if use_ssd { + canoe::titlebar::titlebar_height(ui) + } else { + 0 + }; + let swallow_top = if use_ssd { w.swallow_top } else { 0 }; + let content_height = if use_ssd { + (height - swallow_top).max(1) + } else { + height.max(1) + }; + let frame_width = (width + border_width * 2).max(1); + let frame_height = + (content_height + titlebar_height + border_width * 2).max(1); + let shadow_frame_height = (frame_height - (shadow_size / 2)).max(1); + + let output_scale = w + .output + .as_ref() + .and_then(|o| o.upgrade()) + .map(|o| o.borrow().scale); + let shadow_output_names = w + .shadow + .as_ref() + .map(|s| s.output_names.clone()) + .unwrap_or_default(); + let shadow_surface_scale = if shadow_output_names.is_empty() { + None + } else { + let mut max_scale = 1; + for name in &shadow_output_names { + if let Some(scale) = state.globals.wl_output_scales.get(name) { + max_scale = max_scale.max(*scale); + } + } + Some(max_scale) + }; + let scale = shadow_surface_scale.or(output_scale).unwrap_or(1); + + if let Some(ref mut shadow) = w.shadow { + shadow.ensure_buffer( + frame_width, + frame_height, + shadow_size, + shm, + qh, + scale, + ); + if let Some(ref compositor) = state.globals.compositor { + shadow.update_input_region(compositor, qh); + } + let did_render = shadow.render( + frame_width, + shadow_frame_height, + shadow_size, + shadow_color, + scale, + ); + let offset_x = if use_ssd { + -border_width - shadow_size + } else { + -shadow_size + }; + let shadow_shift = shadow_size / 2; + let offset_y = if use_ssd { + -border_width - titlebar_height + swallow_top - shadow_size + + shadow_shift + } else { + -shadow_size + shadow_shift + }; + shadow.set_offset(offset_x, offset_y); + if did_render && shadow.buffer.is_some() { + shadow.sync_next_commit(); + shadow.commit(); + } + } + } + for (&window_id, window) in &context.windows { let mut w = window.borrow_mut(); @@ -1032,6 +1143,18 @@ impl Dispatch for AppState { // Create titlebar if compositor is available if let Some(ref compositor) = state.globals.compositor { + // Create surface for shadow + let shadow_surface = + compositor.create_surface(qh, ShadowSurfaceData { window_id }); + + // Create decoration for shadow (below window content) + let shadow_decoration: RiverDecorationV1 = + id.get_decoration_below(&shadow_surface, qh, window_id); + + // Create shadow surface + let shadow = canoe::WindowShadow::new(shadow_surface, shadow_decoration); + window.borrow_mut().shadow = Some(shadow); + // Create surface for titlebar let surface = compositor.create_surface(qh, TitlebarSurfaceData { window_id }); @@ -2493,6 +2616,12 @@ struct TitlebarSurfaceData { window_id: canoe::WindowId, } +// Shadow surface user data +struct ShadowSurfaceData { + #[allow(dead_code)] + window_id: canoe::WindowId, +} + impl Dispatch for AppState { fn event( _state: &mut Self, @@ -2560,6 +2689,73 @@ impl Dispatch for AppState { } } +impl Dispatch for AppState { + fn event( + _state: &mut Self, + _proxy: &wl_surface::WlSurface, + _event: wl_surface::Event, + _data: &ShadowSurfaceData, + _conn: &Connection, + _qh: &QueueHandle, + ) { + let (output_name, is_enter) = match &_event { + wl_surface::Event::Enter { output } => { + let name = _state + .globals + .wl_outputs + .iter() + .find_map(|(name, wl_output)| { + if wl_output == output { + Some(*name) + } else { + None + } + }); + (name, true) + } + wl_surface::Event::Leave { output } => { + let name = _state + .globals + .wl_outputs + .iter() + .find_map(|(name, wl_output)| { + if wl_output == output { + Some(*name) + } else { + None + } + }); + (name, false) + } + _ => (None, false), + }; + let Some(output_name) = output_name else { + return; + }; + + let window = { + let context = _state.context.borrow(); + context.windows.get(&_data.window_id).cloned() + }; + let Some(window) = window else { + return; + }; + + let mut w = window.borrow_mut(); + let Some(ref mut shadow) = w.shadow else { + return; + }; + + if is_enter { + if !shadow.output_names.contains(&output_name) { + shadow.output_names.push(output_name); + } + } else { + shadow.output_names.retain(|name| *name != output_name); + } + } +} + impl Dispatch for AppState { fn event( _state: &mut Self,