mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
Compact the Advanced object Control panel into aligned rows
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user