Compare commits

...

1 Commits

Author SHA1 Message Date
jyviko 2605261665 Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)
TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 22:46:17 -05:00
7 changed files with 44 additions and 39 deletions
+2 -2
View File
@@ -318,7 +318,7 @@ fn selected_row_paints_tab_soft_fill_and_accent_text_in_both_layouts() {
let mut app = SolveApp::new(Default::default(), Default::default());
app.ui.layout = mode;
app.ui.tab = tab.into();
crate::theme::TabAccent::publish(&app, &ctx);
app.publish_tab_accent(&ctx);
let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), tab);
let mut row_rect = Rect::NOTHING;
let out = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
@@ -677,7 +677,7 @@ fn collapsed_rail_shares_header_hover_inset_and_expands_from_its_whole_card() {
app.ui.tab = "SURFACE".into();
app.preferences.timeline_left = true;
app.preferences.timeline_left_browser_collapsed = collapsed;
crate::theme::TabAccent::publish(&app, &ctx);
app.publish_tab_accent(&ctx);
let bounds = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 700.0));
let draw = |app: &mut SolveApp, events| {
crate::test_support::frame(&ctx, egui::RawInput { screen_rect: Some(bounds), events, ..Default::default() }, |ui| {
+9 -3
View File
@@ -837,6 +837,12 @@ impl Drop for SolveApp {
}
impl SolveApp {
/// Publish this frame's tab accent from the open tab, the sketch being edited and the active
/// tool or dialog, the three values [`theme::TabAccent::publish`] takes.
pub(crate) fn publish_tab_accent(&self, ctx: &egui::Context) {
theme::TabAccent::publish(&self.ui.tab, self.session.active_sketch.is_some(), self.tool.is_some() || self.dialog.is_some(), ctx);
}
pub fn new(mut session: Session, services: Services) -> Self {
#[cfg(test)]
let image_host = Some(solidcraft_engine::render::job::CpuHost::cooperative());
@@ -3276,7 +3282,7 @@ impl SolveApp {
theme::apply(ctx);
self.styled = true;
}
theme::TabAccent::publish(self, ctx);
self.publish_tab_accent(ctx);
if !self.fitted {
self.fit_view();
self.fitted = true;
@@ -3520,7 +3526,7 @@ impl SolveApp {
if !floating && !self.embedded {
toolbar::toolbar(self, ui);
}
theme::TabAccent::publish(self, ui.ctx());
self.publish_tab_accent(ui.ctx());
browser::publish_row_prefs(self, ui.ctx());
if !floating {
agent_chat::show(self, ui);
@@ -3573,7 +3579,7 @@ impl SolveApp {
self.show_workspace(&mut viewport_ui);
shell::paint_top_corners(ui.painter(), full, theme::Tokens::get().app_bar);
toolbar::toolbar(self, ui);
theme::TabAccent::publish(self, ui.ctx());
self.publish_tab_accent(ui.ctx());
}
if floating && show_timeline && !left_timeline {
timeline::timeline(self, ui);
+1 -1
View File
@@ -975,7 +975,7 @@ impl crate::SolveApp {
self.shell.history_dock_space = None;
self.shell.history_folding = None;
self.shell.history_fold_motion = None;
crate::theme::TabAccent::publish(self, ui.ctx());
self.publish_tab_accent(ui.ctx());
crate::browser::publish_row_prefs(self, ui.ctx());
let mut placement = render_domain;
self.shell.status_bar = if crate::status_bar::shown(self) {
@@ -519,7 +519,7 @@ fn stage_catalogs_fill_a_290_by_530_panel_and_keep_two_line_labels_inside_tiles(
h.app.preferences.render_browser_side = "left".into();
super::super::select_image_method(&mut h.app, method);
library::open_category(&mut h.app, kind);
crate::theme::TabAccent::publish(&h.app, &h.ctx);
h.app.publish_tab_accent(&h.ctx);
let document = h.app.session.doc.to_json();
let history = (h.app.session.undo.len(), h.app.session.redo.len());
// Panel::workspace reserves an 8 pt bottom gap in these bounds.
+28 -29
View File
@@ -20,7 +20,7 @@ thread_local! {
/// must not change one another's palette while a frame or icon texture is being built.
static DARK: Cell<bool> = const { Cell::new(true) };
/// The colour vision mode, per thread. Each app
/// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the
/// sets it from its own preferences at the start of every frame (in its frame logic), on the
/// thread that draws it. Tests run many apps side by side on their own threads in one process:
/// with one global, a test in a mode changed the colours under another one
/// (`hover_stays_off_during_spin_and_animated_views` failed that way). No worker thread reads
@@ -580,7 +580,11 @@ pub(crate) fn scoped_tokens(tokens: Tokens) -> impl Drop {
}
/// Temporarily bind a panel's paint palette without publishing a theme change.
pub(crate) fn scoped_panel_tokens(tokens: Tokens) -> impl Drop {
///
/// `Tokens::get_in` returns these tokens first until the returned guard drops, which restores
/// the previous override. A host uses it to draw any component in another palette or accent
/// (for example another workspace's) without editing the component.
pub fn scoped_panel_tokens(tokens: Tokens) -> impl Drop {
struct Restore {
previous: Option<Tokens>,
// The override belongs to this UI thread, so its guard must not move to another one.
@@ -791,11 +795,13 @@ impl TabAccent {
ctx.data(|d| d.get_temp::<Option<&'static str>>(egui::Id::unique("sc_tab_icon_accent"))).flatten()
}
pub fn publish(app: &crate::SolveApp, ctx: &egui::Context) {
/// Publish the accent of `tab` for this frame. `sketch_open` is whether a sketch is being
/// edited, `tool_active` whether a tool or dialog is open; both only steer focus painting.
pub fn publish(tab: &str, sketch_open: bool, tool_active: bool, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let (published, accent, fading, style_surfaces) = ctx.data_mut(|data| {
let state = data.get_temp_mut_or_default::<TabThemeState>(egui::Id::unique(TAB_STATE));
let (published, accent, fading) = state.publish(&app.ui.tab, now);
let (published, accent, fading) = state.publish(tab, now);
let style_surfaces = published.tab_surfaces || state.styled_surfaces;
state.styled_surfaces = published.tab_surfaces;
(published, accent, fading, style_surfaces)
@@ -818,9 +824,9 @@ impl TabAccent {
}
ctx.data_mut(|d| {
d.insert_temp(egui::Id::unique("sc_tab_accent"), accent);
d.insert_temp(egui::Id::unique("sc_tab_icon_accent"), icon_accent_for_tab(&app.ui.tab));
d.insert_temp(egui::Id::unique("sc_sketch_open"), app.session.active_sketch.is_some());
d.insert_temp(egui::Id::unique("sc_tool_accent"), app.tool.is_some() || app.dialog.is_some());
d.insert_temp(egui::Id::unique("sc_tab_icon_accent"), icon_accent_for_tab(tab));
d.insert_temp(egui::Id::unique("sc_sketch_open"), sketch_open);
d.insert_temp(egui::Id::unique("sc_tool_accent"), tool_active);
});
}
@@ -1958,15 +1964,13 @@ mod tests {
#[test]
fn tabs_publish_requests_repaints_only_during_fade_and_zero_is_instant() {
use crate::theme_files::OptionValue;
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let ctx = crate::fonts::test_context();
let mut state = tabs_state("dark", BTreeMap::new());
state.publish("MODEL", 0.0);
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(TAB_STATE), state));
app.ui.tab = "SKETCH".into();
let frame = |time| {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
TabAccent::publish(&app, ui.ctx());
TabAccent::publish("SKETCH", false, false, ui.ctx());
crate::gpu::paint_background(ui.painter(), ui.max_rect(), &Tokens::get());
});
let colors: Vec<_> = out
@@ -2175,7 +2179,7 @@ mod tests {
themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.as_str(), crate::theme_files::resolve(theme))));
let mut failures = Vec::new();
for (name, tokens) in themes {
for tab in crate::workspace::TABS.iter().copied().chain(["SKETCH", "MESH", "FORM", "SHEET METAL", "RENDER"]) {
for tab in super::TAB_NAMES.iter().copied() {
let accent = super::TabAccent::for_tab(&tokens, tab);
let ratio = super::contrast_ratio(accent.ink, accent.soft);
if ratio < 4.5 {
@@ -2209,20 +2213,16 @@ mod tests {
#[test]
fn tab_accent_context_is_per_app_and_does_not_change_widget_visuals() {
let mut harness = crate::scenario::Harness::new();
let app = &mut harness.app;
let first = crate::fonts::test_context();
let second = crate::fonts::test_context();
let before = first.style_of(egui::Theme::Dark).visuals.clone();
app.ui.tab = "SURFACE".into();
super::TabAccent::publish(app, &first);
super::TabAccent::publish("SURFACE", false, false, &first);
assert_eq!(super::TabAccent::current(&first).color, super::Tokens::get().surface_fill);
assert_eq!(super::TabAccent::current(&second).color, super::Tokens::get().accent);
assert_eq!(super::TabAccent::current_icon_accent(&first), Some("surface"));
assert_eq!(super::TabAccent::current_icon_accent(&second), None);
assert_eq!(first.style_of(egui::Theme::Dark).visuals, before, "dialog OK, checkboxes and status styles must not inherit the tab");
app.ui.tab = "MODEL".into();
super::TabAccent::publish(app, &first);
super::TabAccent::publish("MODEL", false, false, &first);
assert_eq!(super::TabAccent::current(&first).soft, super::Tokens::get().accent_soft);
assert_eq!(super::TabAccent::current_icon_accent(&first), None);
}
@@ -2232,7 +2232,6 @@ mod tests {
use crate::theme_files::{OptionValue, ThemeFile};
for base in ["dark", "light"] {
let ctx = crate::fonts::test_context();
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let file = crate::theme_files::tab_fixture(base);
set_dark(file.base.is_dark());
set_vision(ColorVision::Off);
@@ -2247,19 +2246,19 @@ mod tests {
});
set_custom(custom)
};
let publish = |app: &crate::SolveApp, time| {
ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| TabAccent::publish(app, ui.ctx())).textures_delta.clear();
let publish = |tab: &str, time| {
ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| TabAccent::publish(tab, false, false, ui.ctx()))
.textures_delta
.clear();
};
prepare(&file, Some(true));
apply(&ctx);
let before = ctx.global_style().visuals.clone();
let base_custom = CUSTOM.get();
app.ui.tab = "MODEL".into();
publish(&app, 0.0);
publish("MODEL", 0.0);
assert_eq!(ctx.global_style().visuals, before);
app.ui.tab = "SKETCH".into();
publish(&app, 1.0);
publish(&app, 1.12);
publish("SKETCH", 1.0);
publish("SKETCH", 1.12);
let panel = Tokens::get().panel;
assert_ne!(panel, before.panel_fill, "exercise an actual animated panel fill");
let mut animated = before.clone();
@@ -2268,24 +2267,24 @@ mod tests {
assert_eq!(ctx.global_style().visuals, animated, "all non-surface widget/selection/status visuals stay unchanged");
// Repair either owned fill independently, even when the panel already matches.
ctx.global_style_mut(|style| style.visuals.window_fill = before.window_fill);
publish(&app, 1.12);
publish("SKETCH", 1.12);
assert_eq!(ctx.global_style().visuals, animated);
assert!(!prepare(&file, Some(false)), "toggling surfaces does not change the base Custom tokens");
assert_eq!(CUSTOM.get(), base_custom);
// Deliberately omit apply(): a full restyle would conceal stale animated fills.
publish(&app, 1.13);
publish("SKETCH", 1.13);
assert_eq!(ctx.global_style().visuals, before, "turning surfaces off must restore both base fills");
assert!(!prepare(&file, Some(true)));
publish(&app, 2.0);
publish("SKETCH", 2.0);
assert_ne!(ctx.global_style().visuals.panel_fill, before.panel_fill);
let mut plain = file.clone();
plain.tabs.clear();
plain.options.clear();
assert!(!prepare(&plain, None), "a no-option theme can retain the exact same base tokens");
assert_eq!(CUSTOM.get(), base_custom);
publish(&app, 2.1);
publish("SKETCH", 2.1);
assert_eq!(ctx.global_style().visuals, before, "restoring a no-option theme must release the surface fills");
}
}
+2 -2
View File
@@ -1216,7 +1216,7 @@ const ELEMENTS: &[Element] = &[
let (r, _) = ui.allocate_exact_size(vec2(360.0, 360.0), egui::Sense::hover());
let mut app = crate::test_support::sample_app();
app.ui.tab = "SURFACE".into();
crate::theme::TabAccent::publish(&app, ui.ctx());
app.publish_tab_accent(ui.ctx());
radial_menu(ui, r.center(), &crate::context_menu::radial_items(&app), Some(3));
}),
element!("workspace_menu", |ui, _| {
@@ -2773,7 +2773,7 @@ fn confirm_radial_and_history_rows_keep_multitone_artwork() {
ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() });
let mut app = crate::SolveApp::new(Default::default(), Default::default());
app.ui.tab = tab.into();
crate::theme::TabAccent::publish(&app, &ctx);
app.publish_tab_accent(&ctx);
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(600.0, 360.0));
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(bounds), ..Default::default() }, |ui| {
ui.painter().rect_filled(bounds, 0.0, Tokens::get().panel);
@@ -888,7 +888,7 @@ fn sketch_previews_selection_band_and_number_focus_follow_sketch_accent() {
}
h.app.ui.tab = "MODEL".into(); // A sketch remains open when another ribbon tab is selected.
h.app.tool = crate::tools::Tool::for_command("sketch.line");
crate::theme::TabAccent::publish(&h.app, &h.ctx);
h.app.publish_tab_accent(&h.ctx);
let output = draw(&h.ctx, |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut String::from("20")));
r.request_focus();