From 11ecb6405933bc79d755e83d877e18b0dba1986e Mon Sep 17 00:00:00 2001 From: Tobias Breckle <25928171+tbreckle@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:55:57 +0200 Subject: [PATCH] feat(ui): heading bands for dialogs, sketch status label, tighter palette footer (#418) - frame::heading draws a panel's title row (heading_bg) or a section heading (subheading_bg) on a band darker than the dialog, in both themes. Every docked command dialog, the Sketch Palette and Redefine Sketch Plane use it. - Sketch Palette: "Options" and a new "Sketch Status" heading are section bands; the status line sits under its own heading. - Sketch Palette: Finish Sketch no longer centres in the leftover height, so the palette ends one line below it, one line after the status. Co-authored-by: Claude Opus 5.5 Co-authored-by: bherbruck --- crates/ui-egui/src/dialogs.rs | 5 ++-- crates/ui-egui/src/frame.rs | 34 +++++++++++++++++++++ crates/ui-egui/src/locales/de.json | 1 + crates/ui-egui/src/locales/en.json | 1 + crates/ui-egui/src/locales/ja.json | 1 + crates/ui-egui/src/locales/ko.json | 1 + crates/ui-egui/src/sketch_attachment_ui.rs | 5 ++-- crates/ui-egui/src/sketch_palette.rs | 35 +++++++++++++++------- crates/ui-egui/src/theme.rs | 18 +++++++++++ 9 files changed, 87 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e1b2f058..255715ce 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1350,8 +1350,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin); // Rows that end at the right edge line up with last frame's content width. let total = crate::frame::content_width(ui, wkey, min_w); - // Header: "−" folds the dialog, the command name, "»" at the right. - ui.horizontal(|ui| { + // Header: "−" folds the dialog, the command name, "»" at the right, on the title band. + crate::frame::heading(ui, crate::frame::Level::Title, total, |ui| { + let total = total - 2.0 * crate::frame::HEADING_MARGIN; let fold = if d.collapsed { "+" } else { "−" }; if ui .add(egui::Button::new(RichText::new(fold).size(14.0)).frame(false)) diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs index 98e202e5..c8d65cae 100644 --- a/crates/ui-egui/src/frame.rs +++ b/crates/ui-egui/src/frame.rs @@ -6,6 +6,8 @@ //! - [`window`]: floating windows (Preferences, Parameters, prompts…), centred by default. //! - [`right_aligned`]: right-align a row's last items without widening the window (a //! right-to-left layout would stretch it to its maximum width). +//! - [`heading`]: a panel's title row and its section headings, each on a band darker than the +//! panel ([`Level`]), so every dialog marks them the same way. use egui::{Align2, Pos2, Ui, WidgetText, vec2}; @@ -66,6 +68,38 @@ pub fn area(id: egui::Id) -> egui::Area { egui::Area::new(id).enabled(!crate::issue_report::cad_input_blocked()) } +/// Heading levels inside a panel: its title, and the sections below it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Level { + /// The panel's title row (`Tokens::heading_bg`). + Title, + /// A section heading (`Tokens::subheading_bg`). + Section, +} + +/// Horizontal inner margin of a heading band, on each side. +pub const HEADING_MARGIN: f32 = 4.0; + +/// A heading row drawn on a band `width` wide (a content width, so the band never widens the +/// window); `add` lays out the row left to right in `width - 2 * HEADING_MARGIN`. +pub fn heading(ui: &mut Ui, level: Level, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = crate::theme::Tokens::get(); + let fill = match level { + Level::Title => t.heading_bg, + Level::Section => t.subheading_bg, + }; + let inner = (width - 2.0 * HEADING_MARGIN).max(0.0); + egui::Frame::new() + .fill(fill) + .corner_radius(3) + .inner_margin(egui::Margin::symmetric(HEADING_MARGIN as i8, 1)) + .show(ui, |ui| { + ui.set_width(inner); + ui.horizontal(add).inner + }) + .inner +} + /// The width a window's content had last frame (at least `min`), for laying out rows that end at /// the right edge. pub fn content_width(ui: &Ui, key: egui::Id, min: f32) -> f32 { diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index a864c534..b0834a8c 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -263,6 +263,7 @@ "ui.sketch_dimension": "Skizzenmaß", "ui.sketch_grid": "Skizzenraster", "ui.sketch_scale": "Skizzenskalierung", + "ui.sketch_status": "Skizzenstatus", "ui.sketches": "Skizzen", "ui.slice": "Scheibe", "ui.slot": "Langloch", diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index 4e768bde..d88984e8 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -263,6 +263,7 @@ "ui.sketch_dimension": "Sketch Dimension", "ui.sketch_grid": "Sketch Grid", "ui.sketch_scale": "Sketch Scale", + "ui.sketch_status": "Sketch Status", "ui.sketches": "Sketches", "ui.slice": "Slice", "ui.slot": "Slot", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index dd05cc52..a85c29bc 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -263,6 +263,7 @@ "ui.sketch_dimension": "スケッチ寸法", "ui.sketch_grid": "スケッチ グリッド", "ui.sketch_scale": "スケッチ尺度", + "ui.sketch_status": "スケッチの状態", "ui.sketches": "スケッチ", "ui.slice": "スライス", "ui.slot": "スロット", diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index 06d8f8f9..6252c0c2 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -263,6 +263,7 @@ "ui.sketch_dimension": "스케치 치수", "ui.sketch_grid": "스케치 격자", "ui.sketch_scale": "스케치 축척", + "ui.sketch_status": "스케치 상태", "ui.sketches": "스케치", "ui.slice": "슬라이스", "ui.slot": "슬롯", diff --git a/crates/ui-egui/src/sketch_attachment_ui.rs b/crates/ui-egui/src/sketch_attachment_ui.rs index 5d0b792f..1435760f 100644 --- a/crates/ui-egui/src/sketch_attachment_ui.rs +++ b/crates/ui-egui/src/sketch_attachment_ui.rs @@ -145,8 +145,9 @@ pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) { .scroll([false, true]) .show(ctx, |ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); - ui.strong(format!("Redefine Sketch Plane: {name}")); - ui.separator(); + // The wrapped notes below fill the width anyway, so the band may too. + let width = ui.available_width(); + crate::frame::heading(ui, crate::frame::Level::Title, width, |ui| ui.strong(format!("Redefine Sketch Plane: {name}"))); let saved_plane = match &status { FaceAttachmentStatus::Healthy { plane, target, path } => { ui.label(format!("Attached to {} / {}", target.body, path_label(app, path))); diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index e978d64f..97259456 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -15,6 +15,7 @@ use serde_json::{Value, json}; use solidcraft_engine::Sel; use crate::SolveApp; +use crate::frame::Level; use crate::theme::Tokens; thread_local! { @@ -231,6 +232,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut finish = false; let has_curves = !selected_curves(app).is_empty(); let selected_types = selected_linetypes(app); + let margin_bottom = f32::from(frame.inner_margin.bottom); let response = crate::frame::docked( app.tr("ui.heading_sketch_palette"), egui::Id::new("sc_sketch_palette"), @@ -243,7 +245,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let width = WIDTH.min((vp.width() * 0.46 - 16.0).max(120.0)); ui.set_width(width); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); - ui.horizontal(|ui| { + let line = ui.text_style_height(&egui::TextStyle::Body); + let gap = ui.spacing().item_spacing.y; + crate::frame::heading(ui, Level::Title, width, |ui| { let folded = FOLDED.with(Cell::get); if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() { FOLDED.with(|f| f.set(!folded)); @@ -254,14 +258,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { return; } let ofold = OPTIONS_FOLDED.with(Cell::get); - if ui - .add(egui::Button::new(RichText::new(format!("{} {}", if ofold { "+" } else { "−" }, app.tr("ui.options"))).size(12.0)).frame(false)) - .clicked() - { - OPTIONS_FOLDED.with(|f| f.set(!ofold)); - } + crate::frame::heading(ui, Level::Section, width, |ui| { + if ui + .add(egui::Button::new(RichText::new(format!("{} {}", if ofold { "+" } else { "−" }, app.tr("ui.options"))).size(12.0)).frame(false)) + .clicked() + { + OPTIONS_FOLDED.with(|f| f.set(!ofold)); + } + }); if !ofold { - let options_height = (bottom - ui.cursor().top() - 68.0).max(24.0); + // Keep room for the footer: status heading and line, a line, Finish Sketch, a line. + let footer = 5.0 * line + ui.spacing().interact_size.y + 6.0 * gap + 8.0; + let options_height = (bottom - ui.cursor().top() - footer).max(24.0); let options = egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| { ui.vertical(|ui| { @@ -299,9 +307,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { crate::scenario::publish_rect("sketch_palette:options", options.inner_rect); } ui.add_space(4.0); + // The status changes when the sketch needs attention, so it gets its own heading. + crate::frame::heading(ui, Level::Section, width, |ui| { + ui.label(RichText::new(app.tr("ui.sketch_status")).size(12.0)); + }); ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim })); - ui.add_space(4.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // One line between the status and Finish Sketch, and below it to the edge. + ui.add_space((line - gap).max(0.0)); + // A row as tall as the button, so the gap below it is just the line. + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Min), |ui| { let button = ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent)); crate::scenario::publish_rect("sketch_palette:finish", button.rect); crate::scenario::publish_handle("sketch_palette:finish", button.rect.center()); @@ -309,6 +323,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { finish = true; } }); + ui.add_space((line - margin_bottom).max(0.0)); }); app.viewport.palette_rect = response.map(|r| r.response.rect); if let Some(r) = app.viewport.palette_rect { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9ec1dd0f..c6ce9017 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -93,6 +93,10 @@ pub struct Tokens { pub dialog_bg: Color32, pub dialog_border: Color32, pub chip: Color32, + /// Band behind a panel's heading and, lighter, behind its section headings (both set off + /// from `dialog_bg`). + pub heading_bg: Color32, + pub subheading_bg: Color32, /// Inside of bodies cut by a section. pub section_cap: Color32, /// Closed sketch profiles (translucent fill). @@ -183,6 +187,8 @@ impl Tokens { dialog_bg: Color32::from_rgb(250, 251, 252), dialog_border: Color32::from_rgb(208, 212, 220), chip: Color32::from_rgb(38, 120, 218), + heading_bg: Color32::from_rgb(218, 223, 231), + subheading_bg: Color32::from_rgb(234, 237, 242), profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70), glyph_bg: Color32::WHITE, glyph_edge: Color32::from_rgb(90, 150, 90), @@ -238,6 +244,8 @@ impl Tokens { t.dialog_bg = Color32::from_rgb(59, 68, 83); t.dialog_border = Color32::from_rgb(72, 83, 99); t.chip = Color32::from_rgb(59, 101, 168); + t.heading_bg = Color32::from_rgb(38, 44, 55); + t.subheading_bg = Color32::from_rgb(48, 55, 68); t.glyph_bg = Color32::from_rgb(44, 50, 60); t.glyph_edge = Color32::from_rgb(96, 170, 110); t.glyph_text = Color32::from_rgb(150, 220, 160); @@ -300,6 +308,16 @@ pub fn apply(ctx: &egui::Context) { #[cfg(test)] mod tests { + /// Title and section bands stand out from a dialog, the title more than its sections. + #[test] + fn heading_bands_are_darker_than_the_dialog_in_order() { + let luma = |c: egui::Color32| u32::from(c.r()) * 3 + u32::from(c.g()) * 6 + u32::from(c.b()); + for t in [super::Tokens::light(), super::Tokens::dark()] { + assert!(luma(t.heading_bg) < luma(t.subheading_bg), "{t:?}"); + assert!(luma(t.subheading_bg) < luma(t.dialog_bg), "{t:?}"); + } + } + #[test] fn active_movable_window_header_uses_theme_panel_header() { for dark in [false, true] {