fix(layout): refine tab indicator sizing and theme styling
This commit is contained in:
1 parent
042698ea61
commit
eaeabebe73
9 files changed
+135
-35
No files matched your search
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ pub(crate) enum OverviewChromePart {
|
|||||||
TabActive,
|
TabActive,
|
||||||
TabRail,
|
TabRail,
|
||||||
TabList,
|
TabList,
|
||||||
|
TabListOutline,
|
||||||
TabRow,
|
TabRow,
|
||||||
TabFlash,
|
TabFlash,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user