Compare commits

...
8 changed files with 730 additions and 253 deletions

No files matched your search

+41 -51
View File
@@ -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)]
+1
View File
@@ -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;
+14 -26
View File
@@ -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);
}
+177
View File
@@ -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;
+219
View File
@@ -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());
}
+4
View File
@@ -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
View File
@@ -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;
}
}
+35 -34
View File
@@ -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()