fix(layout): refine tab indicator sizing and theme styling

This commit is contained in:
Samuel Onoja committed 2026-10-13 00:21:10 +01:00
1 parent 042698ea61
commit eaeabebe73
9 files changed
+135 -35

No files matched your search

+10 -2
View File
@@ -12,6 +12,8 @@ pub(super) struct TabIndicatorConfig {
row_height: f64, row_height: f64,
padding: f64, padding: f64,
font_size: f64, font_size: f64,
outline_width: f64,
outline_color: Option<String>,
flash_duration_ms: u64, flash_duration_ms: u64,
active_color: Option<String>, active_color: Option<String>,
inactive_color: Option<String>, inactive_color: Option<String>,
@@ -23,7 +25,7 @@ impl Default for TabIndicatorConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
width: 4.0, width: 4.0,
segment_height: 18.0, segment_height: 24.0,
segment_gap: 4.0, segment_gap: 4.0,
hit_width: 12.0, hit_width: 12.0,
gap_threshold: 8.0, gap_threshold: 8.0,
@@ -31,6 +33,8 @@ impl Default for TabIndicatorConfig {
row_height: 28.0, row_height: 28.0,
padding: 4.0, padding: 4.0,
font_size: 12.0, font_size: 12.0,
outline_width: 0.5,
outline_color: None,
flash_duration_ms: 1000, flash_duration_ms: 1000,
active_color: None, active_color: None,
inactive_color: None, inactive_color: None,
@@ -51,6 +55,8 @@ pub(crate) struct TabIndicatorSettings {
pub row_height: f64, pub row_height: f64,
pub padding: f64, pub padding: f64,
pub font_size: f64, pub font_size: f64,
pub outline_width: f64,
pub outline_color: Option<[f32; 4]>,
pub flash_duration: std::time::Duration, pub flash_duration: std::time::Duration,
pub active_color: Option<[f32; 4]>, pub active_color: Option<[f32; 4]>,
pub inactive_color: Option<[f32; 4]>, pub inactive_color: Option<[f32; 4]>,
@@ -77,6 +83,8 @@ impl Config {
width, width,
hit_width, hit_width,
font_size, 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_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")?, 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")?, 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(); let c = Config::parse_source("").unwrap().tab_indicator_settings().unwrap();
assert_eq!( assert_eq!(
(c.width, c.segment_height, c.segment_gap, c.hit_width, c.gap_threshold), (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_eq!(c.flash_duration.as_millis(), 1000);
assert!(c.active_color.is_none()); assert!(c.active_color.is_none());
+14
View File
@@ -576,6 +576,20 @@ impl Ferese {
Some(buffer) 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::<f64>()
.ceil()
+ 4.0
} else {
chars.count() as f64 * size * 0.65 + 4.0
}
}
pub(crate) fn indicator_label( pub(crate) fn indicator_label(
&mut self, &mut self,
text: &str, text: &str,
+1
View File
@@ -335,6 +335,7 @@ pub(crate) enum OverviewChromePart {
TabActive, TabActive,
TabRail, TabRail,
TabList, TabList,
TabListOutline,
TabRow, TabRow,
TabFlash, TabFlash,
} }
+3 -2
View File
@@ -30,13 +30,13 @@ pub(super) fn overview_chrome_element(
focus_gradient_line: [0.0; 4], focus_gradient_line: [0.0; 4],
focus_mix: 0.0, 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 rounded_clip_program(&mut state.render, renderer)?.border
} else { } else {
material_program(&mut state.render, renderer)?.0 material_program(&mut state.render, renderer)?.0
}; };
let uniforms = |p: &BorderParameters| { let uniforms = |p: &BorderParameters| {
if key.1 == OverviewChromePart::Outline { if matches!(key.1, OverviewChromePart::Outline | OverviewChromePart::TabListOutline) {
border_uniforms(p) border_uniforms(p)
} else { } else {
tint_uniforms(p) tint_uniforms(p)
@@ -126,6 +126,7 @@ pub(super) fn overview_strip_elements(
| OverviewChromePart::Tab | OverviewChromePart::Tab
| OverviewChromePart::TabActive | OverviewChromePart::TabActive
| OverviewChromePart::TabRail | OverviewChromePart::TabRail
| OverviewChromePart::TabListOutline
| OverviewChromePart::TabList | OverviewChromePart::TabList
| OverviewChromePart::TabRow | OverviewChromePart::TabRow
| OverviewChromePart::TabFlash => state.windows.window(ferese_layout::WindowId(*id)).is_some(), | OverviewChromePart::TabFlash => state.windows.window(ferese_layout::WindowId(*id)).is_some(),
+1
View File
@@ -77,6 +77,7 @@ impl RenderResources {
OverviewChromePart::Tab OverviewChromePart::Tab
| OverviewChromePart::TabActive | OverviewChromePart::TabActive
| OverviewChromePart::TabRail | OverviewChromePart::TabRail
| OverviewChromePart::TabListOutline
| OverviewChromePart::TabList | OverviewChromePart::TabList
| OverviewChromePart::TabRow | OverviewChromePart::TabRow
| OverviewChromePart::TabFlash | OverviewChromePart::TabFlash
+51 -15
View File
@@ -14,12 +14,20 @@ fn text_element(
) -> Option<AnimatedWindowRenderElement> { ) -> Option<AnimatedWindowRenderElement> {
let settings = state.tab_indicator_settings; let settings = state.tab_indicator_settings;
let color = settings.text_color.unwrap_or(state.theme_settings.text_primary_color.0); 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 available = (rect.width - 16.0).max(0.0);
let mut chars = text.chars().filter(|c| !c.is_control()); let mut title: String = text.chars().filter(|c| !c.is_control()).take(1024).collect();
let mut title = chars.by_ref().take(limit).collect::<String>(); if state.indicator_text_width(&title, settings.font_size) > available || text.chars().count() > 1024 {
if chars.next().is_some() { let mut boundaries: Vec<_> = title.char_indices().map(|(index, _)| index).collect();
title.pop(); boundaries.push(title.len());
title.push('…'); 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 (buffer, size) = state.indicator_label(&title, scale, color, settings.font_size)?;
let x = if centered { let x = if centered {
@@ -93,9 +101,20 @@ pub(super) fn elements(
let anchor = bar.group.windows[0]; let anchor = bar.group.windows[0];
let accent = settings.active_color.unwrap_or(state.theme_settings.accent_color.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 inactive = settings.inactive_color.unwrap_or(state.theme_settings.border_color.0);
let background = settings let mut background = settings
.background_color .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 radius = state.theme_settings.material_radius;
let hover = state let hover = state
.tab_indicator .tab_indicator
@@ -132,11 +151,10 @@ pub(super) fn elements(
elements.push(element); elements.push(element);
} }
if id == bar.group.active { if id == bar.group.active {
let mut fill = background; // Match the control center's selected shell buttons: a quiet
for i in 0..3 { // foreground tint, with accent reserved for the active rail.
fill[i] += (accent[i] - fill[i]) * 0.2; let mut fill = settings.text_color.unwrap_or(state.theme_settings.text_primary_color.0);
} fill[3] = 0.14 * alpha * hover;
fill[3] = alpha * hover;
if let Some(element) = overview_chrome_element( if let Some(element) = overview_chrome_element(
state, state,
renderer, renderer,
@@ -147,12 +165,29 @@ pub(super) fn elements(
rect, rect,
fill, fill,
0.0, 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()); 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( if let Some(element) = overview_chrome_element(
state, state,
renderer, renderer,
@@ -180,7 +215,8 @@ pub(super) fn elements(
bar.group.windows.len(), bar.group.windows.len(),
title(state, bar.group.active, capture) 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( let bounds = Rect::new(
output_geometry.loc.x as f64, output_geometry.loc.x as f64,
output_geometry.loc.y as f64, output_geometry.loc.y as f64,
+34 -9
View File
@@ -16,6 +16,7 @@ pub(crate) struct TabIndicatorState {
pub hover: Option<Overlay>, pub hover: Option<Overlay>,
pub flash: Option<Overlay>, pub flash: Option<Overlay>,
pub first_row: usize, pub first_row: usize,
pub hover_width: f64,
timer: Option<RegistrationToken>, timer: Option<RegistrationToken>,
} }
@@ -197,13 +198,16 @@ impl Ferese {
boundary = boundary.max(rect.x + rect.width); boundary = boundary.max(rect.x + rect.width);
} }
} }
IndicatorGeometry::new( let mut settings = self.tab_indicator_settings;
content, if self
bounds, .tab_indicator
boundary, .hover
bar.group.windows.len(), .as_ref()
self.tab_indicator_settings, .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<f64, Logical>) -> bool { fn tab_indicator_can_hit(&self, active: WindowId, p: Point<f64, Logical>) -> bool {
@@ -325,6 +329,27 @@ impl Ferese {
}) })
.unwrap_or(0); .unwrap_or(0);
self.tab_indicator.first_row = selected; 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 { self.tab_indicator.hover = Some(Overlay {
anchor, anchor,
opacity: AnimatedValue::new(0.0), opacity: AnimatedValue::new(0.0),
@@ -501,9 +526,9 @@ mod tests {
let g = IndicatorGeometry::new(content, output, content.x - gap, 3, c).unwrap(); let g = IndicatorGeometry::new(content, output, content.x - gap, 3, c).unwrap();
assert_eq!( assert_eq!(
(g.rail.width, g.rail.height, g.segment_height, g.pitch), (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.); assert_eq!(g.hit.width, 12.);
if gap >= 8. { if gap >= 8. {
assert!(g.rail.x >= content.x - gap && g.rail.x + g.rail.width <= content.x); assert!(g.rail.x >= content.x - gap && g.rail.x + g.rail.width <= content.x);
@@ -1103,6 +1103,13 @@ fn tab_indicator_hover_switch_and_flash_preserve_window_geometry() {
state.update_tab_indicator_hover(point); state.update_tab_indicator_hover(point);
assert_eq!(state.tab_indicator.hover.as_ref().unwrap().opacity.current, 1.); assert_eq!(state.tab_indicator.hover.as_ref().unwrap().opacity.current, 1.);
assert_eq!(state.windows.geometry(&b).unwrap().logical, before); 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 row = g.row(0);
let point = (row.x + row.width * 0.5, row.y + row.height * 0.5).into(); let point = (row.x + row.width * 0.5, row.y + row.height * 0.5).into();
state.update_tab_indicator_hover(point); state.update_tab_indicator_hover(point);
+14 -7
View File
@@ -583,7 +583,7 @@ Configure the scrolling indicator under `scrolling.tab-indicator`:
scrolling { scrolling {
tab-indicator { tab-indicator {
width 4 width 4
segment-height 18 segment-height 24
segment-gap 4 segment-gap 4
hit-width 12 hit-width 12
gap-threshold 8 gap-threshold 8
@@ -591,18 +591,25 @@ scrolling {
row-height 28 row-height 28
padding 4 padding 4
font-size 12 font-size 12
outline-width 0.5
flash-duration-ms 1000 flash-duration-ms 1000
} }
} }
``` ```
Sizes are logical pixels. `flash-duration-ms 0` disables keyboard feedback. Sizes are logical pixels. The hover list fits its longest title, up to
Optional `active-color`, `inactive-color`, `background-color`, and `text-color` `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 accept the same color syntax as theme colors. When omitted, they follow the
current theme's accent, border, base surface, and primary text respectively. current theme's accent, border, raised surface, primary text, and border respectively.
The rail has fully rounded ends; the title list follows the shell corner radius. The selected row uses the control center’s faint text-color fill. The rail has
Both use flat fills, without blur or shadows. Changes apply on configuration fully rounded ends; the title list follows the shell corner radius.
reload. 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: To make future scrolling columns tabbed by default: