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,
padding: f64,
font_size: f64,
outline_width: f64,
outline_color: Option<String>,
flash_duration_ms: u64,
active_color: Option<String>,
inactive_color: Option<String>,
@@ -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());
+14
View File
@@ -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::<f64>()
.ceil()
+ 4.0
} else {
chars.count() as f64 * size * 0.65 + 4.0
}
}
pub(crate) fn indicator_label(
&mut self,
text: &str,
+1
View File
@@ -335,6 +335,7 @@ pub(crate) enum OverviewChromePart {
TabActive,
TabRail,
TabList,
TabListOutline,
TabRow,
TabFlash,
}
+3 -2
View File
@@ -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(),
+1
View File
@@ -77,6 +77,7 @@ impl RenderResources {
OverviewChromePart::Tab
| OverviewChromePart::TabActive
| OverviewChromePart::TabRail
| OverviewChromePart::TabListOutline
| OverviewChromePart::TabList
| OverviewChromePart::TabRow
| OverviewChromePart::TabFlash
+51 -15
View File
@@ -14,12 +14,20 @@ fn text_element(
) -> Option<AnimatedWindowRenderElement> {
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::<String>();
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,
+34 -9
View File
@@ -16,6 +16,7 @@ pub(crate) struct TabIndicatorState {
pub hover: Option<Overlay>,
pub flash: Option<Overlay>,
pub first_row: usize,
pub hover_width: f64,
timer: Option<RegistrationToken>,
}
@@ -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<f64, Logical>) -> 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);
@@ -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);
+14 -7
View File
@@ -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: