Compact the Advanced object Control panel into aligned rows

This commit is contained in:
moppeeer
2026-10-12 18:29:18 +02:00
committed by paaspaas00
parent d020b4e85d
commit 6f75b648da
6 changed files with 463 additions and 218 deletions
+2 -200
View File
@@ -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);
+14 -3
View File
@@ -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 {
+1
View File
@@ -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;
+393
View File
@@ -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<R>(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::<bool>(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<egui::Event>) {
frame_with_width(ctx, app, events, 1440.0);
}
fn frame_with_width(ctx: &egui::Context, app: &mut DesignApp, events: Vec<egui::Event>, 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:?}");
}
}
+16 -12
View File
@@ -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<u8> {
pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option<u8> {
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.
+37 -3
View File
@@ -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 };