diff --git a/compositor/src/config/tab_indicator.rs b/compositor/src/config/tab_indicator.rs index dfbea92..e307a6f 100644 --- a/compositor/src/config/tab_indicator.rs +++ b/compositor/src/config/tab_indicator.rs @@ -12,6 +12,8 @@ pub(super) struct TabIndicatorConfig { row_height: f64, padding: f64, font_size: f64, + outline_width: f64, + outline_color: Option, flash_duration_ms: u64, active_color: Option, inactive_color: Option, @@ -23,7 +25,7 @@ impl Default for TabIndicatorConfig { fn default() -> Self { Self { width: 4.0, - segment_height: 18.0, + segment_height: 24.0, segment_gap: 4.0, hit_width: 12.0, gap_threshold: 8.0, @@ -31,6 +33,8 @@ impl Default for TabIndicatorConfig { row_height: 28.0, padding: 4.0, font_size: 12.0, + outline_width: 0.5, + outline_color: None, flash_duration_ms: 1000, active_color: None, inactive_color: None, @@ -51,6 +55,8 @@ pub(crate) struct TabIndicatorSettings { pub row_height: f64, pub padding: f64, pub font_size: f64, + pub outline_width: f64, + pub outline_color: Option<[f32; 4]>, pub flash_duration: std::time::Duration, pub active_color: Option<[f32; 4]>, pub inactive_color: Option<[f32; 4]>, @@ -77,6 +83,8 @@ impl Config { width, hit_width, font_size, + outline_width: range(c.outline_width, 0.0, 8.0, "tab_indicator.outline_width")?, + outline_color: color(&c.outline_color, "tab_indicator.outline_color")?, segment_height: range(c.segment_height, width, 128.0, "tab_indicator.segment_height")?, segment_gap: range(c.segment_gap, 0.0, 64.0, "tab_indicator.segment_gap")?, gap_threshold: range(c.gap_threshold, width, 128.0, "tab_indicator.gap_threshold")?, @@ -106,7 +114,7 @@ mod tests { let c = Config::parse_source("").unwrap().tab_indicator_settings().unwrap(); assert_eq!( (c.width, c.segment_height, c.segment_gap, c.hit_width, c.gap_threshold), - (4., 18., 4., 12., 8.) + (4., 24., 4., 12., 8.) ); assert_eq!(c.flash_duration.as_millis(), 1000); assert!(c.active_color.is_none()); diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 5511d22..14988f3 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -576,6 +576,20 @@ impl Ferese { Some(buffer) } + pub(crate) fn indicator_text_width(&self, text: &str, size: f64) -> f64 { + let chars = text.chars().filter(|c| !c.is_control()); + if let Some(font) = self.overview.font.as_ref() { + let scaled = font.as_scaled(size as f32); + chars + .map(|c| f64::from(scaled.h_advance(font.glyph_id(c)))) + .sum::() + .ceil() + + 4.0 + } else { + chars.count() as f64 * size * 0.65 + 4.0 + } + } + pub(crate) fn indicator_label( &mut self, text: &str, diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index 88c09d0..7e0c4e2 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -335,6 +335,7 @@ pub(crate) enum OverviewChromePart { TabActive, TabRail, TabList, + TabListOutline, TabRow, TabFlash, } diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 1bdc18b..c5d7471 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -30,13 +30,13 @@ pub(super) fn overview_chrome_element( focus_gradient_line: [0.0; 4], focus_mix: 0.0, }; - let program = if key.1 == OverviewChromePart::Outline { + let program = if matches!(key.1, OverviewChromePart::Outline | OverviewChromePart::TabListOutline) { rounded_clip_program(&mut state.render, renderer)?.border } else { material_program(&mut state.render, renderer)?.0 }; let uniforms = |p: &BorderParameters| { - if key.1 == OverviewChromePart::Outline { + if matches!(key.1, OverviewChromePart::Outline | OverviewChromePart::TabListOutline) { border_uniforms(p) } else { tint_uniforms(p) @@ -126,6 +126,7 @@ pub(super) fn overview_strip_elements( | OverviewChromePart::Tab | OverviewChromePart::TabActive | OverviewChromePart::TabRail + | OverviewChromePart::TabListOutline | OverviewChromePart::TabList | OverviewChromePart::TabRow | OverviewChromePart::TabFlash => state.windows.window(ferese_layout::WindowId(*id)).is_some(), diff --git a/compositor/src/render/resources.rs b/compositor/src/render/resources.rs index 4155eab..af2a12d 100644 --- a/compositor/src/render/resources.rs +++ b/compositor/src/render/resources.rs @@ -77,6 +77,7 @@ impl RenderResources { OverviewChromePart::Tab | OverviewChromePart::TabActive | OverviewChromePart::TabRail + | OverviewChromePart::TabListOutline | OverviewChromePart::TabList | OverviewChromePart::TabRow | OverviewChromePart::TabFlash diff --git a/compositor/src/render/tab_indicator.rs b/compositor/src/render/tab_indicator.rs index 45e21ab..4be5ec2 100644 --- a/compositor/src/render/tab_indicator.rs +++ b/compositor/src/render/tab_indicator.rs @@ -14,12 +14,20 @@ fn text_element( ) -> Option { let settings = state.tab_indicator_settings; let color = settings.text_color.unwrap_or(state.theme_settings.text_primary_color.0); - let limit = ((rect.width - 16.0) / (settings.font_size * 0.65)).floor().max(1.0) as usize; - let mut chars = text.chars().filter(|c| !c.is_control()); - let mut title = chars.by_ref().take(limit).collect::(); - if chars.next().is_some() { - title.pop(); - title.push('…'); + let available = (rect.width - 16.0).max(0.0); + let mut title: String = text.chars().filter(|c| !c.is_control()).take(1024).collect(); + if state.indicator_text_width(&title, settings.font_size) > available || text.chars().count() > 1024 { + let mut boundaries: Vec<_> = title.char_indices().map(|(index, _)| index).collect(); + boundaries.push(title.len()); + let fits = boundaries.partition_point(|&end| { + state.indicator_text_width(&format!("{}…", &title[..end]), settings.font_size) <= available + }); + if fits == 0 { + title.clear(); + } else { + title.truncate(boundaries[fits - 1]); + title.push('…'); + } } let (buffer, size) = state.indicator_label(&title, scale, color, settings.font_size)?; let x = if centered { @@ -93,9 +101,20 @@ pub(super) fn elements( let anchor = bar.group.windows[0]; let accent = settings.active_color.unwrap_or(state.theme_settings.accent_color.0); let inactive = settings.inactive_color.unwrap_or(state.theme_settings.border_color.0); - let background = settings + let mut background = settings .background_color - .unwrap_or(state.theme_settings.surface_base_color.0); + .unwrap_or(state.theme_settings.surface_raised_color.0); + let material = crate::effects::resolve_material( + crate::effects::SemanticRole::Popover, + state.theme_settings.material_style, + state.theme_settings.shell_opacity as f32, + ); + background[3] = if state.theme_settings.reduce_transparency || material.style == crate::config::MaterialStyle::Solid + { + 1.0 + } else { + background[3] * material.opacity + }; let radius = state.theme_settings.material_radius; let hover = state .tab_indicator @@ -132,11 +151,10 @@ pub(super) fn elements( elements.push(element); } if id == bar.group.active { - let mut fill = background; - for i in 0..3 { - fill[i] += (accent[i] - fill[i]) * 0.2; - } - fill[3] = alpha * hover; + // Match the control center's selected shell buttons: a quiet + // foreground tint, with accent reserved for the active rail. + let mut fill = settings.text_color.unwrap_or(state.theme_settings.text_primary_color.0); + fill[3] = 0.14 * alpha * hover; if let Some(element) = overview_chrome_element( state, renderer, @@ -147,12 +165,29 @@ pub(super) fn elements( rect, fill, 0.0, - (radius - g.padding).max(0.0).min(rect.height * 0.5), + (radius - g.padding).clamp(0.0, 14.0).min(rect.height * 0.5), ) { elements.push(element.into()); } } } + if settings.outline_width > 0.0 { + let color = settings.outline_color.unwrap_or(state.theme_settings.border_color.0); + if let Some(element) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (anchor.0, OverviewChromePart::TabListOutline), + g.list, + color_with_alpha(color, alpha * hover), + settings.outline_width.max(1.0 / scale), + radius.min(g.list.height * 0.5), + ) { + elements.push(element.into()); + } + } if let Some(element) = overview_chrome_element( state, renderer, @@ -180,7 +215,8 @@ pub(super) fn elements( bar.group.windows.len(), title(state, bar.group.active, capture) ); - let width = (label.chars().count() as f64 * settings.font_size * 0.65 + 24.0).min(g.list.width); + let width = (state.indicator_text_width(&label, settings.font_size) + 24.0) + .min(settings.list_width.min(f64::from(output_geometry.size.w))); let bounds = Rect::new( output_geometry.loc.x as f64, output_geometry.loc.y as f64, diff --git a/compositor/src/state/tab_indicator.rs b/compositor/src/state/tab_indicator.rs index 082196e..1d7891d 100644 --- a/compositor/src/state/tab_indicator.rs +++ b/compositor/src/state/tab_indicator.rs @@ -16,6 +16,7 @@ pub(crate) struct TabIndicatorState { pub hover: Option, pub flash: Option, pub first_row: usize, + pub hover_width: f64, timer: Option, } @@ -197,13 +198,16 @@ impl Ferese { boundary = boundary.max(rect.x + rect.width); } } - IndicatorGeometry::new( - content, - bounds, - boundary, - bar.group.windows.len(), - self.tab_indicator_settings, - ) + let mut settings = self.tab_indicator_settings; + if self + .tab_indicator + .hover + .as_ref() + .is_some_and(|h| bar.group.windows.first() == Some(&h.anchor)) + { + settings.list_width = self.tab_indicator.hover_width; + } + IndicatorGeometry::new(content, bounds, boundary, bar.group.windows.len(), settings) } fn tab_indicator_can_hit(&self, active: WindowId, p: Point) -> bool { @@ -325,6 +329,27 @@ impl Ferese { }) .unwrap_or(0); self.tab_indicator.first_row = selected; + let settings = self.tab_indicator_settings; + let text_width = self + .scrolling_tab_bar(anchor) + .into_iter() + .flat_map(|bar| &bar.group.windows) + .filter_map(|id| self.windows.record(*id)) + .map(|record| { + let title = if !record.title.trim().is_empty() { + record.title.as_str() + } else if !record.app_id.is_empty() { + record.app_id.as_str() + } else { + "Window" + }; + self.indicator_text_width(title, settings.font_size) + }) + .fold(0.0_f64, f64::max); + // Fix the width for this hover session, so metadata updates do + // not move rows or their click targets under the pointer. + self.tab_indicator.hover_width = + (text_width + 16.0 + 2.0 * settings.padding).clamp(80.0, settings.list_width); self.tab_indicator.hover = Some(Overlay { anchor, opacity: AnimatedValue::new(0.0), @@ -501,9 +526,9 @@ mod tests { let g = IndicatorGeometry::new(content, output, content.x - gap, 3, c).unwrap(); assert_eq!( (g.rail.width, g.rail.height, g.segment_height, g.pitch), - (4., 62., 18., 22.) + (4., 80., 24., 28.) ); - assert_eq!(g.rail.y, content.y + (content.height - 62.) * 0.5); + assert_eq!(g.rail.y, content.y + (content.height - 80.) * 0.5); assert_eq!(g.hit.width, 12.); if gap >= 8. { assert!(g.rail.x >= content.x - gap && g.rail.x + g.rail.width <= content.x); diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index 0bef0b9..d851481 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -1103,6 +1103,13 @@ fn tab_indicator_hover_switch_and_flash_preserve_window_geometry() { state.update_tab_indicator_hover(point); assert_eq!(state.tab_indicator.hover.as_ref().unwrap().opacity.current, 1.); assert_eq!(state.windows.geometry(&b).unwrap().logical, before); + let g = state + .tab_indicator_geometry(&bar, state.presented_window_rect(b).unwrap()) + .unwrap(); + assert!( + g.list.width < state.tab_indicator_settings.list_width, + "short titles should not fill the width cap" + ); let row = g.row(0); let point = (row.x + row.width * 0.5, row.y + row.height * 0.5).into(); state.update_tab_indicator_hover(point); diff --git a/docs/configuration.md b/docs/configuration.md index 9a6df87..1fd5027 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -583,7 +583,7 @@ Configure the scrolling indicator under `scrolling.tab-indicator`: scrolling { tab-indicator { width 4 - segment-height 18 + segment-height 24 segment-gap 4 hit-width 12 gap-threshold 8 @@ -591,18 +591,25 @@ scrolling { row-height 28 padding 4 font-size 12 + outline-width 0.5 flash-duration-ms 1000 } } ``` -Sizes are logical pixels. `flash-duration-ms 0` disables keyboard feedback. -Optional `active-color`, `inactive-color`, `background-color`, and `text-color` +Sizes are logical pixels. The hover list fits its longest title, up to +`list-width`, with an 80px minimum. Its width stays fixed while open. +`outline-width 0` hides the outline; positive widths draw at least one physical +pixel. `flash-duration-ms 0` disables keyboard feedback. +Optional `active-color`, `inactive-color`, `background-color`, `text-color`, and `outline-color` accept the same color syntax as theme colors. When omitted, they follow the -current theme's accent, border, base surface, and primary text respectively. -The rail has fully rounded ends; the title list follows the shell corner radius. -Both use flat fills, without blur or shadows. Changes apply on configuration -reload. +current theme's accent, border, raised surface, primary text, and border respectively. +The selected row uses the control center’s faint text-color fill. The rail has +fully rounded ends; the title list follows the shell corner radius. +The hover list and keyboard feedback pill follow the shell material opacity. +Solid material or Reduce transparency makes their backgrounds opaque. Text and +rail markers retain their own opacity. These controls use flat fills without +blur or shadows. Changes apply on configuration reload. To make future scrolling columns tabbed by default: