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 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 18:55:57 -04:00
1 parent 1d7450b04c
commit 11ecb64059
9 files changed
+87 -14

No files matched your search

+3 -2
View File
@@ -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))
+34
View File
@@ -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<R>(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 {
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -263,6 +263,7 @@
"ui.sketch_dimension": "スケッチ寸法",
"ui.sketch_grid": "スケッチ グリッド",
"ui.sketch_scale": "スケッチ尺度",
"ui.sketch_status": "スケッチの状態",
"ui.sketches": "スケッチ",
"ui.slice": "スライス",
"ui.slot": "スロット",
+1
View File
@@ -263,6 +263,7 @@
"ui.sketch_dimension": "스케치 치수",
"ui.sketch_grid": "스케치 격자",
"ui.sketch_scale": "스케치 축척",
"ui.sketch_status": "스케치 상태",
"ui.sketches": "스케치",
"ui.slice": "슬라이스",
"ui.slot": "슬롯",
+3 -2
View File
@@ -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)));
+25 -10
View File
@@ -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 {
+18
View File
@@ -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] {