diff --git a/Cargo.lock b/Cargo.lock index 38be843..fe1d090 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1570,12 +1570,14 @@ dependencies = [ name = "ferese" version = "0.1.0" dependencies = [ + "ab_glyph", "calloop 0.14.4", "ferese-animation", "ferese-core", "ferese-ipc", "ferese-layout", "ferese-protocols", + "fontdb", "image", "libc", "serde", diff --git a/README.md b/README.md index 89ec9dc..2df5591 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,15 @@ out over 80 ms, with a 64 MiB total snapshot budget and automatic release. All monitors share one elapsed-time animation clock, and window content, clips, borders and shadows use the same physical-pixel edges. +Overview has a horizontally scrollable workspace strip with live window +thumbnails and an accent outline on the workspace shown by each output. +Click a workspace to change the window grid without leaving overview; click +a window to focus it and exit, or press Escape to dismiss. Only the workspace +strip has a tinted surface; the window grid sits directly over the wallpaper. +Strip labels use `[theme.typography].font_family` and rasterize at output scale. +The strip fades independently and preserves its opacity on rapid reversals, +including when the selected workspace is empty. + Ferese renders `[theme.background]` wallpapers directly: `path` selects the image and `mode = "fill"` or `"fit"` controls crop/letterboxing. One decoded image and one texture per GPU context serve every output; resizing requires diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 05df774..696e286 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true rust-version.workspace = true [dependencies] +ab_glyph = "0.2.32" +fontdb = "0.23.0" calloop.workspace = true ferese-animation = { path = "../crates/ferese-animation" } ferese-core = { path = "../crates/ferese-core" } diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 517e34b..99c4abb 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -167,6 +167,8 @@ impl Default for InactiveDimConfig { #[derive(Debug, Default, Deserialize)] struct ThemeConfig { + #[serde(default)] + typography: OverviewTypographyConfig, #[serde(default)] background: crate::wallpaper::WallpaperConfig, #[serde(default)] @@ -182,11 +184,23 @@ struct ThemeConfig { } impl Config { + pub(crate) fn overview_font_family(&self) -> String { + self.theme + .typography + .font_family + .clone() + .unwrap_or_else(|| "sans-serif".into()) + } pub(crate) fn wallpaper_settings(&self) -> crate::wallpaper::WallpaperConfig { self.theme.background.clone() } } +#[derive(Debug, Default, Deserialize)] +struct OverviewTypographyConfig { + font_family: Option, +} + #[derive(Debug, Default, Deserialize)] struct ThemeSurfaceConfig { #[serde(default)] diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 864f2a0..83be370 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -195,6 +195,9 @@ impl Ferese { } if event.state() == ButtonState::Pressed { + if self.click_overview_workspace(position) { + return; + } if let Some(window) = self.window_under_visual(position) && let Some(id) = self.window_ids.get(&window).copied() { @@ -230,6 +233,17 @@ impl Ferese { let vertical = event.amount(Axis::Vertical).unwrap_or_else(|| { event.amount_v120(Axis::Vertical).unwrap_or(0.0) * 15.0 / 120.0 }); + let pointer = self.seat.get_pointer().expect("seat has a pointer"); + if self.scroll_overview_strip( + pointer.current_location(), + if horizontal != 0.0 { + horizontal + } else { + vertical + }, + ) { + return; + } let mut frame = AxisFrame::new(event.time() as u32).source(source); if horizontal != 0.0 { frame = frame.value(Axis::Horizontal, horizontal); diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 3aea5f6..c0cf253 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -231,6 +231,7 @@ impl Ferese { "toggle-fullscreen" => self.toggle_focused_fullscreen(), "toggle-maximized" => self.toggle_focused_maximized(), "toggle-layout" => self.toggle_layout_mode(), + "toggle-overview" => self.toggle_overview(), "cycle-column-width" => self.cycle_focused_column_width(), "center-column" => self.center_focused_column(), "consume" => self.consume_focused_window(), diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 8a1b714..7ef7c23 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -72,6 +72,7 @@ fn main() -> Result<(), Box> { viewport_spring_config: config.viewport_spring_config()?, output_profiles, wallpaper: config.wallpaper_settings(), + overview_font_family: config.overview_font_family(), }; let mut state = Ferese::new(&mut event_loop, display, runtime)?; backends::init(launch.backend, &mut event_loop, &mut state)?; diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index f4fab74..c115133 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -1,28 +1,206 @@ use std::{collections::HashMap, time::Duration}; -use ferese_animation::{AnimatedRect, SpringConfig}; +use ferese_animation::{AnimatedRect, AnimatedValue, SpringConfig}; +use ferese_core::{OutputId, WorkspaceId}; use ferese_layout::{Direction, Rect, WindowId}; +use smithay::{ + output::Output, + utils::{Logical, Point}, +}; use crate::Ferese; +use ab_glyph::{Font, FontArc, FontVec, ScaleFont}; +use smithay::{ + backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer}, + utils::Transform, +}; const OVERVIEW_MARGIN: f64 = 48.0; const OVERVIEW_GAP: f64 = 24.0; +const WORKSPACE_CARD_GAP: f64 = 8.0; const MAX_PREVIEW_SCALE: f64 = 0.82; -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct OverviewState { active: bool, selected: Option, presentations: HashMap, + opacity: AnimatedValue, + strip_offsets: HashMap, + font_family: String, + font_loaded: bool, + font: Option, + labels: HashMap< + (String, u64), + ( + MemoryRenderBuffer, + smithay::utils::Size, + ), + >, +} + +impl Default for OverviewState { + fn default() -> Self { + Self { + active: false, + selected: None, + presentations: HashMap::new(), + opacity: AnimatedValue::new(0.0), + strip_offsets: HashMap::new(), + font_family: "sans-serif".into(), + font_loaded: false, + font: None, + labels: HashMap::new(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct WorkspaceCard { + pub workspace: WorkspaceId, + pub rect: Rect, + pub selected: bool, + pub windows: Vec<(WindowId, Rect)>, +} + +pub(crate) fn workspace_strip(bounds: Rect) -> Rect { + let inset = 24.0_f64.min(bounds.width * 0.05); + Rect::new( + bounds.x + inset, + bounds.y + 16.0, + (bounds.width - inset * 2.0).max(1.0), + (bounds.height * 0.24).clamp(40.0, 132.0), + ) +} + +fn window_area(bounds: Rect) -> Rect { + let strip = workspace_strip(bounds); + let top = strip.y + strip.height + 16.0; + Rect::new( + bounds.x, + top, + bounds.width, + (bounds.y + bounds.height - top).max(1.0), + ) +} + +fn contains(rect: Rect, point: Point) -> bool { + point.x >= rect.x + && point.x < rect.x + rect.width + && point.y >= rect.y + && point.y < rect.y + rect.height +} + +fn strip_capacity(strip: Rect) -> usize { + (((strip.width - 24.0) + WORKSPACE_CARD_GAP) / (160.0 + WORKSPACE_CARD_GAP)) + .floor() + .max(1.0) as usize +} + +fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 { + let width = count as f64 * card_width + count.saturating_sub(1) as f64 * WORKSPACE_CARD_GAP; + strip.x + (strip.width - width) * 0.5 } impl OverviewState { + pub(crate) fn with_font_family(font_family: String) -> Self { + Self { + font_family, + ..Self::default() + } + } + + fn label( + &mut self, + text: &str, + scale: f64, + ) -> Option<( + MemoryRenderBuffer, + smithay::utils::Size, + )> { + let key = (text.to_owned(), scale.to_bits()); + if let Some(buffer) = self.labels.get(&key) { + return Some(buffer.clone()); + } + if !self.font_loaded { + self.font_loaded = true; + let mut database = fontdb::Database::new(); + database.load_system_fonts(); + let families = [ + fontdb::Family::Name(&self.font_family), + fontdb::Family::SansSerif, + ]; + self.font = database + .query(&fontdb::Query { + families: &families, + ..Default::default() + }) + .and_then(|id| { + database.with_face_data(id, |bytes, index| { + FontVec::try_from_vec_and_index(bytes.to_vec(), index) + .ok() + .map(FontArc::new) + }) + }) + .flatten(); + } + let font = self.font.as_ref()?; + let scaled = font.as_scaled((13.0 * scale) as f32); + let width = (text + .chars() + .map(|ch| scaled.h_advance(font.glyph_id(ch))) + .sum::() + .ceil() as i32 + + 4) + .max(1); + let height = (18.0 * scale).ceil() as i32; + let mut pixels = vec![0_u8; (width * height * 4) as usize]; + let mut pen = 2.0; + for ch in text.chars() { + let id = font.glyph_id(ch); + let glyph = id.with_scale_and_position( + scaled.scale(), + ab_glyph::point(pen, scaled.ascent() + 1.0), + ); + pen += scaled.h_advance(id); + if let Some(outline) = font.outline_glyph(glyph) { + let bounds = outline.px_bounds(); + outline.draw(|x, y, coverage| { + let x = x as i32 + bounds.min.x as i32; + let y = y as i32 + bounds.min.y as i32; + if x >= 0 && x < width && y >= 0 && y < height { + let offset = ((y * width + x) * 4) as usize; + let alpha = (coverage * 255.0).round() as u8; + pixels[offset..offset + 4].copy_from_slice(&[alpha, alpha, alpha, alpha]); + } + }); + } + } + let buffer = MemoryRenderBuffer::from_slice( + &pixels, + Fourcc::Abgr8888, + (width, height), + 1, + Transform::Normal, + None, + ); + if self.labels.len() >= 128 { + self.labels.clear(); + } + let label = (buffer, (width, height).into()); + self.labels.insert(key, label.clone()); + Some(label) + } pub(crate) fn is_active(&self) -> bool { self.active } pub(crate) fn is_presenting(&self) -> bool { - self.active || !self.presentations.is_empty() + self.active || !self.presentations.is_empty() || self.opacity.current > 0.001 + } + + pub(crate) fn opacity(&self) -> f32 { + self.opacity.current.clamp(0.0, 1.0) as f32 } pub(crate) fn selected(&self) -> Option { @@ -73,6 +251,11 @@ impl OverviewState { animations_enabled: bool, ) { self.active = true; + self.opacity.retarget_preserving_motion(1.0); + if !animations_enabled { + self.opacity.snap(); + } + self.strip_offsets.clear(); self.selected = selected .filter(|id| targets.contains_key(id)) .or_else(|| targets.keys().copied().min_by_key(|id| id.0)); @@ -93,10 +276,20 @@ impl OverviewState { presentation.snap(); } } + if self + .selected + .is_none_or(|id| !self.presentations.contains_key(&id)) + { + self.selected = self.presentations.keys().copied().min_by_key(|id| id.0); + } } fn exit(&mut self, normal: HashMap, animations_enabled: bool) { self.active = false; + self.opacity.retarget_preserving_motion(0.0); + if !animations_enabled { + self.opacity.snap(); + } self.selected = None; self.presentations.retain(|id, presentation| { let Some(target) = normal.get(id).copied() else { @@ -135,15 +328,43 @@ impl OverviewState { self.presentations.clear(); } + if animations_enabled { + // Geometry's 0.1-pixel tolerance is far too coarse for opacity: + // it would abruptly drop the last ten percent of the fade. + active_animation |= self.opacity.advance( + delta, + SpringConfig { + position_tolerance: 0.001, + velocity_tolerance: 0.005, + ..spring + }, + ); + } else { + self.opacity.snap(); + } + active_animation } } impl Ferese { + pub(crate) fn overview_workspace_label( + &mut self, + workspace: WorkspaceId, + scale: f64, + ) -> Option<( + MemoryRenderBuffer, + smithay::utils::Size, + )> { + let name = self.workspaces.workspace(workspace)?.name.clone(); + self.overview.label(&name, scale) + } pub(crate) fn set_overview_active(&mut self, active: bool) { if self.overview.is_active() == active { return; } + // Account for idle time before starting a new transition, not after. + self.advance_animations(std::time::Instant::now()); if active { let targets = self.overview_targets(); @@ -233,6 +454,155 @@ impl Ferese { self.overview.is_active() && self.overview.selected() == Some(id) } + pub(crate) fn overview_workspace_cards(&self, output: &Output) -> Vec { + let Some(bounds) = self.output_bounds_for(output) else { + return Vec::new(); + }; + let Some(output_id) = self.output_id(output) else { + return Vec::new(); + }; + let selected = self.output_workspaces.active_workspace(output_id); + let strip = workspace_strip(bounds); + let capacity = strip_capacity(strip); + let mut workspaces = self.workspaces.iter().collect::>(); + workspaces.sort_by_key(|workspace| workspace.name.parse::().unwrap_or(workspace.id.0)); + let selected_index = workspaces + .iter() + .position(|workspace| Some(workspace.id) == selected) + .unwrap_or(0); + let max_offset = workspaces.len().saturating_sub(capacity); + let offset = self + .overview + .strip_offsets + .get(&output_id) + .copied() + .unwrap_or_else(|| selected_index.saturating_sub(capacity - 1)) + .min(max_offset); + let card_width = + ((strip.width - 24.0 - WORKSPACE_CARD_GAP * capacity.saturating_sub(1) as f64) + / capacity as f64) + .min(160.0) + .max(1.0); + let visible_count = workspaces.len().saturating_sub(offset).min(capacity); + let start_x = centered_row_start(strip, visible_count, card_width); + workspaces + .into_iter() + .skip(offset) + .take(capacity) + .enumerate() + .map(|(index, workspace)| { + let rect = Rect::new( + start_x + index as f64 * (card_width + WORKSPACE_CARD_GAP), + strip.y + 12.0, + card_width, + (strip.height - 24.0).max(1.0), + ); + let windows = workspace + .layout + .window_ids() + .chain(workspace.floating.iter().copied()) + .take(4) + .filter_map(|id| { + let source = self.window_geometry.get(&id)?.visual.current; + Some((id, source)) + }) + .collect::>(); + let preview_bounds = Rect::new( + rect.x + 6.0, + rect.y + 6.0, + (rect.width - 12.0).max(1.0), + (rect.height - 26.0).max(1.0), + ); + let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0) + .into_iter() + .collect(); + WorkspaceCard { + workspace: workspace.id, + rect, + selected: Some(workspace.id) == selected, + windows, + } + }) + .collect() + } + + pub(crate) fn overview_strip_at(&self, point: Point) -> Option { + if !self.overview.is_active() { + return None; + } + self.space + .outputs() + .find(|output| { + self.output_bounds_for(output) + .is_some_and(|bounds| contains(workspace_strip(bounds), point)) + }) + .cloned() + } + + pub(crate) fn click_overview_workspace(&mut self, point: Point) -> bool { + let Some(output) = self.overview_strip_at(point) else { + return false; + }; + let card = self + .overview_workspace_cards(&output) + .into_iter() + .find(|card| contains(card.rect, point)); + if let Some(card) = card { + let Some(output_id) = self.output_id(&output) else { + return true; + }; + if self + .output_workspaces + .switch_workspace(output_id, card.workspace) + .is_ok() + { + // Existing monitor ownership wins; never steal another output's workspace. + self.restore_output_focus(); + self.overview.strip_offsets.clear(); + self.relayout(); + self.restore_keyboard_focus(); + } + } + crate::backends::direct::render_all(self); + true + } + + pub(crate) fn scroll_overview_strip(&mut self, point: Point, delta: f64) -> bool { + let Some(output) = self.overview_strip_at(point) else { + return false; + }; + let Some(output_id) = self.output_id(&output) else { + return true; + }; + let Some(bounds) = self.output_bounds_for(&output) else { + return true; + }; + let capacity = strip_capacity(workspace_strip(bounds)); + let cards = self.overview_workspace_cards(&output); + let mut ordered = self.workspaces.iter().collect::>(); + ordered.sort_by_key(|workspace| workspace.name.parse::().unwrap_or(workspace.id.0)); + let current = cards + .first() + .and_then(|card| { + ordered + .iter() + .position(|workspace| workspace.id == card.workspace) + }) + .unwrap_or(0); + let next = if delta > 0.0 { + current.saturating_add(1) + } else if delta < 0.0 { + current.saturating_sub(1) + } else { + current + }; + self.overview + .strip_offsets + .insert(output_id, next.min(ordered.len().saturating_sub(capacity))); + crate::backends::direct::render_all(self); + true + } + fn overview_targets(&self) -> HashMap { let mut targets = HashMap::new(); @@ -257,7 +627,7 @@ impl Ferese { .collect::>(); windows.sort_by_key(|(id, _)| id.0); - for (id, target) in overview_layout(bounds, &windows) { + for (id, target) in overview_layout(window_area(bounds), &windows) { let normal = self.window_geometry[&id].visual.current; targets.insert(id, (normal, target)); } @@ -275,22 +645,31 @@ impl Ferese { } fn overview_layout(bounds: Rect, windows: &[(WindowId, Rect)]) -> HashMap { + preview_layout(bounds, windows, OVERVIEW_MARGIN, OVERVIEW_GAP) +} + +fn preview_layout( + bounds: Rect, + windows: &[(WindowId, Rect)], + margin: f64, + gap: f64, +) -> HashMap { if windows.is_empty() { return HashMap::new(); } - let content_width = (bounds.width - OVERVIEW_MARGIN * 2.0).max(1.0); - let content_height = (bounds.height - OVERVIEW_MARGIN * 2.0).max(1.0); + let margin = margin.min(bounds.width * 0.1).min(bounds.height * 0.1); + let content_width = (bounds.width - margin * 2.0).max(1.0); + let content_height = (bounds.height - margin * 2.0).max(1.0); let aspect = (content_width / content_height).max(0.1); let columns = ((windows.len() as f64 * aspect).sqrt().ceil() as usize).clamp(1, windows.len()); let rows = windows.len().div_ceil(columns); - let cell_width = ((content_width - OVERVIEW_GAP * columns.saturating_sub(1) as f64) - / columns as f64) - .max(1.0); + let cell_width = + ((content_width - gap * columns.saturating_sub(1) as f64) / columns as f64).max(1.0); let cell_height = - ((content_height - OVERVIEW_GAP * rows.saturating_sub(1) as f64) / rows as f64).max(1.0); - let origin_x = bounds.x + OVERVIEW_MARGIN; - let origin_y = bounds.y + OVERVIEW_MARGIN; + ((content_height - gap * rows.saturating_sub(1) as f64) / rows as f64).max(1.0); + let origin_x = bounds.x + margin; + let origin_y = bounds.y + margin; windows .iter() @@ -303,8 +682,8 @@ fn overview_layout(bounds: Rect, windows: &[(WindowId, Rect)]) -> HashMap strip.y + strip.height); + let windows = [(WindowId(1), Rect::new(0.0, 0.0, 800.0, 900.0))]; + assert!( + overview_layout(area, &windows) + .values() + .all(|rect| rect.y >= area.y) + ); + } + } + + #[test] + fn empty_workspace_fades_without_an_abrupt_last_ten_percent() { + let mut overview = OverviewState::default(); + overview.enter(HashMap::new(), None, false); + overview.exit(HashMap::new(), true); + assert!(overview.is_presenting()); + assert_eq!(overview.opacity(), 1.0); + let mut previous = 1.0; + for _ in 0..100 { + overview.advance(Duration::from_millis(16), SpringConfig::default(), true); + let current = overview.opacity(); + assert!(current <= previous); + if current == 0.0 { + assert!(previous < 0.01); + } + previous = current; + } + assert_eq!(overview.opacity(), 0.0); + assert!(!overview.is_presenting()); + } + + #[test] + fn reversing_overview_preserves_current_opacity() { + let mut overview = OverviewState::default(); + overview.enter(HashMap::new(), None, true); + overview.advance(Duration::from_millis(48), SpringConfig::default(), true); + let opacity = overview.opacity(); + assert!(opacity > 0.0 && opacity < 1.0); + overview.exit(HashMap::new(), true); + assert_eq!(overview.opacity(), opacity); + overview.advance(Duration::from_millis(16), SpringConfig::default(), true); + let opacity = overview.opacity(); + overview.enter(HashMap::new(), None, true); + assert_eq!(overview.opacity(), opacity); + } + + #[test] + fn reduced_motion_snaps_overview_chrome() { + let mut overview = OverviewState::default(); + overview.enter(HashMap::new(), None, false); + assert_eq!(overview.opacity(), 1.0); + overview.exit(HashMap::new(), false); + assert_eq!(overview.opacity(), 0.0); + assert!(!overview.is_presenting()); + } + + #[test] + fn thumbnail_grid_preserves_aspect_and_does_not_overlap_labels() { + let bounds = Rect::new(30.0, 50.0, 148.0, 76.0); + let windows = (1..=4) + .map(|id| (WindowId(id), Rect::new(0.0, 0.0, 1000.0, 800.0))) + .collect::>(); + let layout = preview_layout(bounds, &windows, 4.0, 4.0); + for rect in layout.values() { + assert!((rect.width / rect.height - 1.25).abs() < 0.001); + assert!(rect.y + rect.height <= bounds.y + bounds.height); + assert!(rect.x + rect.width <= bounds.x + bounds.width); + } + } + #[test] fn overview_grid_fits_windows_inside_the_output() { let bounds = Rect::new(0.0, 0.0, 1920.0, 1080.0); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 94c4ebd..712673c 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -208,6 +208,7 @@ pub struct Ferese { } pub struct RuntimeConfig { + pub(crate) overview_font_family: String, pub(crate) wallpaper: crate::wallpaper::WallpaperConfig, pub layout_mode: LayoutMode, pub gap_config: GapConfig, @@ -519,7 +520,7 @@ impl Ferese { pending_screencopies: Vec::new(), shell_resources: Vec::new(), shell_snapshot_serial: 0, - overview: crate::overview::OverviewState::default(), + overview: crate::overview::OverviewState::with_font_family(config.overview_font_family), next_window_id: 1, next_output_id: 1, last_animation_tick: start_time, @@ -693,7 +694,12 @@ impl Ferese { pub fn window_under_visual(&self, position: Point) -> Option { let workspace = self.workspace_under_pointer(position)?; - self.space.elements().rev().find_map(|window| { + let candidates = if self.overview.is_active() { + self.window_ids.keys().cloned().collect::>() + } else { + self.space.elements().rev().cloned().collect::>() + }; + candidates.iter().find_map(|window| { let id = self.window_ids.get(window)?; if self.workspaces.workspace_for_window(*id) != Some(workspace) { return None; diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index d7766a6..f223d0c 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -20,7 +20,7 @@ use smithay::{ Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage, memory::MemoryRenderBufferRenderElement, render_elements, - solid::{SolidColorBuffer, SolidColorRenderElement}, + solid::SolidColorRenderElement, surface::{ WaylandSurfaceRenderElement, WaylandSurfaceTexture, render_elements_from_surface_tree, @@ -554,15 +554,7 @@ pub(crate) struct RoundedClipPrograms { #[derive(Debug)] pub(crate) struct OverviewScrim { - buffer: SolidColorBuffer, -} - -impl OverviewScrim { - fn new(size: Size) -> Self { - Self { - buffer: SolidColorBuffer::new(size, [0.02, 0.025, 0.035, 0.62]), - } - } + chrome: HashMap<(u64, bool), HashMap>, } #[derive(Clone, Debug)] @@ -1013,10 +1005,27 @@ fn output_elements( &[Layer::Overlay, Layer::Top] }; elements.extend(layer_elements(state, renderer, output, upper_layers)); - let windows = state - .space - .elements() - .rev() + if state.overview.is_presenting() { + elements.extend(overview_strip_elements( + state, + renderer, + output, + output_geometry, + scale, + )); + } + let mut candidates = if state.overview.is_active() { + state.window_ids.keys().cloned().collect::>() + } else { + state.space.elements().rev().cloned().collect::>() + }; + if state.overview.is_active() { + candidates.sort_by_key(|window| { + std::cmp::Reverse(state.window_ids.get(window).map_or(0, |id| id.0)) + }); + } + let windows = candidates + .iter() .filter_map(|window| { let id = *state.window_ids.get(window)?; // Scrolling columns may sit outside their monitor's rectangle. @@ -1219,11 +1228,6 @@ fn output_elements( )); } } - if state.overview.is_presenting() - && let Some(scrim) = overview_scrim_element(state, output, output_geometry, scale) - { - elements.push(scrim.into()); - } elements.extend(layer_elements( state, renderer, @@ -1236,29 +1240,218 @@ fn output_elements( elements } -fn overview_scrim_element( +#[allow(clippy::too_many_arguments)] +fn overview_chrome_element( state: &mut Ferese, + renderer: &mut GlesRenderer, output: &Output, output_geometry: Rectangle, scale: f64, -) -> Option { + key: (u64, bool), + rect: ferese_layout::Rect, + color: [f32; 4], + width: f64, + radius: f64, +) -> Option { let output_id = state.output_id(output)?; - let scrim = state + let geometry = rounded_visual_rect(rect, output_geometry.loc); + let physical = physical_rect(rect, output_geometry.loc, scale); + let mode = output.current_mode()?; + let parameters = BorderParameters { + geometry, + clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), + radius: (radius * scale) as f32, + width: (width * scale) as f32, + color, + }; + let program = if key.1 { + rounded_clip_program(state, renderer)?.border + } else { + material_program(state, renderer)?.0 + }; + let uniforms = |p: &BorderParameters| { + if key.1 { + border_uniforms(p) + } else { + vec![ + Uniform::new("visible_rect", p.clip_rect).into_owned(), + Uniform::new("material_radius", p.radius).into_owned(), + Uniform::new("tint", p.color).into_owned(), + Uniform::new("paint_mode", 0.0_f32).into_owned(), + Uniform::new("shadow_rect", p.clip_rect).into_owned(), + Uniform::new("shadow_values", [0.0_f32; 2]).into_owned(), + ] + } + }; + let context = renderer.context_id().erased(); + let chrome = &mut state .overview_scrims .entry(output_id) - .or_insert_with(|| OverviewScrim::new(output_geometry.size)); + .or_insert_with(|| OverviewScrim { + chrome: HashMap::new(), + }) + .chrome; + let cached = chrome + .entry(key) + .or_default() + .entry(context) + .or_insert_with(|| CachedBorder { + element: PixelShaderElement::new( + program, + geometry, + None, + 1.0, + uniforms(¶meters), + RenderElementKind::Unspecified, + ), + parameters: parameters.clone(), + }); + if cached.parameters != parameters { + if cached.parameters.geometry != geometry { + cached.element.resize(geometry, None); + } + cached.element.update_uniforms(uniforms(¶meters)); + cached.parameters = parameters; + } + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: physical, + }) +} - scrim - .buffer - .update(output_geometry.size, [0.02, 0.025, 0.035, 0.62]); - - Some(SolidColorRenderElement::from_buffer( - &scrim.buffer, - Point::::default(), +fn overview_strip_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + output_geometry: Rectangle, + scale: f64, +) -> Vec { + let alpha = state.overview.opacity(); + if alpha <= 0.001 { + return Vec::new(); + } + let Some(bounds) = state.output_bounds_for(output) else { + return Vec::new(); + }; + let cards = state.overview_workspace_cards(output); + let mut elements = Vec::new(); + let programs = rounded_clip_program(state, renderer); + for card in cards { + // Front-to-back: outline and live miniatures above each card, all above + // the strip. Nothing is painted behind the main overview window grid. + if card.selected { + let mut color = state.theme_settings.accent_color.0; + color[3] *= alpha; + if let Some(border) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (card.workspace.0, true), + card.rect, + color, + 1.0, + 10.0, + ) { + elements.push(border.into()); + } + } + if let Some(programs) = &programs { + for (id, rect) in &card.windows { + if let Some(window) = state + .window_ids + .iter() + .find_map(|(window, candidate)| (*candidate == *id).then(|| window.clone())) + { + elements.extend(rounded_window_elements( + renderer, + &window, + rounded_visual_rect(*rect, output_geometry.loc), + physical_rect(*rect, output_geometry.loc, scale), + scale, + 4.0, + alpha, + true, + output, + programs.texture.clone(), + ConstrainScaleBehavior::Stretch, + )); + } + } + } + if let Some((buffer, size)) = state.overview_workspace_label(card.workspace, scale) { + let location = Point::::from(( + ((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale) + .round() as i32 + - size.w / 2, + ((card.rect.y - f64::from(output_geometry.loc.y) + card.rect.height - 20.0) * scale) + .round() as i32, + )); + if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( + renderer, + location.to_f64(), + &buffer, + Some(alpha), + None, + Some( + ( + (f64::from(size.w) / scale).round() as i32, + (f64::from(size.h) / scale).round() as i32, + ) + .into(), + ), + RenderElementKind::Unspecified, + ) { + let element = RescaleRenderElement::from_element(element, location, 1.0); + let element = + RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative); + if let Some(element) = CropRenderElement::from_element( + element, + scale, + physical_rect(card.rect, output_geometry.loc, scale), + ) { + elements.push(element.into()); + } + } + } + let mut card_color = state.theme_settings.surface_base_color.0; + for component in &mut card_color[..3] { + *component = (*component + 0.035).min(1.0); + } + card_color[3] = alpha * 0.75; + if let Some(card_fill) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (card.workspace.0, false), + card.rect, + card_color, + 0.0, + 10.0, + ) { + elements.push(card_fill.into()); + } + } + let mut color = state.theme_settings.surface_base_color.0; + color[3] = alpha * state.theme_settings.panel_opacity as f32; + if let Some(strip) = overview_chrome_element( + state, + renderer, + output, + output_geometry, scale, - 1.0, - RenderElementKind::Unspecified, - )) + (0, false), + crate::overview::workspace_strip(bounds), + color, + 0.0, + 16.0, + ) { + elements.push(strip.into()); + } + elements } fn rounded_clip_program( diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 5d23457..ae0152e 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -48,8 +48,8 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), json!({ "index": index }) } "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" - | "cycle-column-width" | "center-column" | "consume" | "expel" | "close" - | "get-focused-window" | "get-workspaces" | "get-outputs" | "exit" => { + | "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel" + | "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "exit" => { if !positional.is_empty() { return Err(format!("{command} does not accept arguments")); } @@ -78,13 +78,22 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] mod tests { use super::*; + #[test] + fn overview_toggle_accepts_no_arguments() { + assert_eq!( + parse_args(["toggle-overview".into()]).unwrap(), + ("toggle-overview".into(), json!({})) + ); + assert!(parse_args(["toggle-overview".into(), "extra".into()]).is_err()); + } + #[test] fn parses_direction_and_workspace_commands() { assert_eq!(