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:
9 files changed
+87
-14
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"ui.sketch_dimension": "スケッチ寸法",
|
||||
"ui.sketch_grid": "スケッチ グリッド",
|
||||
"ui.sketch_scale": "スケッチ尺度",
|
||||
"ui.sketch_status": "スケッチの状態",
|
||||
"ui.sketches": "スケッチ",
|
||||
"ui.slice": "スライス",
|
||||
"ui.slot": "スロット",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"ui.sketch_dimension": "스케치 치수",
|
||||
"ui.sketch_grid": "스케치 격자",
|
||||
"ui.sketch_scale": "스케치 축척",
|
||||
"ui.sketch_status": "스케치 상태",
|
||||
"ui.sketches": "스케치",
|
||||
"ui.slice": "슬라이스",
|
||||
"ui.slot": "슬롯",
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in new issue
Block a user