From 6f75b648dab3bf9f4dfc8986245db052fe7f6892 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:43:19 +0200 Subject: [PATCH] Compact the Advanced object Control panel into aligned rows --- crates/ui-egui/src/chrome.rs | 202 +----------- crates/ui-egui/src/icons.rs | 17 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/object_control.rs | 393 ++++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 28 +- crates/ui-egui/src/widgets.rs | 40 ++- 6 files changed, 463 insertions(+), 218 deletions(-) create mode 100644 crates/ui-egui/src/object_control.rs diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2896fcdc4..93fa30157 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -5,7 +5,7 @@ use designcraft_geom::Unit; use egui::{Color32, Sense, Stroke, vec2}; use serde_json::json; -use crate::panels::{self, SelInfo}; +use crate::panels; use crate::theme::{Tokens, semibold}; use crate::widgets::{caption, measure, number}; use crate::{DesignApp, icons}; @@ -185,211 +185,13 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if text_mode { control_text(app, ui); } else { - control_object(app, ui); + crate::object_control::show(app, ui); } }); }); }); } -/// A field label drawn as a tool icon (hover for its name). -fn icon_caption(ui: &mut egui::Ui, icon: &str, tip: &str) { - let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); - icons::paint(ui.painter(), r, icon, Tokens::get(ui.ctx()).icon); - resp.on_hover_ui(|ui| { - crate::rtl::label(ui, tip); - }); -} - -fn control_object(app: &mut DesignApp, ui: &mut egui::Ui) { - let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas); - let info = panels::sel_info(app); - // Reference point proxy. - let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); - let t = Tokens::get(ui.ctx()); - icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(26.0, 26.0)), "ref-point", t.icon); - ui.painter().rect_filled(egui::Rect::from_center_size(r.center() + vec2(-7.8, -7.8), vec2(4.0, 4.0)), 0.0, t.text_strong); - let (x, y, w, h) = match &info { - Some(SelInfo { page_rect, .. }) => (Some(page_rect.x0), Some(page_rect.y0), Some(page_rect.width()), Some(page_rect.height())), - None => (None, None, None, None), - }; - egui::Grid::new("ctl_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "X:")); - if let Some(v) = measure(ui, "cx", x, units, 64.0) { - let _ = app.run("transform.set", json!({"x": v})); - } - caption(ui, crate::i18n::tr(&app.ui.language, "W:")); - if let Some(v) = measure(ui, "cw", w, units, 64.0) { - let _ = app.run("transform.set", json!({"width": v})); - } - ui.end_row(); - caption(ui, crate::i18n::tr(&app.ui.language, "Y:")); - if let Some(v) = measure(ui, "cy", y, units, 64.0) { - let _ = app.run("transform.set", json!({"y": v})); - } - caption(ui, crate::i18n::tr(&app.ui.language, "H:")); - if let Some(v) = measure(ui, "ch", h, units, 64.0) { - let _ = app.run("transform.set", json!({"height": v})); - } - ui.end_row(); - }); - vsep(ui); - // Scale, rotation and shear (absolute; see Transformations are Totals). - let tv = if info.is_some() { app.session.execute("transform.info", &json!({})).ok() } else { None }; - let tv_get = |k: &str| tv.as_ref().and_then(|v| v[k].as_f64()); - egui::Grid::new("ctl_srs").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { - icon_caption(ui, "tool-scale", crate::i18n::tr(&app.ui.language, "Scale X Percentage")); - if let Some(v) = number(ui, "csx", tv_get("scaleX"), "%", 56.0, 1) { - let _ = app.run("transform.set", json!({"scaleX": v})); - } - icon_caption(ui, "tool-rotate", crate::i18n::tr(&app.ui.language, "Rotation Angle")); - if let Some(v) = number(ui, "crot", tv_get("rotation"), "°", 50.0, 1) { - let _ = app.run("transform.set", json!({"rotation": v})); - } - ui.end_row(); - icon_caption(ui, "tool-scale", crate::i18n::tr(&app.ui.language, "Scale Y Percentage")); - if let Some(v) = number(ui, "csy", tv_get("scaleY"), "%", 56.0, 1) { - let _ = app.run("transform.set", json!({"scaleY": v})); - } - icon_caption(ui, "tool-shear", crate::i18n::tr(&app.ui.language, "Shear X Angle")); - if let Some(v) = number(ui, "cshr", tv_get("shear"), "°", 50.0, 1) { - let _ = app.run("transform.set", json!({"shear": v})); - } - ui.end_row(); - }); - vsep(ui); - // Rotate / flip. - ui.vertical(|ui| { - ui.horizontal(|ui| { - if icons::button(ui, "tool-rotate", 20.0, false, crate::i18n::tr(&app.ui.language, "Rotate 90° Counterclockwise")).clicked() { - let _ = app.run("transform.rotate", json!({"angle": 90})); - } - if ui - .small_button("⇋") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Flip Horizontal")); - }) - .clicked() - { - let _ = app.run("transform.flip", json!({"axis": "horizontal"})); - } - }); - ui.horizontal(|ui| { - if ui - .small_button("↻") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Rotate 90° Clockwise")); - }) - .clicked() - { - let _ = app.run("transform.rotate", json!({"angle": -90})); - } - if ui - .small_button("⇵") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Flip Vertical")); - }) - .clicked() - { - let _ = app.run("transform.flip", json!({"axis": "vertical"})); - } - }); - }); - vsep(ui); - // Fill / stroke. - ui.vertical(|ui| { - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Fill")); - panels::swatch_picker(app, ui, "ctlfill", info.as_ref().map(|i| i.fill.clone()), |app, name| { - let _ = app.run("object.fill", json!({"swatch": name})); - }); - }); - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Stroke")); - panels::swatch_picker(app, ui, "ctlstroke", info.as_ref().map(|i| i.stroke.clone()), |app, name| { - let _ = app.run("object.stroke", json!({"swatch": name})); - }); - if let Some(v) = number(ui, "csw", info.as_ref().map(|i| i.stroke_weight), " pt", 46.0, 3) { - let _ = app.run("object.stroke", json!({"weight": v})); - } - }); - }); - vsep(ui); - // Opacity, fx. - ui.vertical(|ui| { - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Opacity")); - if let Some(v) = number(ui, "cop", info.as_ref().map(|i| i.opacity * 100.0), "%", 42.0, 0) { - let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); - } - }); - ui.horizontal(|ui| { - if icons::button(ui, "panel-effects", 20.0, info.as_ref().is_some_and(|i| i.shadow), crate::i18n::tr(&app.ui.language, "Drop Shadow")) - .clicked() - { - let _ = app.run("object.dropShadow", json!({})); - } - }); - }); - vsep(ui); - // Fitting. - ui.vertical(|ui| { - ui.horizontal(|ui| { - for (icon, mode, tip) in [ - ("fit-fill", "fillProportionally", "Fill Frame Proportionally"), - ("fit-prop", "fitProportionally", "Fit Content Proportionally"), - ("fit-content", "fitContentToFrame", "Fit Content to Frame"), - ] { - if icons::button(ui, icon, 20.0, false, tip).clicked() { - let _ = app.run("object.fit", json!({"mode": mode})); - } - } - }); - ui.horizontal(|ui| { - for (icon, mode, tip) in [("fit-frame", "fitFrameToContent", "Fit Frame to Content"), ("fit-center", "centerContent", "Center Content")] { - if icons::button(ui, icon, 20.0, false, tip).clicked() { - let _ = app.run("object.fit", json!({"mode": mode})); - } - } - }); - }); - vsep(ui); - // Text wrap. - ui.vertical(|ui| { - ui.horizontal(|ui| { - for (icon, mode, tip) in [ - ("wrap-none", "none", "No Text Wrap"), - ("wrap-bbox", "boundingBox", "Wrap Around Bounding Box"), - ("wrap-contour", "contour", "Wrap Around Object Shape"), - ] { - let on = info.as_ref().is_some_and(|i| i.wrap == mode); - if icons::button(ui, icon, 20.0, on, tip).clicked() { - let _ = app.run("object.textWrap", json!({"mode": mode})); - } - } - }); - ui.horizontal(|ui| { - for (icon, mode, tip) in [("wrap-jump", "jumpObject", "Jump Object"), ("wrap-next", "jumpToNextColumn", "Jump to Next Column")] { - let on = info.as_ref().is_some_and(|i| i.wrap == mode); - if icons::button(ui, icon, 20.0, on, tip).clicked() { - let _ = app.run("object.textWrap", json!({"mode": mode})); - } - } - }); - }); - if let Some(i) = &info - && let Some(cols) = i.columns - { - vsep(ui); - ui.vertical(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Columns")); - if let Some(v) = number(ui, "ccols", Some(cols as f64), "", 36.0, 0) { - let _ = app.run("object.textFrameOptions", json!({"columns": v.max(1.0) as u64})); - } - }); - } -} - fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let attrs = panels::text_attrs(app); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b3..7c55bbd9c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -168,15 +168,26 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(7.0, 4.0), (16.0, 4.0)]); pen.line(&[(13.5, 2.0), (16.0, 4.0), (13.5, 6.0)]); } - "tool-rotate" => { + "tool-rotate" | "rotate-clockwise" => { + let direction = if name == "rotate-clockwise" { -1.0 } else { 1.0 }; let pts: Vec<(f32, f32)> = (0..24) .map(|i| { let a = (i as f32 / 23.0) * 4.6 - 0.6; - (10.0 + 6.5 * a.cos(), 10.0 - 6.5 * a.sin()) + (10.0 + direction * 6.5 * a.cos(), 10.0 - 6.5 * a.sin()) }) .collect(); pen.line(&pts); - pen.line(&[(13.0, 4.0), (15.5, 6.0), (16.5, 2.8)]); + pen.line(&[(10.0 + direction * 3.0, 4.0), (10.0 + direction * 5.5, 6.0), (10.0 + direction * 6.5, 2.8)]); + } + "flip-horizontal" => { + pen.line(&[(10.0, 2.0), (10.0, 18.0)]); + pen.fill(&[(3.0, 4.0), (8.0, 10.0), (3.0, 16.0)]); + pen.closed(&[(17.0, 4.0), (12.0, 10.0), (17.0, 16.0)]); + } + "flip-vertical" => { + pen.line(&[(2.0, 10.0), (18.0, 10.0)]); + pen.fill(&[(4.0, 3.0), (10.0, 8.0), (16.0, 3.0)]); + pen.closed(&[(4.0, 17.0), (10.0, 12.0), (16.0, 17.0)]); } "tool-gradient" | "tool-gradient-feather" => { for i in 0..6 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0fa1dff0..8699704c8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod object_control; mod panel_docking; pub mod panels; mod prefs; diff --git a/crates/ui-egui/src/object_control.rs b/crates/ui-egui/src/object_control.rs new file mode 100644 index 000000000..0000caf8f --- /dev/null +++ b/crates/ui-egui/src/object_control.rs @@ -0,0 +1,393 @@ +//! The object Control panel: fixed, compact two-row groups, independent of widget layout growth. + +use designcraft_geom::{Unit, corners::CornerShape}; +use egui::{Rect, Sense, Ui, vec2}; +use serde_json::{Value, json}; + +use crate::panels::{self, properties}; +use crate::widgets::{self, FIELD_H, NumField}; +use crate::{DesignApp, icons, theme::Tokens}; + +const ROW_PITCH: f32 = FIELD_H + 4.0; +pub(crate) const HEIGHT: f32 = ROW_PITCH + FIELD_H; + +fn group(ui: &mut Ui, id: &str, width: f32, draw: impl FnOnce(&mut Ui, Rect)) { + let (rect, _) = ui.allocate_exact_size(vec2(width, HEIGHT), Sense::hover()); + ui.interact(rect, egui::Id::new(("object_control", id)), Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + draw(&mut child, rect); +} + +fn cell(ui: &mut Ui, group: Rect, x: f32, row: usize, width: f32, enabled: bool, draw: impl FnOnce(&mut Ui) -> R) -> R { + let rect = Rect::from_min_size(group.min + vec2(x, row as f32 * ROW_PITCH), vec2(width, FIELD_H)); + properties::place(ui, rect, |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(rect)); + if !enabled { + ui.disable(); + } + draw(ui) + }) +} + +fn icon(ui: &mut Ui, group: Rect, x: f32, row: usize, name: &str, tip: &str) { + let rect = Rect::from_min_size(group.min + vec2(x, row as f32 * ROW_PITCH + 2.5), vec2(16.0, 16.0)); + icons::paint(ui.painter(), rect, name, Tokens::get(ui.ctx()).icon); + ui.interact(rect, ui.id().with(("caption", name, row)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }); +} + +fn separator(ui: &mut Ui) { + let (rect, _) = ui.allocate_exact_size(vec2(4.0, HEIGHT), Sense::hover()); + ui.painter().line_segment([rect.center_top(), rect.center_bottom()], egui::Stroke::new(1.0, Tokens::get(ui.ctx()).divider)); +} + +fn toggle(ui: &mut Ui, group: Rect, x: f32, key: &str, tip: &str) -> bool { + let id = egui::Id::new(key); + let on = ui.data(|d| d.get_temp::(id)).unwrap_or(false); + let rect = Rect::from_min_size(group.min + vec2(x, 12.5), vec2(20.0, FIELD_H)); + if properties::place(ui, rect, |ui| widgets::icon_toggle(ui, if on { "link" } else { "link-broken" }, on, tip)).clicked() { + ui.data_mut(|d| d.insert_temp(id, !on)); + !on + } else { + on + } +} + +pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { + let language = app.ui.language.clone(); + let tr = |text: &str| crate::i18n::tr(&language, text).to_owned(); + let units = app.session.active().map_or(Unit::Picas, |d| d.doc.settings.horizontal_units); + let info = panels::sel_info(app); + let selected = info.is_some(); + let reference = app.ui.ref_point.min(8); + let geometry = info.as_ref().map(|i| i.page_rect); + group(ui, "reference", 26.0, |ui, rect| { + let proxy = Rect::from_center_size(rect.center(), vec2(26.0, 26.0)); + if let Some(point) = properties::ref_point_proxy(ui, proxy, reference) { + app.ui.ref_point = point; + } + }); + group(ui, "dimensions", 236.0, |ui, rect| { + let constrain = toggle(ui, rect, 216.0, "constrain_wh", &tr("Constrain proportions")); + let (fx, fy) = ((reference % 3) as f64 / 2.0, (reference / 3) as f64 / 2.0); + let values = geometry.map(|r| [r.x0 + r.width() * fx, r.y0 + r.height() * fy, r.width(), r.height()]); + let mut set = serde_json::Map::new(); + for (row, fields) in + [[("X:", "cx", "x", 0), ("W:", "cw", "width", 2)], [("Y:", "cy", "y", 1), ("H:", "ch", "height", 3)]].into_iter().enumerate() + { + for (column, (label, id, key, index)) in fields.into_iter().enumerate() { + let x = column as f32 * 108.0; + cell(ui, rect, x, row, 14.0, true, |ui| widgets::caption_w(ui, label, 14.0)); + let value = values.map(|v| v[index]); + if let Some(v) = cell(ui, rect, x + 15.0, row, 86.0, selected, |ui| { + NumField::measure(id, value, units).width(86.0).spinner().enabled(selected).show(ui) + }) { + set.insert(key.into(), json!(v)); + if constrain + && column == 1 + && let Some(old) = value.filter(|old| *old > 0.0) + { + let other = if key == "width" { ("height", 3) } else { ("width", 2) }; + if let Some(values) = values { + set.insert(other.0.into(), json!(values[other.1] * v / old)); + } + } + } + } + } + if !set.is_empty() { + set.insert("ref".into(), json!(reference)); + let _ = app.run("transform.set", Value::Object(set)); + } + }); + separator(ui); + let transform = if selected { app.session.execute("transform.info", &json!({})).ok() } else { None }; + let get = |key: &str| transform.as_ref().and_then(|v| v.get(key)).and_then(Value::as_f64); + group(ui, "transform", 214.0, |ui, rect| { + let constrain = toggle(ui, rect, 102.0, "ctl_constrain_scale", &tr("Constrain proportions")); + for (row, scale, angle, angle_icon) in [(0, "scaleX", "rotation", "tool-rotate"), (1, "scaleY", "shear", "tool-shear")] { + icon(ui, rect, 0.0, row, "tool-scale", &tr(if row == 0 { "Scale X Percentage" } else { "Scale Y Percentage" })); + if let Some(v) = cell(ui, rect, 20.0, row, 78.0, selected, |ui| { + NumField::number(scale, get(scale), "%", 1).width(78.0).spinner().enabled(selected).show(ui) + }) { + let mut set = json!({scale: v, "ref": reference}); + if constrain && let Some(old) = get(scale).filter(|v| v.abs() > 0.01) { + let other = if row == 0 { "scaleY" } else { "scaleX" }; + if let Some(value) = get(other) { + set[other] = json!(value * v / old); + } + } + let _ = app.run("transform.set", set); + } + icon(ui, rect, 126.0, row, angle_icon, &tr(if row == 0 { "Rotation Angle" } else { "Shear X Angle" })); + if let Some(v) = cell(ui, rect, 146.0, row, 68.0, selected, |ui| { + NumField::number(angle, get(angle), "°", 1).width(68.0).spinner().enabled(selected).show(ui) + }) { + let _ = app.run("transform.set", json!({angle: v, "ref": reference})); + } + } + }); + separator(ui); + group(ui, "rotate_flip", 44.0, |ui, rect| { + for (row, angle, rotate, axis, flip) in + [(0, 90, "tool-rotate", "horizontal", "flip-horizontal"), (1, -90, "rotate-clockwise", "vertical", "flip-vertical")] + { + let tip = tr(if row == 0 { "Rotate 90° Counterclockwise" } else { "Rotate 90° Clockwise" }); + if cell(ui, rect, 0.0, row, 20.0, selected, |ui| icons::button(ui, rotate, 20.0, false, &tip)).clicked() { + let _ = app.run("transform.rotate", json!({"angle": angle, "ref": reference})); + } + if cell(ui, rect, 24.0, row, 20.0, selected, |ui| { + icons::button(ui, flip, 20.0, false, &tr(if row == 0 { "Flip Horizontal" } else { "Flip Vertical" })) + }) + .clicked() + { + let _ = app.run("transform.flip", json!({"axis": axis, "ref": reference})); + } + } + }); + separator(ui); + group(ui, "appearance", 224.0, |ui, rect| { + for (row, stroke) in [(0, false), (1, true)] { + cell(ui, rect, 0.0, row, 30.0, selected, |ui| { + panels::swatch_picker( + app, + ui, + if stroke { "ctlstroke" } else { "ctlfill" }, + info.as_ref().map(|i| if stroke { i.stroke.clone() } else { i.fill.clone() }), + |app, name| { + let _ = app.run(if stroke { "object.stroke" } else { "object.fill" }, json!({"swatch": name})); + }, + ); + let chip = Rect::from_min_size(rect.min + vec2(0.0, row as f32 * ROW_PITCH + 1.5), vec2(18.0, 18.0)); + if stroke && let Some(doc) = app.session.active().map(|st| &st.doc) { + let (color, gradient) = info.as_ref().map_or((Some(egui::Color32::GRAY), None), |i| widgets::swatch_colors(doc, &i.stroke, 1.0)); + let inner = Tokens::get(ui.ctx()).panel; + if gradient.is_some() { + widgets::paint_chip(ui.painter(), chip, color, gradient); + ui.painter().rect_filled(chip.shrink(5.4), 0.0, inner); + } else { + widgets::paint_stroke_chip(ui.painter(), chip, color, inner); + } + } + ui.interact(chip, ui.id().with("swatch_tip"), Sense::hover()).on_hover_text(tr(if stroke { "Stroke" } else { "Fill" })); + }); + } + icon(ui, rect, 36.0, 0, "panel-stroke", &tr("Stroke Weight")); + if let Some(v) = cell(ui, rect, 56.0, 0, 74.0, selected, |ui| { + NumField::number("csw", info.as_ref().map(|i| i.stroke_weight), " pt", 3).width(74.0).spinner().range(0.0, 1000.0).show(ui) + }) { + let _ = app.run("object.stroke", json!({"weight": v})); + } + cell(ui, rect, 36.0, 1, 94.0, selected, |ui| { + properties::stroke_type_dropdown(app, ui, info.as_ref().map_or("solid", |i| &i.stroke_kind), 94.0) + }); + icon(ui, rect, 138.0, 0, "opacity", &tr("Opacity")); + if let Some(v) = cell(ui, rect, 158.0, 0, 66.0, selected, |ui| { + NumField::number("cop", info.as_ref().map(|i| i.opacity * 100.0), "%", 0).width(66.0).spinner().range(0.0, 100.0).show(ui) + }) { + let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); + } + cell(ui, rect, 138.0, 1, 20.0, selected, |ui| { + let resp = icons::button(ui, "fx", 20.0, info.as_ref().is_some_and(|i| i.shadow), &tr("Effects")); + egui::Popup::menu(&resp).show(|ui| { + if ui.selectable_label(info.as_ref().is_some_and(|i| i.shadow), crate::i18n::tr(&app.ui.language, "Drop Shadow")).clicked() { + let _ = app.run("object.dropShadow", json!({})); + ui.close(); + } + }); + }); + }); + separator(ui); + group(ui, "wrap", 68.0, |ui, rect| { + for (index, (icon, mode, tip)) in [ + ("wrap-none", "none", "No Text Wrap"), + ("wrap-bbox", "boundingBox", "Wrap Around Bounding Box"), + ("wrap-contour", "contour", "Wrap Around Object Shape"), + ("wrap-jump", "jumpObject", "Jump Object"), + ("wrap-next", "jumpToNextColumn", "Jump to Next Column"), + ] + .into_iter() + .enumerate() + { + if cell(ui, rect, (index % 3) as f32 * 24.0, index / 3, 20.0, selected, |ui| { + icons::button(ui, icon, 20.0, info.as_ref().is_some_and(|i| i.wrap == mode), &tr(tip)) + }) + .clicked() + { + let _ = app.run("object.textWrap", json!({"mode": mode})); + } + } + }); + separator(ui); + group(ui, "corners", 102.0, |ui, rect| { + let (shape, size) = info.as_ref().map_or((CornerShape::None, 0.0), |i| i.corner); + icon(ui, rect, 0.0, 0, "corner", &tr("Corner Size")); + if let Some(v) = cell(ui, rect, 22.0, 0, 80.0, selected, |ui| { + NumField::measure("corner", info.as_ref().map(|i| i.corner.1), units).width(80.0).spinner().range(0.0, 1000.0).show(ui) + }) { + let shape = if shape == CornerShape::None { CornerShape::Rounded } else { shape }; + let _ = app.run("object.cornerOptions", json!({"shape": shape, "size": v})); + } + cell(ui, rect, 0.0, 1, 102.0, selected, |ui| properties::corner_type_dropdown(app, ui, shape, size, 102.0)); + }); + separator(ui); + group(ui, "fitting", 116.0, |ui, rect| { + let graphic = info.as_ref().is_some_and(|i| i.is_graphic); + for (index, (icon, mode, tip)) in [ + ("fit-fill", "fillProportionally", "Fill Frame Proportionally"), + ("fit-prop", "fitProportionally", "Fit Content Proportionally"), + ("fit-content", "fitContentToFrame", "Fit Content to Frame"), + ("fit-frame", "fitFrameToContent", "Fit Frame to Content"), + ("fit-center", "centerContent", "Center Content"), + ] + .into_iter() + .enumerate() + { + if cell(ui, rect, index as f32 * 24.0, 0, 20.0, graphic, |ui| icons::button(ui, icon, 20.0, false, &tr(tip))).clicked() { + let _ = app.run("object.fit", json!({"mode": mode})); + } + } + let mut auto = app + .session + .active() + .and_then(|st| st.selection.items.first().and_then(|id| st.doc.item(*id))) + .and_then(|it| it.graphic()) + .is_some_and(|g| g.auto_fit != designcraft_doc::Fitting::None); + if cell(ui, rect, 0.0, 1, 116.0, graphic, |ui| widgets::checkbox(ui, &mut auto, crate::i18n::tr(&app.ui.language, "Auto-Fit"))).changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } + }); + separator(ui); + group(ui, "align", 68.0, |ui, rect| { + for (index, (edge, tip)) in [ + ("left", "Align left edges"), + ("hcenter", "Align horizontal centers"), + ("right", "Align right edges"), + ("top", "Align top edges"), + ("vcenter", "Align vertical centers"), + ("bottom", "Align bottom edges"), + ] + .into_iter() + .enumerate() + { + if cell(ui, rect, (index % 3) as f32 * 24.0, index / 3, 20.0, selected, |ui| { + icons::button(ui, &format!("objalign-{edge}"), 20.0, false, &tr(tip)) + }) + .clicked() + { + let _ = app.run("object.align", json!({"edge": edge})); + } + } + }); + if let Some(columns) = info.as_ref().and_then(|i| i.columns) { + separator(ui); + group(ui, "columns", 48.0, |ui, rect| { + icon(ui, rect, 0.0, 0, "text-columns", &tr("Columns")); + if let Some(v) = cell(ui, rect, 0.0, 1, 48.0, true, |ui| { + NumField::number("ccols", Some(columns as f64), "", 0).width(48.0).spinner().range(1.0, 100.0).show(ui) + }) { + let _ = app.run("object.textFrameOptions", json!({"columns": v as u64})); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(ctx: &egui::Context, app: &mut DesignApp, events: Vec) { + frame_with_width(ctx, app, events, 1440.0); + } + + fn frame_with_width(ctx: &egui::Context, app: &mut DesignApp, events: Vec, width: f32) { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 900.0))), + time: Some(ctx.input(|i| i.time) + 0.1), + events, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| crate::chrome::control_bar(app, ui)); + output.textures_delta.clear(); + } + + fn fixture(selected: bool) -> (egui::Context, DesignApp) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("window.workspace", json!({"name": "Advanced"})).unwrap(); + assert!(app.ui.control_bar); + if selected { + app.run("frame.create", json!({"rect": [20, 30, 80, 90]})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, ""); + crate::theme::apply(&ctx, &Tokens::for_brightness(app.ui.brightness)); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + (ctx, app) + } + + fn click(ctx: &egui::Context, app: &mut DesignApp, p: egui::Pos2) { + frame(ctx, app, vec![egui::Event::PointerMoved(p)]); + for pressed in [true, false] { + frame( + ctx, + app, + vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }], + ); + } + } + + #[test] + fn bottom_row_spinner_edits_height_inside_the_control_panel() { + let (ctx, mut app) = fixture(true); + let before = panels::sel_info(&app).unwrap().page_rect.height(); + let group = ctx.read_response(egui::Id::new(("object_control", "dimensions"))).unwrap().rect; + assert!(group.bottom() <= 59.0, "second row is inside the panel: {group:?}"); + let height_up = group.min + vec2(123.0 + widgets::SEG_W / 2.0, ROW_PITCH + FIELD_H / 4.0); + click(&ctx, &mut app, height_up); + let after = panels::sel_info(&app).unwrap().page_rect.height(); + assert!((after - before - 1.0).abs() < 1e-6, "height spinner reaches transform.set: {before} -> {after}"); + } + + #[test] + fn empty_selection_keeps_numeric_controls_inactive() { + let (ctx, mut app) = fixture(false); + let status = app.ui.status.clone(); + let group = ctx.read_response(egui::Id::new(("object_control", "dimensions"))).unwrap().rect; + click(&ctx, &mut app, group.min + vec2(24.0, FIELD_H / 4.0)); + assert_eq!(app.ui.status, status, "disabled spinners do not attempt transforms"); + assert!(panels::sel_info(&app).is_none()); + } + + #[test] + fn narrow_windows_can_scroll_to_the_alignment_controls() { + let (ctx, mut app) = fixture(true); + frame_with_width(&ctx, &mut app, vec![], 900.0); + frame_with_width(&ctx, &mut app, vec![], 900.0); + let id = egui::Id::new(("object_control", "align")); + let before = ctx.read_response(id).unwrap().rect; + assert!(before.right() > 900.0, "the toolbar overflows at the minimum width"); + frame_with_width( + &ctx, + &mut app, + vec![ + egui::Event::PointerMoved(egui::pos2(700.0, 30.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(-500.0, 0.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ], + 900.0, + ); + for _ in 0..6 { + frame_with_width(&ctx, &mut app, vec![], 900.0); + } + let after = ctx.read_response(id).unwrap().rect; + assert!(after.left() < before.left() && after.right() <= 900.0, "scrolling reaches the last group: {before:?} -> {after:?}"); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..fef40d902 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -381,7 +381,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // ---------------------------------------------------------------- Objects /// The 9-point reference-point proxy; returns a newly picked point (0..8, row-major). -fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { +pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; let pitch = r.width() / 3.0; @@ -466,7 +466,7 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } /// A stroke-type preview dropdown (light box with the line style). -fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { +pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { const TYPES: &[(&str, &str)] = &[ ("Solid", "solid"), ("Dashed", "dashed"), @@ -563,6 +563,19 @@ fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { p.add(egui::Shape::line(pts, s)); } +pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { + let resp = preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(140.0); + for s in CornerShape::ALL { + if ui.selectable_label(s == shape, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, s.label()))).clicked() { + let _ = app.run("object.cornerOptions", json!({"shape": s, "size": if size > 0.0 { size } else { 12.0 }})); + ui.close(); + } + } + }); +} + fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; @@ -623,16 +636,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let _ = app.run("object.cornerOptions", json!({"shape": sh, "size": v})); } place(ui, sub(b, 171.0, 0.0, 54.0, FIELD_H), |ui| { - let resp = preview_dropdown(ui, 54.0, |p, r| paint_corner_shape(p, r, shape)); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); - for s in CornerShape::ALL { - if ui.selectable_label(s == shape, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, s.label()))).clicked() { - let _ = app.run("object.cornerOptions", json!({"shape": s, "size": if size > 0.0 { size } else { 12.0 }})); - ui.close(); - } - } - }); + corner_type_dropdown(app, ui, shape, size, 54.0); }); ui.add_space(4.0); // Opacity. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b13627c9c..3342a22e1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -201,9 +201,15 @@ impl<'a> NumField<'a> { .text_color(fg) .margin(egui::Margin::ZERO) .vertical_align(egui::Align::Center); - let r = ui - .add_enabled_ui(self.enabled, |ui| ui.put(Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0)), te)) - .inner; + // The field already allocated its slot. A scoped allocation here would consume another + // grid cell and enlarge toolbar rows; the value editor must stay inside that same slot. + let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0)); + let mut editor_ui = ui.new_child(egui::UiBuilder::new().max_rect(value_rect)); + editor_ui.set_clip_rect(editor_ui.clip_rect().intersect(text_rect)); + if !self.enabled { + editor_ui.disable(); + } + let r = editor_ui.put(value_rect, te); if r.has_focus() { // ↑/↓ step the value while typing. let (up, down, shift) = ui.input_mut(|i| { @@ -701,6 +707,34 @@ mod tests { assert_eq!(got, vec![None]); } + #[test] + fn numeric_fields_use_one_cell_in_a_two_row_grid() { + let ctx = egui::Context::default(); + let mut bounds = Rect::NOTHING; + let mut draw = |ui: &mut Ui| { + bounds = egui::Grid::new("numeric_field_grid") + .num_columns(4) + .min_col_width(0.0) + .spacing(vec2(4.0, 4.0)) + .show(ui, |ui| { + for row in 0..2 { + for column in 0..2 { + caption_w(ui, if column == 0 { "X:" } else { "W:" }, 14.0); + let id = [["grid_x", "grid_w"], ["grid_y", "grid_h"]][row][column]; + NumField::number(id, Some(100.0), "%", 0).width(72.0).show(ui); + } + ui.end_row(); + } + }) + .response + .rect; + }; + run(&ctx, vec![], egui::Modifiers::NONE, &mut draw); + run(&ctx, vec![], egui::Modifiers::NONE, &mut draw); + assert!(bounds.height() <= 2.0 * FIELD_H + 4.0, "both rows fit their allocated height: {bounds:?}"); + assert!(bounds.width() <= 190.0, "editors do not add extra grid columns: {bounds:?}"); + } + #[test] fn number_kind_formats_and_parses() { let k = NumKind::Number { suffix: "%", decimals: 0 };