mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-20 22:24:10 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f297a7418 |
No files matched your search
@@ -1,12 +1,13 @@
|
||||
//! Properties panel for pixel (and other bounded) layers, Photoshop 2026 style: a Transform section
|
||||
//! (W/H/X/Y of the layer content, editable), Align and Distribute, and Quick Actions. Edits dispatch
|
||||
//! (W/H/X/Y of the layer content, editable), Align and Distribute, the kind's sections and Quick Actions. Edits dispatch
|
||||
//! `edit.transform`, `layer.translate`, `layer.align.*` and selection commands.
|
||||
|
||||
use egui::{Rect, Sense, Stroke, pos2, vec2};
|
||||
use photocraft_doc::Layer;
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section};
|
||||
use crate::theme::{ROW_GAP, Tokens};
|
||||
use crate::{PhotocraftApp, widgets};
|
||||
|
||||
/// `edit.transform` params that scale the content box `b` = [x0, y0, x1, y1] to `w` x `h`, keeping
|
||||
@@ -32,34 +33,14 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option<f32>, h: Option<f32>,
|
||||
Some(json!({"layer": layer, "rect": b, "quad": quad}))
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = egui::Id::new(("layer-props-section", id));
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(true);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
crate::icons::paint(
|
||||
ui,
|
||||
Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
11.0,
|
||||
t.text_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), egui::Align2::LEFT_CENTER, title, crate::theme::semibold(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(key, open));
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// A number field that reports a value once committed (drag released, Enter, focus lost).
|
||||
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32) -> Option<f32> {
|
||||
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32) -> Option<f32> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 22.0), Sense::hover());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(12.0), t.text_dim);
|
||||
let key = egui::Id::new(("layer-props-field", id));
|
||||
let mut v = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or(current);
|
||||
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", 80.0);
|
||||
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", width);
|
||||
if resp.dragged() || resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(key, v));
|
||||
return None;
|
||||
@@ -107,7 +88,12 @@ fn align_glyph(ui: &egui::Ui, r: Rect, kind: &str, color: egui::Color32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pro Properties body for a non-adjustment layer.
|
||||
/// Width of the link toggle between the W and H fields (the X/Y row leaves the same gap).
|
||||
const LINK_W: f32 = 20.0;
|
||||
|
||||
/// Pro Properties body for a non-adjustment layer: Transform, Align and Distribute, the kind's own
|
||||
/// sections (Character/Paragraph/Type Options for type, Appearance/Shape for shapes), then Quick
|
||||
/// Actions, all with the same collapsible headers (#155).
|
||||
pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut run: Vec<(String, Value)> = Vec::new();
|
||||
@@ -117,32 +103,33 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
let link_key = egui::Id::new("layer-props-link");
|
||||
let linked = ui.data(|d| d.get_temp::<bool>(link_key)).unwrap_or(true);
|
||||
let bb = [b.x0, b.y0, b.x1, b.y1];
|
||||
// Two label+field columns with the link toggle between them, filling the panel.
|
||||
let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if let Some(v) = field(ui, "w", "W", b.width() as f32) {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
if let Some(v) = field(ui, "w", "W", b.width() as f32, w) {
|
||||
run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p)));
|
||||
}
|
||||
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, 20.0, linked, "Link width and height").clicked() {
|
||||
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, "Link width and height").clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(link_key, !linked));
|
||||
}
|
||||
if let Some(v) = field(ui, "h", "H", b.height() as f32) {
|
||||
if let Some(v) = field(ui, "h", "H", b.height() as f32, w) {
|
||||
run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p)));
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if let Some(v) = field(ui, "x", "X", b.x0 as f32) {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
if let Some(v) = field(ui, "x", "X", b.x0 as f32, w) {
|
||||
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0})));
|
||||
}
|
||||
ui.add_space(24.0);
|
||||
if let Some(v) = field(ui, "y", "Y", b.y0 as f32) {
|
||||
ui.add_space(LINK_W + LABEL_GAP);
|
||||
if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w) {
|
||||
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32})));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if section(ui, "align", "Align and Distribute") {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -164,25 +151,28 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(2.0);
|
||||
if section(ui, "quick", "Quick Actions") {
|
||||
ui.horizontal(|ui| {
|
||||
for (label, id) in [("Remove Background", "layer.removeBackground"), ("Select Subject", "select.subject")] {
|
||||
if photocraft_engine::commands::find(id).is_some() && widgets::secondary_button(ui, label, 0.0).clicked() {
|
||||
run.push((id.to_string(), json!({})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (id, p) in run {
|
||||
for (id, p) in run.drain(..) {
|
||||
if let Err(e) = app.run(&id, p) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Text(_) => crate::type_tool::type_properties(app, ui),
|
||||
LayerContent::Shape(_) => crate::vector_ui::shape_properties(app, ui, layer.id),
|
||||
_ => {}
|
||||
}
|
||||
// Re-read: a section above may have changed the layer (e.g. point to paragraph type).
|
||||
let content = app.session.active().and_then(|s| s.doc.layer(layer.id)).map_or_else(|| layer.content.clone(), |l| l.content.clone());
|
||||
if let Some(id) = quick_actions_ui(app, ui, &content) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -69,6 +69,7 @@ pub mod plugin_ui;
|
||||
pub mod prefs_ui;
|
||||
pub mod preset_files_ui;
|
||||
pub mod preset_panels;
|
||||
pub mod props_layout;
|
||||
pub mod proxy;
|
||||
pub mod puppet_ui;
|
||||
pub mod retouch_ui;
|
||||
|
||||
@@ -1116,6 +1116,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
});
|
||||
// Docks zero the item spacing: keep the Opacity and Fill fields apart (#155).
|
||||
ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, if t.pro { "Lock:" } else { "Lock" });
|
||||
if t.pro {
|
||||
@@ -1766,37 +1768,23 @@ fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let Some(id) = st.active_layer else { return };
|
||||
let Some(layer) = st.doc.layer(id).cloned() else { return };
|
||||
ui.horizontal(|ui| {
|
||||
let icon = match &layer.content {
|
||||
LayerContent::Adjustment(_) => "sliders-horizontal",
|
||||
LayerContent::Group(_) => "folder",
|
||||
LayerContent::Fill(_) => "paint-bucket",
|
||||
LayerContent::Text(_) => "type",
|
||||
LayerContent::Shape(_) => "pentagon",
|
||||
_ => "image",
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover());
|
||||
icons::paint(ui, r, icon, 15.0, t.icon);
|
||||
let kind = match &layer.content {
|
||||
LayerContent::Adjustment(a) => a.label().to_string(),
|
||||
LayerContent::Group(g) if g.artboard.is_some() => "Artboard".to_string(),
|
||||
other => format!("{} Layer", other.kind_name()),
|
||||
};
|
||||
ui.label(RichText::new(kind).color(t.text));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(6.0);
|
||||
// Header: kind icon, layer name and kind (#155); sections below draw their own separators.
|
||||
crate::props_layout::header(ui, &layer);
|
||||
let is_adjustment = matches!(layer.content, LayerContent::Adjustment(_));
|
||||
if is_adjustment || layer.artboard().is_some() || !t.pro {
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
adjustment_controls(app, ui, id, adj);
|
||||
} else if layer.artboard().is_some() {
|
||||
crate::artboard_ui::properties(app, ui, &layer);
|
||||
} else if t.pro {
|
||||
// Transform, Align, the kind's sections and Quick Actions.
|
||||
crate::layer_props_ui::properties(app, ui, &layer);
|
||||
} else {
|
||||
if t.pro {
|
||||
crate::layer_props_ui::properties(app, ui, &layer);
|
||||
} else {
|
||||
layer_controls(app, ui, &layer);
|
||||
}
|
||||
layer_controls(app, ui, &layer);
|
||||
if matches!(layer.content, LayerContent::Text(_)) {
|
||||
crate::type_tool::type_properties(app, ui);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Shared Properties-panel layout (#155): the layer header (kind icon + layer name), the one
|
||||
//! collapsible section header every section uses (separator, disclosure chevron, title), column
|
||||
//! widths that make field rows fill the panel, and the Quick Actions each layer kind offers.
|
||||
|
||||
use egui::{Rect, Sense, pos2, vec2};
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PhotocraftApp, widgets};
|
||||
|
||||
/// Height of a section header row.
|
||||
pub const SECTION_H: f32 = 24.0;
|
||||
/// Width of the short label (or glyph) column in front of a field: "W", "tT", "VA"…
|
||||
pub const LABEL_W: f32 = 22.0;
|
||||
/// Horizontal gap between a label and its field.
|
||||
pub const LABEL_GAP: f32 = 4.0;
|
||||
/// Horizontal gap between two label+field columns.
|
||||
pub const COL_GAP: f32 = 8.0;
|
||||
|
||||
/// Icon for a layer kind (the same glyphs as the Layers panel's kind filter).
|
||||
pub fn kind_icon(content: &LayerContent) -> &'static str {
|
||||
match content {
|
||||
LayerContent::Adjustment(_) => "sliders-horizontal",
|
||||
LayerContent::Group(_) => "folder",
|
||||
LayerContent::Fill(_) => "paint-bucket",
|
||||
LayerContent::Text(_) => "type",
|
||||
LayerContent::Shape(_) => "pentagon",
|
||||
LayerContent::Smart(_) => "app-window",
|
||||
LayerContent::Raster(_) => "image",
|
||||
}
|
||||
}
|
||||
|
||||
/// "Type Layer", "Curves", "Artboard"…
|
||||
pub fn kind_label(layer: &Layer) -> String {
|
||||
match &layer.content {
|
||||
LayerContent::Adjustment(a) => a.label().to_string(),
|
||||
LayerContent::Group(g) if g.artboard.is_some() => "Artboard".to_string(),
|
||||
other => format!("{} Layer", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Header row: kind icon, the layer's name, and its kind right-aligned in the dim text colour.
|
||||
pub fn header(ui: &mut egui::Ui, layer: &Layer) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
|
||||
let icon_r = Rect::from_center_size(pos2(r.left() + 10.0, r.center().y), vec2(20.0, 20.0));
|
||||
crate::icons::paint(ui, icon_r, kind_icon(&layer.content), 15.0, t.icon);
|
||||
let kind = kind_label(layer);
|
||||
let kind_g = ui.painter().layout_no_wrap(kind.clone(), egui::FontId::proportional(11.0), t.text_faint);
|
||||
let kind_w = kind_g.size().x;
|
||||
// The name takes what's left between the icon and the kind (elided when long).
|
||||
let name_x = r.left() + 26.0;
|
||||
let name_w = (r.right() - kind_w - 8.0 - name_x).max(0.0);
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(layer.name.clone(), theme::medium(12.5), t.text);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(name_w);
|
||||
let name_g = ui.painter().layout_job(job);
|
||||
ui.painter().galley(pos2(name_x, r.center().y - name_g.size().y / 2.0), name_g, t.text);
|
||||
if r.width() > kind_w + 60.0 {
|
||||
ui.painter().galley(pos2(r.right() - kind_w - 2.0, r.center().y - kind_g.size().y / 2.0), kind_g, t.text_faint);
|
||||
}
|
||||
let name = layer.name.clone();
|
||||
resp.widget_info(move || egui::WidgetInfo::labeled(egui::WidgetType::Label, true, format!("{name} — {kind}")));
|
||||
}
|
||||
|
||||
/// A collapsible section: a separator, then a chevron + title row. Returns whether it's open.
|
||||
/// Every Properties section uses this, so headers look and behave alike.
|
||||
pub fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = egui::Id::new(("props-section", id));
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(true);
|
||||
ui.add_space(theme::ROW_GAP);
|
||||
widgets::hairline(ui);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
crate::icons::paint(
|
||||
ui,
|
||||
Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
11.0,
|
||||
t.text_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), egui::Align2::LEFT_CENTER, title, theme::semibold(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(key, open));
|
||||
}
|
||||
let label = title.to_string();
|
||||
resp.widget_info(move || egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, &label));
|
||||
record(ui.ctx(), title, r);
|
||||
if open {
|
||||
ui.add_space(theme::ROW_GAP / 2.0);
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// Section headers drawn this pass, top to bottom (title, rect), for tests and `ui.inspect`.
|
||||
pub fn sections_drawn(ctx: &egui::Context) -> Vec<(String, Rect)> {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data(|d| d.get_temp::<(u64, Vec<(String, Rect)>)>(egui::Id::new("props-sections"))).filter(|(p, _)| *p + 1 >= pass).map(|(_, v)| v).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn record(ctx: &egui::Context, title: &str, r: Rect) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| {
|
||||
let e = d.get_temp_mut_or_default::<(u64, Vec<(String, Rect)>)>(egui::Id::new("props-sections"));
|
||||
if e.0 != pass {
|
||||
*e = (pass, Vec::new());
|
||||
}
|
||||
e.1.push((title.to_string(), r));
|
||||
});
|
||||
}
|
||||
|
||||
/// Width of each field when `cols` label+field pairs (labels `label_w` wide) share the row.
|
||||
pub fn field_width(avail: f32, cols: usize, label_w: f32) -> f32 {
|
||||
let n = cols.max(1) as f32;
|
||||
((avail - n * (label_w + LABEL_GAP) - (n - 1.0) * COL_GAP) / n).max(36.0)
|
||||
}
|
||||
|
||||
/// The Quick Actions a layer kind offers, as (label, command id), Photoshop-style: pixel layers get
|
||||
/// subject selection, type layers its conversions, shapes and smart objects their own.
|
||||
pub fn quick_actions(content: &LayerContent) -> &'static [(&'static str, &'static str)] {
|
||||
match content {
|
||||
LayerContent::Raster(_) => &[("Remove Background", "layer.removeBackground"), ("Select Subject", "select.subject")],
|
||||
LayerContent::Text(_) => &[
|
||||
("Convert to Shape", "type.convertToShape"),
|
||||
("Convert to Paragraph Text", "type.convertToParagraphText"),
|
||||
("Convert to Point Text", "type.convertToPointText"),
|
||||
("Create Warped Text", "type.warpText"),
|
||||
("Rasterize Type", "type.rasterizeTypeLayer"),
|
||||
],
|
||||
LayerContent::Shape(_) => &[("Rasterize Shape", "layer.rasterize.shape"), ("Convert to Smart Object", "layer.smartObjects.convertToSmartObject")],
|
||||
LayerContent::Smart(_) => &[("Edit Contents", "layer.smartObjects.editContents"), ("Rasterize Smart Object", "layer.rasterize.smartObject")],
|
||||
LayerContent::Group(_) => &[("Ungroup Layers", "layer.ungroupLayers"), ("Convert to Smart Object", "layer.smartObjects.convertToSmartObject")],
|
||||
LayerContent::Fill(_) => &[("Rasterize Fill Content", "layer.rasterize.fillContent")],
|
||||
LayerContent::Adjustment(_) => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The Quick Actions shown right now: the kind's list, limited to commands that exist and are
|
||||
/// enabled (Convert to Paragraph Text only for point type, and vice versa).
|
||||
pub fn visible_quick_actions(app: &PhotocraftApp, content: &LayerContent) -> Vec<(&'static str, &'static str)> {
|
||||
quick_actions(content).iter().copied().filter(|(_, id)| crate::menus::is_live(id) && crate::menus::is_enabled(app, id)).collect()
|
||||
}
|
||||
|
||||
/// Quick Actions section body: full-width buttons, two per row when the panel is wide enough.
|
||||
/// Returns the command to run (through the menus, so ones with dialogs open them).
|
||||
pub fn quick_actions_ui(app: &PhotocraftApp, ui: &mut egui::Ui, content: &LayerContent) -> Option<&'static str> {
|
||||
let actions = visible_quick_actions(app, content);
|
||||
if actions.is_empty() || !section(ui, "quick", "Quick Actions") {
|
||||
return None;
|
||||
}
|
||||
let avail = ui.available_width();
|
||||
let cols = if avail >= 360.0 { 2 } else { 1 };
|
||||
let mut out = None;
|
||||
for row in actions.chunks(cols) {
|
||||
// A short last row (or a lone action) still spans the panel.
|
||||
let w = ((avail - (row.len() - 1) as f32 * COL_GAP) / row.len() as f32).floor();
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = COL_GAP;
|
||||
for (label, id) in row {
|
||||
let resp = widgets::secondary_button(ui, label, w);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, *label));
|
||||
if resp.clicked() {
|
||||
out = Some(*id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::ROW_GAP);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "props_layout_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,219 @@
|
||||
//! #155: Properties sections share one header style, Quick Actions fit the layer kind, controls
|
||||
//! fill the panel at every dock width, and the Layers panel's Opacity/Fill rows keep their gap.
|
||||
|
||||
use egui::{Rect, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// A document with one layer of each kind; returns the session and the ids by kind.
|
||||
fn kinds() -> (photocraft_engine::Session, Vec<(&'static str, u64)>) {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = |v: serde_json::Value| v["layer"].as_u64().unwrap();
|
||||
let pixel = id(s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap());
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let shape = id(s.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 80, 40], "fill": "#ff0000"})).unwrap());
|
||||
let ty = id(s.execute("type.create", json!({"text": "Headline", "size": 48, "x": 40, "y": 200})).unwrap());
|
||||
(s, vec![("pixel", pixel), ("shape", shape), ("type", ty)])
|
||||
}
|
||||
|
||||
fn layer_of(app: &PhotocraftApp, id: u64) -> Layer {
|
||||
app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
/// Pro theme with only the Properties group expanded (it then fills the column).
|
||||
fn harness(session: photocraft_engine::Session, ppp: f32, dock_width: f32) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 1600.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(session, crate::Services::default())
|
||||
});
|
||||
set(&mut h, json!({"dockWidth": dock_width, "dock": {"collapsed": ["color", "navigator", "history", "layers"]}}));
|
||||
h
|
||||
}
|
||||
|
||||
fn set(h: &mut Harness<'static, PhotocraftApp>, p: serde_json::Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = crate::control::ControlRequest::new("ui.set", p);
|
||||
crate::control::handle(h.state_mut(), &ctx, &req);
|
||||
h.run_steps(8);
|
||||
}
|
||||
|
||||
fn select(h: &mut Harness<'static, PhotocraftApp>, id: u64) {
|
||||
h.state_mut().run("layer.select", json!({"layer": id})).unwrap();
|
||||
h.run_steps(8);
|
||||
}
|
||||
|
||||
fn headers(h: &Harness<'_, PhotocraftApp>) -> Vec<(String, Rect)> {
|
||||
sections_drawn(&h.ctx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_actions_fit_the_layer_kind() {
|
||||
let ids = |c: &LayerContent| quick_actions(c).iter().map(|(_, id)| *id).collect::<Vec<_>>();
|
||||
let (s, layers) = kinds();
|
||||
let app = PhotocraftApp::new(s, crate::Services::default());
|
||||
for (kind, id) in layers {
|
||||
let l = layer_of(&app, id);
|
||||
let all = ids(&l.content);
|
||||
assert!(!all.is_empty(), "{kind}");
|
||||
match kind {
|
||||
"pixel" => assert!(all.contains(&"select.subject")),
|
||||
_ => assert!(!all.contains(&"select.subject"), "{kind}: Select Subject is for pixel layers"),
|
||||
}
|
||||
// Every listed command exists, except ones not implemented yet (filtered when shown).
|
||||
for id in &all {
|
||||
assert!(crate::menus::is_live(id) || *id == "layer.removeBackground", "{kind}: {id} is not a command");
|
||||
}
|
||||
}
|
||||
let ty = app.session.active().unwrap().doc.layers.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap().content.clone();
|
||||
assert_eq!(ids(&ty), ["type.convertToShape", "type.convertToParagraphText", "type.convertToPointText", "type.warpText", "type.rasterizeTypeLayer"]);
|
||||
assert!(ids(&LayerContent::Adjustment(photocraft_doc::Adjustment::Invert)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_layer_sections_share_one_header_style_and_actions_fit() {
|
||||
let (s, layers) = kinds();
|
||||
let ty = layers.iter().find(|(k, _)| *k == "type").unwrap().1;
|
||||
let mut h = harness(s, 1.0, 320.0);
|
||||
select(&mut h, ty);
|
||||
let hs = headers(&h);
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, ["Transform", "Align and Distribute", "Character", "Paragraph", "Type Options", "Quick Actions"]);
|
||||
let first = hs[0].1;
|
||||
for (n, r) in &hs {
|
||||
assert!((r.left() - first.left()).abs() < 0.5 && (r.width() - first.width()).abs() < 0.5, "{n}: {r:?} vs {first:?}");
|
||||
assert!((r.height() - SECTION_H).abs() < 0.5, "{n}: {r:?}");
|
||||
}
|
||||
// The header names the layer.
|
||||
assert!(h.query_by_value("Headline — Type Layer").is_some());
|
||||
// Only type-relevant Quick Actions (point type: Convert to Paragraph Text, not Point Text).
|
||||
let app = h.state();
|
||||
let shown: Vec<&str> = visible_quick_actions(app, &layer_of(app, ty).content).iter().map(|(l, _)| *l).collect();
|
||||
assert_eq!(shown, ["Convert to Shape", "Convert to Paragraph Text", "Create Warped Text", "Rasterize Type"]);
|
||||
for label in &shown {
|
||||
assert!(h.query_by_label(label).is_some(), "{label} button");
|
||||
}
|
||||
assert!(h.query_by_label("Select Subject").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_kinds_use_the_same_headers() {
|
||||
let (s, layers) = kinds();
|
||||
let mut h = harness(s, 1.0, 320.0);
|
||||
for (kind, id) in layers {
|
||||
select(&mut h, id);
|
||||
let hs = headers(&h);
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names.first(), Some(&"Transform"), "{kind}: {names:?}");
|
||||
assert_eq!(names.last(), Some(&"Quick Actions"), "{kind}: {names:?}");
|
||||
if kind == "shape" {
|
||||
assert!(names.contains(&"Appearance") && names.contains(&"Shape"), "{names:?}");
|
||||
}
|
||||
let first = hs[0].1;
|
||||
assert!(hs.iter().all(|(_, r)| (r.left() - first.left()).abs() < 0.5 && (r.height() - SECTION_H).abs() < 0.5), "{kind}: {hs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_controls_fill_the_panel_at_every_width_and_scale() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
for dw in [250.0, 380.0, 520.0] {
|
||||
let (s, layers) = kinds();
|
||||
let ty = layers.iter().find(|(k, _)| *k == "type").unwrap().1;
|
||||
let mut h = harness(s, ppp, dw);
|
||||
select(&mut h, ty);
|
||||
let hs = headers(&h);
|
||||
let header = hs.iter().find(|(n, _)| n == "Character").unwrap().1;
|
||||
let what = format!("{ppp}x @ {dw}");
|
||||
// Font family and style dropdowns span the section.
|
||||
let combos: Vec<Rect> = h
|
||||
.query_all_by_role(egui::accesskit::Role::ComboBox)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0)
|
||||
.collect();
|
||||
assert!(combos.len() >= 2, "{what}: {combos:?}");
|
||||
for r in &combos[..2] {
|
||||
assert!((r.left() - header.left()).abs() <= 2.0 && (r.right() - header.right()).abs() <= 2.0, "{what}: {r:?} vs {header:?}");
|
||||
}
|
||||
// Number fields end at the section's right edge (the second column), never past it.
|
||||
let fields: Vec<Rect> = h
|
||||
.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0)
|
||||
.collect();
|
||||
assert!(fields.len() >= 6, "{what}: {} fields", fields.len());
|
||||
let right = fields.iter().map(|r| r.right()).fold(f32::MIN, f32::max);
|
||||
assert!(right <= header.right() + 0.5 && right >= header.right() - 30.0, "{what}: fields end at {right}, panel {}", header.right());
|
||||
// Transform's second column ends where Character's does (one right edge for the panel).
|
||||
let transform = hs[0].1;
|
||||
let t_right = h
|
||||
.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.top() >= transform.bottom() && r.bottom() <= header.top())
|
||||
.map(|r| r.right())
|
||||
.fold(f32::MIN, f32::max);
|
||||
// (Both rows have a unit suffix: compare with the size/leading row.)
|
||||
let top = fields.iter().map(|r| r.top()).fold(f32::MAX, f32::min);
|
||||
let lead = fields.iter().filter(|r| (r.top() - top).abs() < 0.5).map(|r| r.right()).fold(f32::MIN, f32::max);
|
||||
assert!((t_right - lead).abs() < 0.5, "{what}: Transform fields end at {t_right}, Character at {lead}");
|
||||
// Quick Actions buttons span the panel too.
|
||||
let qa = h.get_by_label("Rasterize Type").rect();
|
||||
assert!(qa.right() >= header.right() - 2.0 && qa.right() <= header.right() + 1.0, "{what}: {qa:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_opacity_and_fill_rows_keep_a_gap() {
|
||||
for dw in [250.0, 520.0] {
|
||||
let (s, layers) = kinds();
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
});
|
||||
set(&mut h, json!({"dockWidth": dw}));
|
||||
select(&mut h, layers[0].1);
|
||||
let o = h.query_all_by_label("Opacity:").next().unwrap().rect();
|
||||
let f = h.query_all_by_label("Fill:").next().unwrap().rect();
|
||||
// Rows are 24 pt high: centres at least a row plus the gap apart.
|
||||
assert!(f.center().y - o.center().y >= 24.0 + crate::theme::ROW_GAP - 0.5, "{dw}: {o:?} {f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_width_divides_the_row() {
|
||||
for avail in [200.0, 300.0, 500.0] {
|
||||
let w = field_width(avail, 2, LABEL_W);
|
||||
assert!((2.0 * (LABEL_W + LABEL_GAP + w) + COL_GAP - avail).abs() < 1e-3);
|
||||
}
|
||||
assert_eq!(field_width(10.0, 3, LABEL_W), 36.0);
|
||||
assert_eq!(field_width(f32::NAN, 0, LABEL_W), 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_and_paragraph_dock_panels_share_the_headers_without_type_options() {
|
||||
let (s, layers) = kinds();
|
||||
let (ty, px) = (layers.iter().find(|(k, _)| *k == "type").unwrap().1, layers[0].1);
|
||||
let mut h = harness(s, 1.0, 320.0);
|
||||
// Only the Window › Character group, expanded.
|
||||
set(&mut h, json!({"panels": {"character": true}, "dock": {"collapsed": ["color", "navigator", "history", "layers", "properties"]}}));
|
||||
select(&mut h, ty);
|
||||
for (tab, want) in [(0, "Character"), (1, "Paragraph")] {
|
||||
set(&mut h, json!({"dockTabs": {"character": tab}}));
|
||||
let hs = headers(&h);
|
||||
assert!(hs.iter().all(|(_, r)| (r.height() - SECTION_H).abs() < 0.5), "{hs:?}");
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, [want], "tab {tab}");
|
||||
}
|
||||
// No type layer: the empty-state message, no sections.
|
||||
select(&mut h, px);
|
||||
assert!(headers(&h).is_empty());
|
||||
assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_some());
|
||||
}
|
||||
@@ -375,6 +375,10 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Vertical gap between stacked control rows in panels (Properties fields, the Layers panel's
|
||||
/// Opacity and Fill rows); docks zero egui's item spacing, so rows add this themselves.
|
||||
pub const ROW_GAP: f32 = 4.0;
|
||||
|
||||
pub fn canvas_bg(t: &Tokens) -> Color32 {
|
||||
t.canvas
|
||||
}
|
||||
|
||||
+239
-142
@@ -565,10 +565,10 @@ pub fn styles(family: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Searchable font-family combo box.
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String) -> bool {
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32) -> bool {
|
||||
let mut changed = false;
|
||||
let search_id = ui.id().with("font-search");
|
||||
egui::ComboBox::from_id_salt("type-font").selected_text(current.as_str()).width(170.0).height(460.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
egui::ComboBox::from_id_salt("type-font").selected_text(current.as_str()).width(width).height(460.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search fonts").desired_width(200.0));
|
||||
if !r.has_focus() && q.is_empty() {
|
||||
@@ -649,7 +649,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
None => (o.type_font.clone(), o.type_style.clone(), o.type_size),
|
||||
};
|
||||
let _ = crate::icons::button(ui, "text-cursor", 24.0, false, "Toggle text orientation");
|
||||
if font_picker(ui, &mut fam) {
|
||||
if font_picker(ui, &mut fam, 170.0) {
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
@@ -757,7 +757,7 @@ fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::CharStyle, ph
|
||||
|
||||
fn icon_label(ui: &mut egui::Ui, icon: &str, tip: &str) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui, r, icon, 12.0, t.text_dim);
|
||||
resp.on_hover_text(tip);
|
||||
}
|
||||
@@ -801,9 +801,37 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties panel sections for a type layer: Character and Paragraph (Photoshop CC layout).
|
||||
/// A toggle button (faux styles, paragraph alignment) of width `w`, painted by `paint`.
|
||||
fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnce(&egui::Ui, egui::Rect, Color32)) -> bool {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(w, 24.0), egui::Sense::click());
|
||||
let bg = if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(r, t.radius_sm, bg);
|
||||
if on {
|
||||
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside);
|
||||
}
|
||||
paint(ui, r, if on { t.text } else { t.text_dim });
|
||||
let tip = tip.to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip));
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
/// Width of each of `n` equal cells filling `avail` with 2 pt gaps.
|
||||
fn cell_width(avail: f32, n: usize) -> f32 {
|
||||
let n = n.max(1) as f32;
|
||||
((avail - (n - 1.0) * 2.0) / n).floor().max(20.0)
|
||||
}
|
||||
|
||||
/// Properties panel sections for a type layer: Character, Paragraph and Type Options (#155).
|
||||
pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
type_sections(app, ui, true, true);
|
||||
type_options(app, ui);
|
||||
}
|
||||
|
||||
/// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock
|
||||
@@ -818,35 +846,42 @@ pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bo
|
||||
type_sections(app, ui, !paragraph, paragraph);
|
||||
}
|
||||
|
||||
/// The Character and/or Paragraph sections, with the shared collapsible headers and fields that
|
||||
/// fill the panel width (#155).
|
||||
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) {
|
||||
use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section};
|
||||
use crate::theme::ROW_GAP;
|
||||
let Some((c, para)) = styles_at(app) else { return };
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let section = |ui: &mut egui::Ui, title: &str| {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(title).font(crate::theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(2.0);
|
||||
};
|
||||
let w = ((ui.available_width() - 70.0) / 2.0).clamp(50.0, 90.0);
|
||||
if character {
|
||||
section(ui, "Character");
|
||||
let mut fam = c.font_family.clone();
|
||||
// Row helper: `ui.horizontal` with the column gap, then the shared row gap.
|
||||
let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| {
|
||||
ui.horizontal(|ui| {
|
||||
if font_picker(ui, &mut fam) {
|
||||
ui.spacing_mut().item_spacing.x = COL_GAP;
|
||||
add(ui)
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
};
|
||||
if character && section(ui, "character", "Character") {
|
||||
let full = ui.available_width();
|
||||
let w = field_width(full, 2, LABEL_W);
|
||||
let mut fam = c.font_family.clone();
|
||||
row(ui, &mut |ui| {
|
||||
if font_picker(ui, &mut fam, full) {
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
row(ui, &mut |ui| {
|
||||
let mut style = if c.font_style.is_empty() { "Regular".to_string() } else { c.font_style.clone() };
|
||||
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
|
||||
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
|
||||
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
|
||||
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, full) {
|
||||
apply(app, ui.ctx(), json!({"fontStyle": style}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
row(ui, &mut |ui| {
|
||||
let k = shown_scale(app);
|
||||
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
|
||||
app.ui.tool_options.type_size = v;
|
||||
@@ -857,28 +892,31 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
apply(app, ui.ctx(), json!({"leading": v / k}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
row(ui, &mut |ui| {
|
||||
let mut k = match c.kerning {
|
||||
photocraft_doc::text::Kerning::Metrics => "metrics",
|
||||
photocraft_doc::text::Kerning::Optical => "optical",
|
||||
photocraft_doc::text::Kerning::Off => "off",
|
||||
}
|
||||
.to_string();
|
||||
icon_label(ui, "text-cursor", "Kerning");
|
||||
if crate::widgets::dropdown(
|
||||
ui,
|
||||
"props-kern",
|
||||
&mut k,
|
||||
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
|
||||
w,
|
||||
) {
|
||||
apply(app, ui.ctx(), json!({"kerning": k}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
icon_label(ui, "text-cursor", "Kerning");
|
||||
if crate::widgets::dropdown(
|
||||
ui,
|
||||
"props-kern",
|
||||
&mut k,
|
||||
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
|
||||
w,
|
||||
) {
|
||||
apply(app, ui.ctx(), json!({"kerning": k}));
|
||||
}
|
||||
});
|
||||
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
|
||||
apply(app, ui.ctx(), json!({"tracking": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"verticalScale": v}));
|
||||
}
|
||||
@@ -886,90 +924,89 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
apply(app, ui.ctx(), json!({"horizontalScale": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"baselineShift": v}));
|
||||
}
|
||||
// Colour chip.
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Color:").color(t.text_dim).size(12.0));
|
||||
let rgb = c.color.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(40.0, 18.0), egui::Sense::click());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
}
|
||||
// Colour: label + a swatch filling the rest of the second column.
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
let g = ui.painter().layout_no_wrap("Color".into(), egui::FontId::proportional(12.0), t.text_dim);
|
||||
let lw = g.size().x.max(LABEL_W);
|
||||
let (lr, _) = ui.allocate_exact_size(egui::vec2(lw, 22.0), egui::Sense::hover());
|
||||
ui.painter().galley(egui::pos2(lr.left(), lr.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
let rgb = c.color.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let sw = (LABEL_W + LABEL_GAP + w - lw - LABEL_GAP).max(24.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(sw, 22.0), egui::Sense::click());
|
||||
ui.painter().rect_filled(rect, t.radius_sm, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
let resp = resp.on_hover_text("Text color");
|
||||
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Faux styles row: T (bold) T (italic) TT Tᴛ T̲ T̶
|
||||
// Faux styles: T (bold) T (italic) TT Tᴛ T̲ T̶, sharing the row evenly.
|
||||
let caps = c.caps;
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
|
||||
(
|
||||
"TT",
|
||||
"All Caps",
|
||||
caps == photocraft_doc::text::Caps::AllCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
|
||||
),
|
||||
(
|
||||
"Tᴛ",
|
||||
"Small Caps",
|
||||
caps == photocraft_doc::text::Caps::SmallCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
|
||||
),
|
||||
("T", "Underline", c.underline, json!({"underline": !c.underline})),
|
||||
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
|
||||
];
|
||||
let cw = cell_width(full, toggles.len());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let caps = c.caps;
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
|
||||
(
|
||||
"TT",
|
||||
"All Caps",
|
||||
caps == photocraft_doc::text::Caps::AllCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
|
||||
),
|
||||
(
|
||||
"Tᴛ",
|
||||
"Small Caps",
|
||||
caps == photocraft_doc::text::Caps::SmallCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
|
||||
),
|
||||
("T", "Underline", c.underline, json!({"underline": !c.underline})),
|
||||
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
|
||||
];
|
||||
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click());
|
||||
let bg = if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(r, 3.0, bg);
|
||||
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
|
||||
let col = if on { t.text } else { t.text_dim };
|
||||
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
|
||||
let pos = r.center() - g.size() / 2.0;
|
||||
let gr = egui::Rect::from_min_size(pos, g.size());
|
||||
if i == 1 {
|
||||
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
|
||||
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
|
||||
let slant = (bot - top) * 0.25;
|
||||
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
|
||||
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
|
||||
} else {
|
||||
ui.painter().galley(pos, g, col);
|
||||
}
|
||||
if i == 4 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
|
||||
}
|
||||
if i == 5 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
|
||||
}
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
let clicked = toggle_cell(ui, cw, on, tip, |ui, r, col| {
|
||||
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
|
||||
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
|
||||
let pos = r.center() - g.size() / 2.0;
|
||||
let gr = egui::Rect::from_min_size(pos, g.size());
|
||||
if i == 1 {
|
||||
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
|
||||
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
|
||||
let slant = (bot - top) * 0.25;
|
||||
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
|
||||
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
|
||||
} else {
|
||||
ui.painter().galley(pos, g, col);
|
||||
}
|
||||
if i == 4 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
|
||||
}
|
||||
if i == 5 {
|
||||
ui.painter()
|
||||
.line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
|
||||
}
|
||||
});
|
||||
if clicked {
|
||||
apply(app, ui.ctx(), props);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
if !paragraph {
|
||||
return;
|
||||
}
|
||||
section(ui, "Paragraph");
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
if paragraph && section(ui, "paragraph", "Paragraph") {
|
||||
use photocraft_doc::text::TextAlign as A;
|
||||
let full = ui.available_width();
|
||||
let w = field_width(full, 2, LABEL_W);
|
||||
let items = [
|
||||
("left", "Left align text", A::Left),
|
||||
("center", "Center text", A::Center),
|
||||
@@ -979,53 +1016,113 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
("justifyRight", "Justify last right", A::JustifyRight),
|
||||
("justifyAll", "Justify all", A::JustifyAll),
|
||||
];
|
||||
for (key, tip, a) in items {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 24.0), egui::Sense::click());
|
||||
let on = para.align == a;
|
||||
ui.painter().rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
},
|
||||
);
|
||||
if on {
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside);
|
||||
let cw = cell_width(full, items.len());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (key, tip, a) in items {
|
||||
let clicked = toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| {
|
||||
// The glyph keeps its size; the cell grows around it.
|
||||
let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0));
|
||||
align_glyph(ui.painter(), g, a, if para.align == a { col } else { t.icon });
|
||||
});
|
||||
if clicked {
|
||||
apply(app, ui.ctx(), json!({"align": key}));
|
||||
}
|
||||
}
|
||||
align_glyph(ui.painter(), r.shrink2(egui::vec2(7.0, 7.0)), a, if on { t.text } else { t.icon });
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
apply(app, ui.ctx(), json!({"align": key}));
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"startIndent": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"endIndent": v}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"firstLineIndent": v}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceBefore": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceAfter": v}));
|
||||
}
|
||||
});
|
||||
let mut hy = para.hyphenate;
|
||||
if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() {
|
||||
apply(app, ui.ctx(), json!({"hyphenate": hy}));
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type Options: anti-aliasing and orientation of the whole layer (the Type menu's commands).
|
||||
fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
use crate::props_layout::{COL_GAP, LABEL_GAP};
|
||||
let Some((layer, _)) = target(app) else { return };
|
||||
let Some((aa, vertical)) = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|st| text_layer(&st.doc, LayerId(layer)))
|
||||
.map(|t| (photocraft_engine::type_extra_cmds::aa_name(t.antialias).to_string(), t.orientation == photocraft_doc::text::Orientation::Vertical))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !crate::props_layout::section(ui, "typeOptions", "Type Options") {
|
||||
return;
|
||||
}
|
||||
let full = ui.available_width();
|
||||
let mut run: Option<String> = None;
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"startIndent": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"endIndent": v}));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = COL_GAP;
|
||||
// Anti-aliasing dropdown (aₐ glyph label), then the two orientation toggles.
|
||||
let orient_w = 2.0 * 28.0 + 2.0;
|
||||
let dd_w = (full - crate::props_layout::LABEL_W - LABEL_GAP - COL_GAP - orient_w).max(60.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "aa", crate::theme::semibold(11.0), t.text_dim);
|
||||
resp.on_hover_text("Anti-aliasing method");
|
||||
let mut cur = aa.clone();
|
||||
let opts: Vec<(String, &str)> = ["none", "sharp", "crisp", "strong", "smooth", "windowsLcd", "windows"]
|
||||
.into_iter()
|
||||
.filter_map(|k| photocraft_engine::commands::find(&format!("type.antiAlias.{k}")).map(|c| (k.to_string(), c.label)))
|
||||
.collect();
|
||||
if crate::widgets::dropdown(ui, "props-type-aa", &mut cur, &opts, dd_w) {
|
||||
run = Some(format!("type.antiAlias.{cur}"));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (key, tip, on) in [("horizontal", "Horizontal type", !vertical), ("vertical", "Vertical type", vertical)] {
|
||||
let clicked = toggle_cell(ui, 28.0, on, tip, |ui, r, col| {
|
||||
let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 12.0));
|
||||
let s = Stroke::new(1.3, col);
|
||||
if key == "horizontal" {
|
||||
ui.painter().line_segment([g.left_top(), g.right_top()], s);
|
||||
ui.painter().line_segment([g.center_top(), g.center_bottom()], s);
|
||||
} else {
|
||||
ui.painter().line_segment([g.left_top(), g.left_bottom()], s);
|
||||
ui.painter().line_segment([g.left_center(), g.right_center()], s);
|
||||
}
|
||||
});
|
||||
if clicked && !on {
|
||||
run = Some(format!("type.orientation.{key}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"firstLineIndent": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceBefore": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceAfter": v}));
|
||||
}
|
||||
});
|
||||
let mut hy = para.hyphenate;
|
||||
if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() {
|
||||
apply(app, ui.ctx(), json!({"hyphenate": hy}));
|
||||
ui.add_space(crate::theme::ROW_GAP);
|
||||
if let Some(id) = run
|
||||
&& let Err(e) = app.run(&id, json!({}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -445,42 +445,43 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut edit: Option<Value> = None;
|
||||
let key = |k: &str| format!("shape-{}-{k}", id.0);
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Appearance").font(crate::theme::semibold(12.0)).color(t.text));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Fill").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.fill.as_ref(), "Set shape fill type") {
|
||||
edit = Some(if c == "none" { json!({"fill": null}) } else { json!({"fill": c, "coalesce": key("fill")}) });
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new("Stroke").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), "Set shape stroke type") {
|
||||
edit = Some(if c == "none" {
|
||||
json!({"stroke": null})
|
||||
} else {
|
||||
json!({"stroke": {"color": c, "width": sh.stroke.as_ref().map_or(3.0, |s| s.width)}, "coalesce": key("stroke")})
|
||||
});
|
||||
}
|
||||
let mut w = sh.stroke.as_ref().map_or(0.0, |s| s.width);
|
||||
if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() {
|
||||
edit = Some(if w <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": {"width": w}, "coalesce": key("stroke-w")}) });
|
||||
}
|
||||
if sh.stroke.is_some() {
|
||||
let mut align = match sh.stroke.as_ref().map(|s| s.align) {
|
||||
Some(photocraft_doc::vector::StrokeAlign::Inside) => "inside",
|
||||
Some(photocraft_doc::vector::StrokeAlign::Outside) => "outside",
|
||||
_ => "center",
|
||||
// The shared collapsible section headers (#155).
|
||||
if crate::props_layout::section(ui, "appearance", "Appearance") {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Fill").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.fill.as_ref(), "Set shape fill type") {
|
||||
edit = Some(if c == "none" { json!({"fill": null}) } else { json!({"fill": c, "coalesce": key("fill")}) });
|
||||
}
|
||||
.to_string();
|
||||
let opts = [("inside".to_string(), "Inside"), ("center".to_string(), "Center"), ("outside".to_string(), "Outside")];
|
||||
if crate::widgets::dropdown(ui, &key("align"), &mut align, &opts, 84.0) {
|
||||
edit = Some(json!({"stroke": {"align": align}}));
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new("Stroke").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), "Set shape stroke type") {
|
||||
edit = Some(if c == "none" {
|
||||
json!({"stroke": null})
|
||||
} else {
|
||||
json!({"stroke": {"color": c, "width": sh.stroke.as_ref().map_or(3.0, |s| s.width)}, "coalesce": key("stroke")})
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(live) = &sh.live {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Shape").font(crate::theme::semibold(12.0)).color(t.text));
|
||||
let mut w = sh.stroke.as_ref().map_or(0.0, |s| s.width);
|
||||
if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() {
|
||||
edit = Some(if w <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": {"width": w}, "coalesce": key("stroke-w")}) });
|
||||
}
|
||||
if sh.stroke.is_some() {
|
||||
let mut align = match sh.stroke.as_ref().map(|s| s.align) {
|
||||
Some(photocraft_doc::vector::StrokeAlign::Inside) => "inside",
|
||||
Some(photocraft_doc::vector::StrokeAlign::Outside) => "outside",
|
||||
_ => "center",
|
||||
}
|
||||
.to_string();
|
||||
let opts = [("inside".to_string(), "Inside"), ("center".to_string(), "Center"), ("outside".to_string(), "Outside")];
|
||||
if crate::widgets::dropdown(ui, &key("align"), &mut align, &opts, 84.0) {
|
||||
edit = Some(json!({"stroke": {"align": align}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(live) = &sh.live
|
||||
&& crate::props_layout::section(ui, "liveShape", "Shape")
|
||||
{
|
||||
let num = |ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, unit: &str| -> bool {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim).size(12.0));
|
||||
crate::widgets::value_field(ui, v, range, unit, 64.0).changed()
|
||||
|
||||
Reference in new issue
Block a user