Title bar: outline inactive tabs and keep the Home tab readable on hover (#1116)

Inactive document tabs get a faint outline so neighbouring tabs stay apart, and the Home tab uses the same hover tint as the design tabs so its label stays readable in the light theme. Fixes #1115.
This commit is contained in:
Tobias Breckle authored and GitHub committed 2026-10-14 13:06:23 -04:00
1 parent 54a8c807bf
commit 805ea45be6
3 files changed
+70 -19

No files matched your search

+2
View File
@@ -75,6 +75,8 @@ impl Tokens {
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
rubber_band: Color32::from_rgb(30, 120, 230),
app_bar_text: Color32::from_rgb(230, 234, 242),
app_bar_hover: Color32::from_rgba_unmultiplied_const(255, 255, 255, 28),
app_bar_tab_edge: Color32::from_rgba_unmultiplied_const(255, 255, 255, 40),
timeline_item: Color32::WHITE,
timeline_item_error: Color32::from_rgb(250, 220, 220),
timeline_marker: Color32::from_rgb(70, 76, 88),
+64 -19
View File
@@ -313,17 +313,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let t = Tokens::get();
let home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0));
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
ui.painter().rect_filled(
home,
egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 },
if app.home.open {
t.toolbar
} else if response.hovered() {
t.hover
} else {
t.app_bar
},
);
tab_shape(ui.painter(), &t, home, app.home.open, response.hovered());
let home_text = app.tr("ui.home");
ui.painter().text(
home.center(),
@@ -357,14 +347,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
crate::titlebar::toggle_maximize(ui.ctx());
}
}
let fill = if active {
t.toolbar
} else if resp.hovered() {
Color32::from_white_alpha(28)
} else {
t.app_bar
};
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
tab_shape(ui.painter(), &t, tr, active, resp.hovered());
let ink = if active { t.text } else { t.app_bar_text };
let name_display = if name == "Untitled" { app.tr("ui.untitled") } else { name };
let label = format!("{name_display}{}", if *dirty { " •" } else { "" });
@@ -447,6 +430,25 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
}
}
/// One tab of the strip: the active tab takes the toolbar's colour and runs into it; the others
/// sit on the application bar with a faint outline (no bottom edge) so neighbours stay apart.
fn tab_shape(painter: &egui::Painter, t: &Tokens, r: Rect, active: bool, hovered: bool) {
let corners = egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 };
let fill = if active {
t.toolbar
} else if hovered {
t.app_bar_hover
} else {
t.app_bar
};
painter.rect_filled(r, corners, fill);
if !active {
// Drawn past the bottom and clipped, so only the top and the sides show.
let outline = Rect::from_min_max(r.min, pos2(r.max.x, r.max.y + 2.0));
painter.with_clip_rect(r).rect_stroke(outline, corners, Stroke::new(1.0, t.app_bar_tab_edge), egui::StrokeKind::Inside);
}
}
/// Which tabs the strip shows in `room` points: (first, how many, tab width). Tabs shrink to
/// `MIN_TAB`; past that a window of tabs around the active one is shown and the rest go in the »
/// list.
@@ -588,6 +590,49 @@ mod tests {
assert_eq!(layout(12, 0, 600.0).0, 0);
}
/// The tabs sit on the dark application bar in both themes: a hovered tab (the Home tab
/// included) keeps the bar's light text readable, and an inactive tab is outlined so
/// neighbouring tabs stay apart.
#[test]
fn title_bar_tabs_are_outlined_and_keep_their_text_readable_on_hover() {
fn luminance(c: Color32) -> f32 {
let c = egui::Rgba::from(c);
0.2126 * c.r() + 0.7152 * c.g() + 0.0722 * c.b()
}
// egui blends premultiplied colours in gamma space.
fn over(top: Color32, bottom: Color32) -> Color32 {
let ch = |t: u8, b: u8| t.saturating_add(((u16::from(b) * u16::from(255 - top.a())) / 255) as u8);
Color32::from_rgb(ch(top.r(), bottom.r()), ch(top.g(), bottom.g()), ch(top.b(), bottom.b()))
}
for t in [Tokens::light(), Tokens::dark()] {
let r = Rect::from_min_size(pos2(10.0, 5.0), vec2(80.0, 30.0));
for (active, hovered) in [(false, false), (false, true), (true, false)] {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| tab_shape(ui.painter(), &t, r, active, hovered));
out.textures_delta.clear();
let rects: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(rect) => Some((rect.clone(), s.clip_rect)),
_ => None,
})
.collect();
let fill = rects.iter().find(|(rect, _)| rect.fill.a() > 0).map(|(rect, _)| rect.fill).unwrap_or_default();
if !active {
let bg = over(fill, t.app_bar);
let (hi, lo) = (luminance(t.app_bar_text).max(luminance(bg)), luminance(t.app_bar_text).min(luminance(bg)));
assert!((hi + 0.05) / (lo + 0.05) >= 4.5, "text on a tab (hovered: {hovered}) must stay readable: {bg:?} {t:?}");
}
let outline = rects.iter().find(|(rect, _)| rect.stroke.width > 0.0);
assert_eq!(outline.is_some(), !active, "only inactive tabs are outlined (active: {active})");
if let Some((rect, clip)) = outline {
assert!(rect.rect.bottom() > r.bottom() && clip.bottom() <= r.bottom(), "the outline has no bottom edge");
}
}
}
}
#[test]
fn tabs_keep_their_own_design() {
let mut a = app();
+4
View File
@@ -122,6 +122,10 @@ pub struct Tokens {
pub rubber_band: Color32,
/// Light text on the application bar.
pub app_bar_text: Color32,
/// A hovered tab on the application bar (keeps `app_bar_text` readable).
pub app_bar_hover: Color32,
/// Outline of the inactive tabs on the application bar, so neighbouring tabs stay apart.
pub app_bar_tab_edge: Color32,
/// Timeline items: normal and failed.
pub timeline_item: Color32,
pub timeline_item_error: Color32,