Fix Shape Stroke controls, popup interactions and paint colours (#3019)

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 09:46:44 -04:00
1 parent 3cbd727b85
commit 30bd8a5598
12 files changed
+1749 -444

No files matched your search

+4 -6
View File
@@ -971,9 +971,6 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
if let Some(shown) = crate::shape_fill_ui::display_doc(app, idx) {
return shown;
}
if let Some(shown) = crate::shape_stroke_ui::display_doc(app, idx) {
return shown;
}
if let Some(shown) = crate::type_transform::display_doc(app, idx) {
return shown;
}
@@ -2486,6 +2483,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation, aspect };
let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid);
let response = ui.allocate_rect(rect, Sense::click_and_drag());
let stroke_dismissal = primary && crate::shape_stroke_ui::dismiss_from_canvas(app, &response);
let painter = ui.painter_at(rect);
match crate::prefs_ui::pasteboard_color(app) {
@@ -2829,7 +2827,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on.
let flick_id = egui::Id::new(("pc-flick-pan", idx, primary, ctx.viewport_id()));
let flick_on = app.session.prefs().tools.enable_flick_panning;
if let Some(d) = crate::flick_pan::frame(&ctx, flick_id, hand_pan && response.dragged(), response.drag_delta(), flick_on) {
if let Some(d) = crate::flick_pan::frame(&ctx, flick_id, hand_pan && response.dragged() && !stroke_dismissal, response.drag_delta(), flick_on) {
let d = xf.unmap_vec(d) / (view.zoom / ppp);
if d.is_finite() {
view.center[0] -= d.x;
@@ -2840,13 +2838,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
ctx.data_mut(|m| m.get_temp_mut_or_default::<crate::flick_pan::Flick>(flick_id).stop());
}
}
if hand_pan && response.dragged() {
if hand_pan && response.dragged() && !stroke_dismissal {
let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp);
view.center[0] -= d.x;
view.center[1] -= d.y;
// The tool cursors below are skipped while panning: the fist is set here (#2196).
ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true));
} else if primary {
} else if primary && !stroke_dismissal {
let mods = ui.input(|i| i.modifiers);
// An ⌥-click the window manager took never arrives; say so (alt_grab.rs).
crate::alt_grab::watch(app, &ctx, egui::Id::new(("pc-alt-grab", idx)), rect, tool, !under_dialog);
+2 -2
View File
@@ -474,7 +474,6 @@ pub struct PhotocraftApp {
pub(crate) blend_preview: Option<blend_preview::BlendPreview>,
/// Patch Tool drag: the healed document at the pointer (`patch_preview`).
pub(crate) patch_preview: Option<patch_preview::PatchPreview>,
pub(crate) shape_stroke_preview: Option<shape_stroke_ui::ShapeStrokePreview>,
/// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`).
pub(crate) magnetic: magnetic_lasso_ui::Runtime,
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
@@ -688,7 +687,6 @@ impl PhotocraftApp {
move_hover: None,
blend_preview: None,
patch_preview: None,
shape_stroke_preview: None,
magnetic: Default::default(),
secondary_erase: false,
defer_live_stroke: false,
@@ -1481,6 +1479,7 @@ impl eframe::App for PhotocraftApp {
vector_ui::pen_commit(self, false);
}
transform_tool::end_if_left(self);
shape_stroke_ui::sync_editor(self, ctx);
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
prefs_ui::tick(self, ctx);
@@ -1642,6 +1641,7 @@ impl eframe::App for PhotocraftApp {
// Tool options are remembered between launches the same way (#2814).
tool_memory::persist(self, &ctx);
self.automation_input = false;
shape_stroke_ui::finish_frame(self, &ctx);
native_menu::sync(self, &ctx);
if screen_picker::busy(&ctx) {
// Block input without a backdrop: the worker may still be capturing the screen.
+77 -338
View File
@@ -1,11 +1,9 @@
//! Shape stroke geometry controls shared by the tool defaults and Properties.
use egui::{Sense, Stroke, vec2};
use egui::vec2;
use photocraft_doc::{LineCap, LineJoin, ShapeStroke, StrokeAlign};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::theme::Tokens;
/// Colour and width stay in their existing controls. These options never replace a shape's paint.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -133,353 +131,94 @@ pub fn paint_shape_preview(painter: &egui::Painter, shape: &photocraft_doc::Shap
}
}
/// Transient editor content is still plain serde state, visible to inspection.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StrokeEditor {
pub key: String,
pub draft: StrokeOptions,
pub original: StrokeOptions,
pub target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
mod editor;
pub use editor::{StrokeEditor, button};
fn open_frame_id() -> egui::Id {
egui::Id::new("shape-stroke-open-frame")
}
pub(crate) struct ShapeStrokePreview {
doc: photocraft_doc::DocId,
revision: u64,
target: photocraft_doc::LayerId,
options: StrokeOptions,
shown: std::sync::Arc<photocraft_doc::Document>,
key: u64,
pub(super) fn mark_rendered(ctx: &egui::Context, key: &str) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(egui::Id::new(("shape-stroke-rendered", key)), pass));
}
/// Cache the selected shape preview by revision and draft. Cancel simply drops the snapshot.
pub(crate) fn display_doc(app: &mut crate::PhotocraftApp, idx: usize) -> Option<(std::sync::Arc<photocraft_doc::Document>, u64)> {
let active = app.session.active_index() == Some(idx);
if !active {
return None;
}
let editor = app.ui.stroke_editor.as_ref().filter(|e| active && e.target.is_some() && e.draft != e.original && e.draft.valid());
let Some(editor) = editor else {
app.shape_stroke_preview = None;
return None;
};
let (doc, target) = editor.target?;
let st = app.session.documents().get(idx)?;
let current = st.doc.layer(target).and_then(|l| match &l.content {
photocraft_doc::LayerContent::Shape(sh) => sh.stroke.as_ref().map(StrokeOptions::from),
_ => None,
});
let locks = st.doc.effective_locks(target);
if st.doc.id != doc || st.active_layer != Some(target) || locks.all || locks.pixels || current.as_ref() != Some(&editor.original) {
app.shape_stroke_preview = None;
/// A hidden options row or collapsed Appearance section no longer owns input.
/// Called after all options bars and Properties windows have had a chance to draw.
pub(crate) fn finish_frame(app: &mut crate::PhotocraftApp, ctx: &egui::Context) {
let Some(e) = &app.ui.stroke_editor else { return };
let shown = ctx.data(|d| d.get_temp::<u64>(egui::Id::new(("shape-stroke-rendered", e.key.as_str()))));
if shown != Some(ctx.cumulative_pass_nr()) {
editor::close_children(ctx, &e.key);
app.ui.stroke_editor = None;
return None;
}
let fresh =
app.shape_stroke_preview.as_ref().is_some_and(|p| p.doc == st.doc.id && p.revision == st.revision && p.target == target && p.options == editor.draft);
if !fresh {
let shown = photocraft_engine::vector_cmds::preview_shape_stroke(&st.doc, target, &editor.draft.params()).ok()?;
// Hash all cache inputs so returning to an earlier draft is deterministic, and a
// reopened editor cannot alias a different document revision's GPU output.
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
(st.doc.id, st.revision, target, editor.key.as_str(), editor.draft.params().to_string()).hash(&mut hash);
let key = (1u64 << 60) | (hash.finish() & ((1u64 << 60) - 1));
app.shape_stroke_preview =
Some(ShapeStrokePreview { doc: st.doc.id, revision: st.revision, target, options: editor.draft.clone(), shown: std::sync::Arc::new(shown), key });
}
app.shape_stroke_preview.as_ref().map(|p| (p.shown.clone(), p.key))
}
/// Staged editor: outside click/Escape cancels; Apply returns one complete command patch.
pub fn button(
ui: &mut egui::Ui,
key: &str,
current: &StrokeOptions,
editor: &mut Option<StrokeEditor>,
closed: bool,
corners: bool,
target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
) -> Option<StrokeOptions> {
let response = ui.add_sized(vec2(96.0, 22.0), egui::Button::new(tl!(current.label()))).on_hover_text(tl!("Stroke Options"));
let c = response.rect.right_center() - vec2(10.0, 0.0);
ui.painter().add(egui::Shape::line(vec![c - vec2(3.0, 1.5), c + vec2(0.0, 1.5), c + vec2(3.0, -1.5)], Stroke::new(1.0, Tokens::get(ui.ctx()).text_dim)));
if response.clicked() {
let draft = if current.valid() { current.clone() } else { StrokeOptions::default() };
*editor = Some(StrokeEditor { key: key.into(), draft, original: current.clone(), target });
#[derive(Clone, Default)]
struct CanvasDismissal {
held: bool,
consumed_frame: Option<u64>,
}
/// Pressing the canvas dismisses this popup and consumes that entire gesture. In particular,
/// its release must not open a shape-size dialog or commit a shape dragged behind the popup.
pub(crate) fn dismiss_from_canvas(app: &mut crate::PhotocraftApp, response: &egui::Response) -> bool {
let ctx = &response.ctx;
let frame = ctx.cumulative_frame_nr();
let was_open = app.ui.stroke_editor.is_some() || ctx.data(|d| d.get_temp::<u64>(open_frame_id())) == Some(frame);
let (pressed, down) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.any_down()));
let dismiss = was_open && pressed && response.contains_pointer();
if dismiss && let Some(e) = app.ui.stroke_editor.take() {
editor::close_children(ctx, &e.key);
ctx.request_repaint();
}
let mut result = None;
let mut dismissed = false;
egui::Popup::from_toggle_button_response(&response).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let Some(e) = editor.as_mut().filter(|e| e.key == key && e.original == *current && e.target == target) else {
ui.close();
return;
};
let s = &mut e.draft;
ui.set_width(260.0);
ui.label(egui::RichText::new(tl!("Stroke Options")).strong());
let body_height = (ui.ctx().content_rect().height() - 150.0).clamp(120.0, 520.0);
// Keep the footer stable when presets add/remove dash fields or a scroll bar appears.
egui::ScrollArea::vertical().max_height(body_height).min_scrolled_height(body_height).auto_shrink([false, false]).show(ui, |ui| {
for name in ["Solid", "Dashed", "Dotted"] {
let mut sample = s.clone();
sample.preset(name);
ui.horizontal(|ui| {
if ui.selectable_label(s.label() == name, tl!(name)).clicked() {
s.preset(name);
}
preview(ui, &sample, 154.0, false);
});
}
ui.separator();
ui.add_enabled_ui(closed, |ui| {
choice(ui, "Alignment", &mut s.align, &[(StrokeAlign::Inside, "Inside"), (StrokeAlign::Center, "Center"), (StrokeAlign::Outside, "Outside")]);
});
if !closed {
ui.label(egui::RichText::new(tl!("Open paths use centered strokes")).small());
}
ui.add_enabled_ui(!closed || !s.dashes.is_empty(), |ui| {
choice(ui, "Caps", &mut s.cap, &[(LineCap::Butt, "Butt / Flat"), (LineCap::Round, "Round"), (LineCap::Square, "Square")]);
});
ui.add_enabled_ui(corners, |ui| {
choice(ui, "Corners", &mut s.join, &[(LineJoin::Miter, "Miter"), (LineJoin::Round, "Round"), (LineJoin::Bevel, "Bevel")]);
});
ui.add_enabled_ui(corners && s.join == LineJoin::Miter, |ui| {
number(ui, "Miter limit", &mut s.miter_limit, 1.0..=500.0, "×");
});
let mut opacity = s.opacity * 100.0;
number(ui, "Opacity", &mut opacity, 0.0..=100.0, "%");
s.opacity = opacity / 100.0;
ui.separator();
let mut dashed = !s.dashes.is_empty();
if ui.checkbox(&mut dashed, tl!("Dashed line")).changed() {
s.preset(if dashed { "Dashed" } else { "Solid" });
}
if dashed {
ui.label(egui::RichText::new(tl!("Dash / gap lengths × stroke width")).small());
for (i, pair) in s.dashes.chunks_mut(2).enumerate() {
ui.push_id(i, |ui| {
ui.horizontal(|ui| {
for (j, value) in pair.iter_mut().enumerate() {
ui.label(tl!(if j == 0 { "Dash" } else { "Gap" }));
crate::widgets::value_field(ui, value, 0.0..=10000.0, "×", 68.0);
}
});
});
}
ui.horizontal(|ui| {
if ui.add_enabled(s.dashes.len() < 32, egui::Button::new(tl!("Add pair"))).clicked() {
if s.dashes.len() % 2 == 1 {
s.dashes.push(2.0);
} else {
s.dashes.extend([4.0, 2.0]);
}
}
if ui.add_enabled(s.dashes.len() > 2, egui::Button::new(tl!("Remove pair"))).clicked() {
s.dashes.truncate(s.dashes.len().saturating_sub(2));
}
});
number(ui, "Dash offset", &mut s.dash_offset, -1_000_000.0..=1_000_000.0, "×");
}
preview(ui, s, 260.0, true);
});
let valid = s.valid();
if !valid {
ui.colored_label(Tokens::get(ui.ctx()).text_dim, tl!("Use finite lengths and a positive pattern total"));
ctx.data_mut(|d| {
let state = d.get_temp_mut_or_default::<CanvasDismissal>(egui::Id::new("shape-stroke-canvas-dismissal"));
state.held |= dismiss;
let consumed = state.held || state.consumed_frame == Some(frame);
if consumed {
state.held = down;
state.consumed_frame = Some(frame);
}
ui.horizontal(|ui| {
if ui.add_enabled(valid, egui::Button::new(tl!("Apply"))).clicked() {
result = Some(s.clone());
dismissed = true;
ui.close();
}
if ui.button(tl!("Cancel")).clicked() {
dismissed = true;
ui.close();
}
});
});
if dismissed {
egui::Popup::close_id(ui.ctx(), egui::Popup::default_response_id(&response));
*editor = None;
consumed
})
}
/// Drop a popup whose source disappeared or changed outside this editor (including Undo).
pub(crate) fn sync_editor(app: &mut crate::PhotocraftApp, ctx: &egui::Context) {
let Some(e) = &app.ui.stroke_editor else { return };
let valid = if let Some((doc, layer)) = e.target {
app.session.active().is_some_and(|st| {
let locks = st.doc.effective_locks(layer);
st.doc.id == doc
&& st.active_layer == Some(layer)
&& !locks.all
&& !locks.pixels
&& st.doc.layer(layer).is_some_and(|l| match &l.content {
photocraft_doc::LayerContent::Shape(shape) => shape.stroke.as_ref().map(StrokeOptions::from).unwrap_or_default() == e.original,
_ => false,
})
})
} else {
let tool = app.ui.tool;
((e.key == "tool-stroke" && crate::vector_ui::is_shape_tool(tool))
|| (e.key == "pen-stroke" && tool == crate::state::Tool::Pen && app.ui.tool_options.vector_mode == "shape"))
&& app.ui.tool_options.shape_stroke == e.original
};
if !valid {
editor::close_children(ctx, &e.key);
app.ui.stroke_editor = None;
} else {
// The options row can process a batched press+release before the canvas is drawn.
// Keep the opening state for this frame so that click is still dismissal-only.
let frame = ctx.cumulative_frame_nr();
ctx.data_mut(|d| d.insert_temp(open_frame_id(), frame));
}
if editor.as_ref().is_some_and(|e| e.key == key) && !egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&response)) {
*editor = None;
}
result
}
fn choice<T: Copy + PartialEq>(ui: &mut egui::Ui, label: &str, value: &mut T, choices: &[(T, &str)]) {
ui.horizontal(|ui| {
ui.label(tl!(label));
let (press, close) = crate::press_menu::PressCombo::before(ui, label);
let combo = egui::ComboBox::from_id_salt(label)
.width(140.0)
.selected_text(choices.iter().find(|(v, _)| v == value).map_or("", |(_, n)| tl!(*n)))
.close_behavior(close)
.show_ui(ui, |ui| {
for (v, n) in choices {
let item = ui.selectable_label(*value == *v, tl!(*n));
if press.chosen(ui, &item) {
*value = *v;
}
}
});
press.after(&combo.response);
});
}
fn number(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, unit: &str) {
ui.horizontal(|ui| {
ui.label(tl!(label));
crate::widgets::value_field(ui, value, range, unit, 78.0);
});
}
#[derive(Clone)]
struct SampleCache {
options: StrokeOptions,
width: usize,
closed: bool,
color: egui::Color32,
texture: egui::TextureHandle,
}
/// A tiny cached raster sample uses the actual vector renderer, including AA, opacity and joins.
fn preview(ui: &mut egui::Ui, options: &StrokeOptions, width: f32, corners: bool) {
let t = Tokens::get(ui.ctx());
let height = if corners { 68.0 } else { 22.0 };
let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
ui.painter().rect_filled(rect, t.radius_sm, t.field);
let id = response.id.with("stroke-preview");
let w = width as usize;
let mut sample = options.clone();
if !corners {
sample.align = StrokeAlign::Center;
}
let cached = ui.data(|d| d.get_temp::<SampleCache>(id));
let cache = cached.filter(|c| c.options == sample && c.width == w && c.closed == corners && c.color == t.text).unwrap_or_else(|| {
let path = if corners {
photocraft_vector::shapes::rounded_rect(16.0, 16.0, width as f64 - 32.0, 36.0, [0.0; 4])
} else {
let mut sp = photocraft_doc::Subpath::polygon(&[(12.0, 11.0), (width as f64 - 12.0, 11.0)]);
sp.closed = false;
photocraft_doc::Path::new(vec![sp])
};
let c = t.text;
let shape =
photocraft_doc::ShapeLayer {
path,
stroke: Some(sample.stroke(
3.0,
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0, 1.0)),
)),
..Default::default()
};
let r = photocraft_geom::Rect::from_size(photocraft_geom::Size::new(w as u32, height as u32));
let pixels = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, r).render_rgba(r);
let bytes: Vec<u8> = pixels.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
let texture =
ui.ctx().load_texture("stroke-preview", egui::ColorImage::from_rgba_unmultiplied([w, height as usize], &bytes), egui::TextureOptions::LINEAR);
SampleCache { options: sample, width: w, closed: corners, color: t.text, texture }
});
ui.painter().image(cache.texture.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
ui.data_mut(|d| d.insert_temp(id, cache));
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::{Harness, kittest::Queryable};
mod tests;
struct State {
current: StrokeOptions,
editor: Option<StrokeEditor>,
applied: usize,
}
fn harness() -> Harness<'static, State> {
Harness::builder().with_size(vec2(500.0, 800.0)).build_ui_state(
|ui, s: &mut State| {
if let Some(next) = button(ui, "test", &s.current, &mut s.editor, true, true, None) {
s.current = next;
s.applied += 1;
}
},
State { current: Default::default(), editor: None, applied: 0 },
)
}
#[test]
fn preset_preview_is_staged_cancelled_or_applied_once() {
let mut h = harness();
h.run();
h.get_by_label("Solid").click();
h.run();
h.get_by_label("Dotted").click();
h.run_steps(3);
assert!(h.state().current.dashes.is_empty());
assert_eq!(h.state().editor.as_ref().unwrap().draft.dashes, [0.0, 2.0]);
let cancel_rect = h.get_by_label("Cancel").rect();
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.state().editor.is_none(), "Cancel at {cancel_rect:?}; remaining: {:?}", h.query_all_by_label("Cancel").map(|n| n.rect()).collect::<Vec<_>>());
assert_eq!(h.state().applied, 0);
h.get_by_label("Solid").click();
h.run();
h.get_by_label("Dotted").click();
h.run_steps(3);
h.get_by_label("Apply").click();
h.run();
assert_eq!(h.state().current.cap, LineCap::Round);
assert_eq!(h.state().current.dashes, [0.0, 2.0]);
assert_eq!(h.state().applied, 1);
}
#[test]
fn options_serde_defaults_keep_old_tool_settings_and_full_stroke() {
assert_eq!(serde_json::from_value::<StrokeOptions>(json!({})).unwrap(), StrokeOptions::default());
let mut style = StrokeOptions::default();
style.preset("Dotted");
style.dashes = vec![0.0, 2.0, 4.0, 1.0];
style.dash_offset = -0.5;
let stroke = style.stroke(5.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK));
assert_eq!(StrokeOptions::from(&stroke), style);
assert_eq!(serde_json::from_value::<StrokeOptions>(serde_json::to_value(&style).unwrap()).unwrap(), style);
}
#[test]
fn canvas_preview_is_cached_and_matches_commit_at_all_depths() {
for depth in [8, 16, 32] {
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":80,"height":60,"depth":depth})).unwrap();
let id = app.run("shape.create", json!({"rect":[15,15,45,30],"stroke":{"width":5,"color":"#ff0000"}})).unwrap()["layer"].as_u64().unwrap();
let target = photocraft_doc::LayerId(id);
let original = StrokeOptions::default();
let mut draft = original.clone();
draft.preset("Dotted");
draft.join = LineJoin::Round;
draft.opacity = 0.5;
let committed = app.session.active().unwrap().doc.clone();
let count = app.session.active().unwrap().history.past_len();
app.ui.stroke_editor = Some(StrokeEditor { key: "test".into(), original, draft: draft.clone(), target: Some((committed.id, target)) });
let idx = app.session.active_index().unwrap();
let first = display_doc(&mut app, idx).unwrap();
let next = display_doc(&mut app, idx).unwrap();
assert!(std::sync::Arc::ptr_eq(&first.0, &next.0));
assert_eq!(first.1, next.1);
assert_eq!(app.session.active().unwrap().doc, committed);
assert_eq!(app.session.active().unwrap().history.past_len(), count);
app.ui.stroke_editor = None;
assert!(display_doc(&mut app, idx).is_none());
app.run("shape.edit", json!({"layer":id,"stroke":draft.params()})).unwrap();
assert_eq!(app.session.active().unwrap().doc, first.0);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc, committed);
app.ui.stroke_editor = Some(StrokeEditor { key: "test".into(), original: StrokeOptions::default(), draft, target: Some((committed.id, target)) });
app.run("file.new", json!({"width":80,"height":60,"depth":depth})).unwrap();
let other = app.session.active_index().unwrap();
assert!(display_doc(&mut app, other).is_none());
assert!(app.ui.stroke_editor.is_none());
}
}
}
#[cfg(test)]
mod shortcut_tests;
@@ -0,0 +1,354 @@
//! A live, locally owned popup: egui's memory popup slot belongs to its child ComboBoxes.
use egui::{Rect, Sense, Stroke, pos2, vec2};
use photocraft_doc::{LineCap, LineJoin, StrokeAlign};
use serde::{Deserialize, Serialize};
use super::StrokeOptions;
use crate::theme::{self, Tokens};
/// Inspection state. Valid edits are applied immediately; only invalid numeric input is pending.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StrokeEditor {
pub key: String,
pub draft: StrokeOptions,
pub original: StrokeOptions,
pub target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
}
pub struct StrokeChange {
pub options: StrokeOptions,
pub coalesce: String,
}
#[derive(Default)]
struct Children {
shown: bool,
clicked: bool,
ids: Vec<egui::Id>,
}
pub(super) fn close_children(ctx: &egui::Context, key: &str) {
let ids = ctx.data_mut(|d| d.remove_temp::<Vec<egui::Id>>(egui::Id::new(("stroke-children", key)))).unwrap_or_default();
for id in ids {
egui::Popup::close_id(ctx, id);
}
}
/// A line sample and the entire style row share one accessible, keyboard-operable hit target.
fn style_button(ui: &mut egui::Ui, options: &StrokeOptions, label: &str, selected: bool, width: f32, opener: bool, enabled: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let font = egui::TextStyle::Button.resolve(ui.style());
let color = if selected && t.bevel { t.accent_text } else { t.text };
let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), font, color);
let height = (galley.size().y + if opener { 8.0 } else { 14.0 }).max(if opener { 24.0 } else { 32.0 });
let (rect, response) = ui.allocate_exact_size(vec2(width.max(galley.size().x + 92.0), height), Sense::click());
response.widget_info(|| {
if opener {
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Stroke Options"))
} else {
egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), selected, tl!(label))
}
});
let active = selected || response.has_focus();
let fill = if selected {
t.accent_soft
} else if response.hovered() {
t.hover
} else {
t.field
};
ui.painter().rect_filled(rect, t.radius_sm, fill);
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, if active { t.accent_border } else { t.field_border }), egui::StrokeKind::Inside);
ui.painter().galley(pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0), galley.clone(), color);
let sample = Rect::from_min_max(
pos2(rect.left() + galley.size().x + 18.0, rect.top() + 3.0),
pos2(rect.right() - if opener { 20.0 } else { 8.0 }, rect.bottom() - 3.0),
);
if enabled {
paint_sample(ui, sample, response.id.with("sample"), options, false, color);
} else {
ui.painter().line_segment([sample.left_center(), sample.right_center()], Stroke::new(1.0, t.text_faint));
ui.painter().line_segment([sample.center() + vec2(-4.0, 6.0), sample.center() + vec2(4.0, -6.0)], Stroke::new(1.5, t.danger));
}
if opener {
crate::icons::paint(ui, Rect::from_center_size(rect.right_center() - vec2(10.0, 0.0), vec2(12.0, 12.0)), "chevron-down", 12.0, t.text_dim);
}
response
}
/// Emit only deliberate, valid changes. No confirmation footer or document preview snapshot.
#[allow(clippy::too_many_arguments)]
pub fn button(
ui: &mut egui::Ui,
key: &str,
current: &StrokeOptions,
editor: &mut Option<StrokeEditor>,
closed: bool,
corners: bool,
target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
stroke_enabled: bool,
) -> Option<StrokeChange> {
let label = if stroke_enabled { current.label() } else { "No Color" };
let response = style_button(ui, current, label, false, 138.0, true, stroke_enabled).on_hover_text(tl!("Stroke Options"));
let own = editor.as_ref().is_some_and(|e| e.key == key);
if response.clicked() {
if let Some(e) = editor.as_ref() {
close_children(ui.ctx(), &e.key);
}
if own {
*editor = None;
} else {
let draft = if current.valid() { current.clone() } else { StrokeOptions::default() };
*editor = Some(StrokeEditor { key: key.into(), draft, original: current.clone(), target });
}
}
if editor.as_ref().is_some_and(|e| e.key == key && (e.original != *current || e.target != target)) || !ui.is_enabled() && own {
close_children(ui.ctx(), key);
*editor = None;
}
let e = editor.as_mut().filter(|e| e.key == key)?;
super::mark_rendered(ui.ctx(), key);
let before = e.draft.clone();
let mut selected_preset = false;
let mut children = Children::default();
let gesture_id = response.id.with("gesture");
let pass = ui.ctx().cumulative_pass_nr();
// Track presses even when a drag has not changed a value yet, like the mask controls.
let pressed = ui.input(|i| i.pointer.any_pressed());
let gesture = ui.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(gesture_id);
if pressed && g.1 != pass {
g.0 = g.0.wrapping_add(1);
g.1 = pass;
}
g.0
});
// A memory-backed parent is replaced as soon as a ComboBox opens. Explicit ownership
// keeps the parent alive; dismissal below considers both parent and child hit regions.
let popup = egui::Popup::from_response(&response).open(true).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(|ui| {
ui.spacing_mut().item_spacing = vec2(8.0, theme::ROW_GAP);
let width = 272.0_f32.min((ui.ctx().content_rect().width() - 24.0).max(160.0));
ui.set_width(width);
let height = (ui.ctx().content_rect().height() - 40.0).max(80.0);
// An Area remembers its previous size. Let a newly selected dashed style grow the
// body before the ScrollArea measures it, then shrink back to the actual content.
ui.set_max_height(height);
egui::ScrollArea::vertical().max_height(height).auto_shrink([false, true]).show(ui, |ui| {
ui.label(egui::RichText::new(tl!("Stroke Options")).strong());
let s = &mut e.draft;
for name in ["Solid", "Dashed", "Dotted"] {
let mut sample = s.clone();
sample.preset(name);
// Presets illustrate their pattern even when current opacity is zero.
sample.opacity = 1.0;
if style_button(ui, &sample, name, s.label() == name, width, false, true).clicked() {
s.preset(name);
selected_preset = true;
}
}
ui.add_space(theme::ROW_GAP);
ui.separator();
egui::Grid::new("stroke-geometry").num_columns(2).spacing(vec2(12.0, theme::ROW_GAP)).show(ui, |ui| {
ui.add_enabled_ui(closed, |ui| {
ui.label(tl!("Alignment"));
});
ui.add_enabled_ui(closed, |ui| {
choice(
ui,
"Alignment",
&mut s.align,
&[(StrokeAlign::Inside, "Inside"), (StrokeAlign::Center, "Center"), (StrokeAlign::Outside, "Outside")],
&mut children,
)
});
ui.end_row();
ui.add_enabled_ui(!closed || !s.dashes.is_empty(), |ui| {
ui.label(tl!("Caps"));
});
ui.add_enabled_ui(!closed || !s.dashes.is_empty(), |ui| {
choice(ui, "Caps", &mut s.cap, &[(LineCap::Butt, "Butt / Flat"), (LineCap::Round, "Round"), (LineCap::Square, "Square")], &mut children)
});
ui.end_row();
ui.add_enabled_ui(corners, |ui| {
ui.label(tl!("Corners"));
});
ui.add_enabled_ui(corners, |ui| {
choice(ui, "Corners", &mut s.join, &[(LineJoin::Miter, "Miter"), (LineJoin::Round, "Round"), (LineJoin::Bevel, "Bevel")], &mut children)
});
ui.end_row();
ui.add_enabled_ui(corners && s.join == LineJoin::Miter, |ui| {
ui.label(tl!("Miter limit"));
});
ui.add_enabled_ui(corners && s.join == LineJoin::Miter, |ui| number(ui, "Miter limit", &mut s.miter_limit, 1.0..=500.0, "×"));
ui.end_row();
ui.label(tl!("Opacity"));
let mut opacity = s.opacity * 100.0;
if number(ui, "Opacity", &mut opacity, 0.0..=100.0, "%") {
s.opacity = opacity / 100.0;
}
ui.end_row();
});
if !closed {
ui.label(egui::RichText::new(tl!("Open paths use centered strokes")).small());
}
ui.separator();
let mut dashed = !s.dashes.is_empty();
if crate::widgets::checkbox(ui, &mut dashed, "Dashed line").changed() {
s.preset(if dashed { "Dashed" } else { "Solid" });
}
if dashed {
ui.label(egui::RichText::new(tl!("Dash / gap lengths × stroke width")).small());
for (i, pair) in s.dashes.chunks_mut(2).enumerate() {
ui.push_id(i, |ui| {
ui.horizontal(|ui| {
for (j, value) in pair.iter_mut().enumerate() {
let name = if j == 0 { "Dash" } else { "Gap" };
ui.label(tl!(name));
number(ui, name, value, 0.0..=10000.0, "×");
}
});
});
}
ui.horizontal(|ui| {
if ui.add_enabled(s.dashes.len() < 32, egui::Button::new(tl!("Add pair"))).clicked() {
if s.dashes.len() % 2 == 1 {
s.dashes.push(2.0);
} else {
s.dashes.extend([4.0, 2.0]);
}
}
if ui.add_enabled(s.dashes.len() > 2, egui::Button::new(tl!("Remove pair"))).clicked() {
s.dashes.truncate(s.dashes.len().saturating_sub(2));
}
});
ui.horizontal(|ui| {
ui.label(tl!("Dash offset"));
number(ui, "Dash offset", &mut s.dash_offset, -1_000_000.0..=1_000_000.0, "×");
});
}
let t = Tokens::get(ui.ctx());
let (rect, sample_response) = ui.allocate_exact_size(vec2(width, 58.0), Sense::hover());
ui.painter().rect_filled(rect, t.radius_sm, t.field);
paint_sample(ui, rect, sample_response.id, s, true, t.text);
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
if !s.valid() {
ui.colored_label(t.danger, tl!("Use finite lengths and a positive pattern total"));
}
});
});
let changed = e.draft != before || selected_preset && !stroke_enabled;
let result = if changed && e.draft.valid() {
e.original = e.draft.clone();
if !ui.input(|i| i.pointer.any_down()) {
ui.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(gesture_id).0 = gesture.wrapping_add(1));
}
Some(StrokeChange { options: e.draft.clone(), coalesce: format!("shape-stroke:{key}:{}:{gesture}", gesture_id.value()) })
} else {
None
};
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
let outside = popup.is_some_and(|p| p.response.clicked_elsewhere()) && !children.clicked && !response.clicked();
ui.data_mut(|d| d.insert_temp(egui::Id::new(("stroke-children", key)), children.ids));
if outside || escape && !children.shown {
close_children(ui.ctx(), key);
*editor = None;
if escape {
response.request_focus();
}
}
result
}
fn choice<T: Copy + PartialEq>(ui: &mut egui::Ui, label: &str, value: &mut T, choices: &[(T, &str)], children: &mut Children) {
let parent_layer = ui.layer_id();
let (press, close) = crate::press_menu::PressCombo::before(ui, label);
let response = egui::ComboBox::from_id_salt(label)
.width(152.0)
.selected_text(choices.iter().find(|(v, _)| v == value).map_or("", |(_, n)| tl!(*n)))
.close_behavior(close)
.show_ui(ui, |ui| {
// Restored Area order can place a previously used child behind its parent.
// Both request the top on opening, so explicitly preserve their relationship.
ui.ctx().set_sublayer(parent_layer, ui.layer_id());
for (v, name) in choices {
let item = ui.selectable_label(*value == *v, tl!(*name));
if press.chosen(ui, &item) {
*value = *v;
}
}
});
press.after(&response.response);
response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, ui.is_enabled(), tl!(label)));
let id = egui::Popup::default_response_id(&response.response);
children.ids.push(id);
if response.inner.is_some() {
children.shown = true;
children.clicked |= ui.ctx().read_response(id).is_some_and(|r| !r.clicked_elsewhere());
}
}
fn number(ui: &mut egui::Ui, name: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, unit: &str) -> bool {
let response = crate::widgets::value_field(ui, value, range, unit, 74.0);
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), tl!(name)));
response.changed()
}
#[derive(Clone)]
struct SampleCache {
options: StrokeOptions,
size: egui::Vec2,
scale: f32,
corners: bool,
color: egui::Color32,
texture: egui::TextureHandle,
}
/// Bounded, DPI-aware samples of the actual vector renderer, cached independently per control.
fn paint_sample(ui: &egui::Ui, rect: Rect, id: egui::Id, options: &StrokeOptions, corners: bool, color: egui::Color32) {
if !rect.is_finite() || rect.width() < 2.0 || rect.height() < 2.0 {
return;
}
let size = rect.size();
let scale = ui.ctx().pixels_per_point().clamp(0.5, 4.0).min(1024.0 / size.x.max(size.y));
let mut sample = options.clone();
if !corners {
sample.align = StrokeAlign::Center;
}
let cached = ui.data(|d| d.get_temp::<SampleCache>(id));
let cache = cached.filter(|c| c.options == sample && c.size == size && c.scale == scale && c.corners == corners && c.color == color).unwrap_or_else(|| {
let width = (size.x * scale).ceil().max(1.0) as u32;
let height = (size.y * scale).ceil().max(1.0) as u32;
let path = if corners {
photocraft_vector::shapes::rounded_rect(12.0, 12.0, f64::from(size.x - 24.0), f64::from(size.y - 24.0), [0.0; 4])
} else {
let mut sp = photocraft_doc::Subpath::polygon(&[(4.0, f64::from(size.y / 2.0)), (f64::from(size.x - 4.0), f64::from(size.y / 2.0))]);
sp.closed = false;
photocraft_doc::Path::new(vec![sp])
};
let path = path.transform(&photocraft_geom::Affine { m: [f64::from(scale), 0.0, 0.0, f64::from(scale), 0.0, 0.0] });
let shape = photocraft_doc::ShapeLayer {
path,
stroke: Some(sample.stroke(
2.5 * scale,
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(
f32::from(color.r()) / 255.0,
f32::from(color.g()) / 255.0,
f32::from(color.b()) / 255.0,
1.0,
)),
)),
..Default::default()
};
let bounds = photocraft_geom::Rect::from_size(photocraft_geom::Size::new(width, height));
let pixels = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, bounds).render_rgba(bounds);
let bytes: Vec<u8> = pixels.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
let texture = ui.ctx().load_texture(
"stroke-sample",
egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &bytes),
egui::TextureOptions::LINEAR,
);
SampleCache { options: sample, size, scale, corners, color, texture }
});
ui.painter().image(cache.texture.id(), rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), egui::Color32::WHITE);
ui.data_mut(|d| d.insert_temp(id, cache));
}
@@ -0,0 +1,126 @@
//! Stroke popups own plain keys before the application's tool shortcuts run.
use egui::{Key, Modifiers, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use crate::{PhotocraftApp, state::Tool};
fn app(tool: Tool) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
app.ui.tool = tool;
app.ui.tool_options.vector_mode = "shape".into();
app.ui.tool_options.stroke_width = 3.0;
app.sync_views();
app
}
fn harness(app: PhotocraftApp, properties: bool) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
let _language = crate::i18n::language_scope(crate::i18n::Lang::EN);
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
// Match PhotocraftApp::update: source validation and application shortcuts precede
// drawing the controls, so their popup handlers have not seen this frame's keys.
super::sync_editor(app, ui.ctx());
crate::shortcuts::handle(app, ui.ctx());
if properties {
let layer = app.session.active().unwrap().active_layer.unwrap();
ui.set_max_width(320.0);
crate::vector_ui::shape_properties(app, ui, layer);
} else {
let tool = app.ui.tool;
ui.horizontal(|ui| crate::vector_ui::options_bar(app, ui, tool));
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
h.run_steps(4);
h
}
fn open(h: &mut Harness<'_, PhotocraftApp>) {
h.get_by_role_and_label(egui::accesskit::Role::Button, "Stroke Options").click();
h.run_steps(3);
assert!(h.state().ui.stroke_editor.is_some());
}
#[test]
fn escape_dismisses_stroke_child_then_parent_without_deselecting_the_document() {
let mut app = app(Tool::Rectangle);
app.run("select.rect", json!({"x": 8, "y": 8, "width": 24, "height": 20})).unwrap();
let original = app.session.active().unwrap().doc.clone();
let history = app.session.active().unwrap().history.past_len();
let mut h = harness(app, false);
open(&mut h);
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Alignment").click();
h.run_steps(3);
assert!(egui::Popup::is_any_open(&h.ctx));
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx));
assert!(h.state().ui.stroke_editor.is_some(), "the first Escape closes only the child");
assert_eq!(h.state().session.active().unwrap().doc, original);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().ui.stroke_editor.is_none(), "the second Escape closes the parent");
assert_eq!(h.state().session.active().unwrap().doc, original);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "normal Escape resumes after the popup closes");
}
#[test]
fn enter_selects_a_stroke_style_and_escape_closes_its_popups_without_committing_pen() {
let mut app = app(Tool::Pen);
app.ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[12.0, 12.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() });
let pen = app.ui.pen.clone();
let original = app.session.active().unwrap().doc.clone();
let history = app.session.active().unwrap().history.past_len();
let mut h = harness(app, false);
open(&mut h);
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Dotted").focus();
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(h.state().ui.tool_options.shape_stroke.dashes, [0.0, 2.0]);
assert_eq!(h.state().ui.pen, pen, "Enter belongs to the focused style target");
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Caps").click();
h.run_steps(3);
assert!(egui::Popup::is_any_open(&h.ctx));
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx));
assert!(h.state().ui.stroke_editor.is_some());
assert_eq!(h.state().ui.pen, pen);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().ui.stroke_editor.is_none());
assert_eq!(h.state().ui.pen, pen, "dismissing Stroke Options leaves the Pen gesture intact");
assert_eq!(h.state().session.active().unwrap().doc, original);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
}
#[test]
fn undo_shortcut_remains_available_and_invalidates_the_open_stroke_editor() {
let mut app = app(Tool::Rectangle);
app.run("shape.create", json!({"rect": [10, 10, 40, 30], "stroke": {"width": 4, "color": "#dd2244"}})).unwrap();
let original = app.session.active().unwrap().doc.clone();
let history = app.session.active().unwrap().history.past_len();
let mut h = harness(app, true);
open(&mut h);
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Dotted").click();
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1);
assert!(h.state().ui.stroke_editor.is_some());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc, original);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
assert!(h.state().ui.stroke_editor.is_none(), "Undo invalidates the editor's last applied style");
assert!(!egui::Popup::is_any_open(&h.ctx));
}
+630
View File
@@ -0,0 +1,630 @@
use super::*;
use egui::accesskit::Role;
use egui::{Key, Modifiers, PointerButton, Pos2, Rect, Vec2, pos2};
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
struct State {
current: StrokeOptions,
editor: Option<StrokeEditor>,
changes: Vec<String>,
stroke_enabled: bool,
closed: bool,
corners: bool,
popup: Option<Rect>,
}
impl Default for State {
fn default() -> Self {
Self { current: StrokeOptions::default(), editor: None, changes: Vec::new(), stroke_enabled: true, closed: true, corners: true, popup: None }
}
}
fn sized_harness(size: Vec2, scale: f32, theme: crate::theme::ThemeKind) -> Harness<'static, State> {
let mut h = Harness::builder().with_size(size).with_pixels_per_point(scale).build_ui_state(
|ui, s: &mut State| {
let _language = crate::i18n::language_scope(crate::i18n::Lang::EN);
let popup_id = ui.next_auto_id().with("popup");
if let Some(change) = button(ui, "test", &s.current, &mut s.editor, s.closed, s.corners, None, s.stroke_enabled) {
s.current = change.options;
s.stroke_enabled = true;
s.changes.push(change.coalesce);
}
s.popup = s.editor.as_ref().and_then(|_| ui.ctx().read_response(popup_id).map(|r| r.rect));
},
State::default(),
);
crate::PhotocraftApp::setup_context(&h.ctx, theme);
h.run_steps(3);
h
}
fn harness() -> Harness<'static, State> {
sized_harness(vec2(640.0, 800.0), 1.0, crate::theme::ThemeKind::ProMedium)
}
fn click_at<S>(h: &mut Harness<'_, S>, pos: Pos2) {
h.hover_at(pos);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(3);
}
fn open(h: &mut Harness<'_, State>) {
let rect = h.get_by_role_and_label(Role::Button, "Stroke Options").rect();
click_at(h, rect.center());
assert!(h.state().editor.is_some());
assert!(h.query_by_role_and_label(Role::RadioButton, "Dotted").is_some());
}
fn combo(h: &mut Harness<'_, State>, label: &str) {
let rect = h.get_by_role_and_label(Role::ComboBox, label).rect();
click_at(h, rect.center());
}
#[test]
fn line_samples_and_row_edges_apply_immediately_and_survive_dismissal() {
let mut h = harness();
open(&mut h);
assert!(h.query_by_label("Apply").is_none());
assert!(h.query_by_label("Cancel").is_none());
// The right half is painted line sample, well away from the text.
let dotted = h.get_by_role_and_label(Role::RadioButton, "Dotted").rect();
click_at(&mut h, pos2(dotted.right() - 16.0, dotted.center().y));
assert_eq!(h.state().current.dashes, [0.0, 2.0]);
assert_eq!(h.state().current.cap, LineCap::Round);
assert_eq!(h.state().changes.len(), 1);
assert!(h.state().editor.is_some(), "a direct edit keeps the editor open");
assert_eq!(h.get_by_role_and_label(Role::RadioButton, "Dotted").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
// The empty padding at the row's left edge is part of the same target.
let dashed = h.get_by_role_and_label(Role::RadioButton, "Dashed").rect();
click_at(&mut h, pos2(dashed.left() + 3.0, dashed.center().y));
assert_eq!(h.state().current.dashes, [4.0, 2.0]);
assert_eq!(h.state().changes.len(), 2);
assert_ne!(h.state().changes[0], h.state().changes[1], "separate clicks are separate undo gestures");
click_at(&mut h, pos2(620.0, 780.0));
assert!(h.state().editor.is_none());
assert_eq!(h.state().current.dashes, [4.0, 2.0], "dismissal must retain the live change");
assert_eq!(h.state().changes.len(), 2);
}
#[test]
fn child_dropdown_open_selection_and_escape_leave_the_parent_alive() {
let mut h = harness();
open(&mut h);
combo(&mut h, "Alignment");
assert!(h.state().editor.is_some(), "opening a ComboBox must not replace the parent popup");
assert!(egui::Popup::is_any_open(&h.ctx));
let inside = h.get_by_label("Inside").rect();
click_at(&mut h, inside.center());
assert_eq!(h.state().current.align, StrokeAlign::Inside);
assert_eq!(h.state().changes.len(), 1);
assert!(h.state().editor.is_some(), "selecting a child item must not dismiss its parent");
assert!(!egui::Popup::is_any_open(&h.ctx));
combo(&mut h, "Alignment");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx));
assert!(h.state().editor.is_some(), "the first Escape belongs to the child");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().editor.is_none(), "the second Escape dismisses the parent");
assert_eq!(h.state().current.align, StrokeAlign::Inside);
assert_eq!(h.state().changes.len(), 1);
assert!(h.get_by_role_and_label(Role::Button, "Stroke Options").is_focused());
}
#[test]
fn reopened_child_stays_above_a_parent_with_restored_area_order() {
let mut h = harness();
open(&mut h);
let alignment = h.get_by_role_and_label(Role::ComboBox, "Alignment").rect().center();
let parent_layer = h.ctx.layer_id_at(alignment).unwrap();
combo(&mut h, "Alignment");
let outside = h.get_by_label("Outside").rect().center();
let child_layer = h.ctx.layer_id_at(outside).unwrap();
assert_ne!(parent_layer, child_layer);
h.key_press(Key::Escape);
h.run_steps(3);
// Reproduce persisted Area order where the parent was brought above its closed child.
h.ctx.move_to_top(parent_layer);
h.run_steps(1);
h.key_press(Key::Escape);
h.run_steps(3);
open(&mut h);
combo(&mut h, "Alignment");
let outside = h.get_by_label("Outside").rect().center();
assert_eq!(h.ctx.layer_id_at(outside), Some(child_layer), "the open child must receive input above its parent");
click_at(&mut h, outside);
assert_eq!(h.state().current.align, StrokeAlign::Outside);
assert!(h.state().editor.is_some());
assert!(!egui::Popup::is_any_open(&h.ctx));
}
#[test]
fn click_outside_parent_and_child_closes_both_without_changing_the_style() {
let mut h = harness();
open(&mut h);
combo(&mut h, "Corners");
assert!(egui::Popup::is_any_open(&h.ctx));
click_at(&mut h, pos2(620.0, 780.0));
assert!(!egui::Popup::is_any_open(&h.ctx));
assert!(h.state().editor.is_none());
assert!(h.state().changes.is_empty());
open(&mut h);
assert!(!egui::Popup::is_any_open(&h.ctx), "reopening does not restore an abandoned child");
}
#[test]
fn closing_the_opener_cleans_up_its_child_in_the_same_frame() {
let mut h = harness();
open(&mut h);
combo(&mut h, "Alignment");
assert!(egui::Popup::is_any_open(&h.ctx));
let pos = h.get_by_role_and_label(Role::Button, "Stroke Options").rect().center();
h.hover_at(pos);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
assert!(h.state().editor.is_none());
assert!(!egui::Popup::is_any_open(&h.ctx), "a hidden parent must not leave a child owning popup state");
assert!(h.state().changes.is_empty());
}
#[test]
fn opening_and_closing_by_button_escape_or_outside_is_a_no_op() {
let mut h = harness();
h.state_mut().current.opacity = 0.613;
h.state_mut().current.dashes = vec![1.25, 2.75, 5.5, 3.0];
h.state_mut().current.dash_offset = -0.625;
let before = h.state().current.clone();
for mode in 0..3 {
open(&mut h);
h.run_steps(8);
assert_eq!(h.state().current, before);
match mode {
0 => {
h.get_by_role_and_label(Role::Button, "Stroke Options").click();
h.run_steps(3);
}
1 => {
h.key_press(Key::Escape);
h.run_steps(3);
}
_ => click_at(&mut h, pos2(620.0, 780.0)),
}
assert!(h.state().editor.is_none());
assert_eq!(h.state().current, before);
assert!(h.state().changes.is_empty(), "merely viewing custom settings emits no edit");
}
}
#[test]
fn stroke_options_remain_available_without_stroke_and_a_preset_enables_it() {
let mut h = harness();
h.state_mut().stroke_enabled = false;
h.run_steps(2);
open(&mut h);
assert!(!h.state().stroke_enabled, "opening the editor must preserve No Color");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!h.state().stroke_enabled);
assert!(h.state().changes.is_empty());
open(&mut h);
let solid = h.get_by_role_and_label(Role::RadioButton, "Solid").rect();
click_at(&mut h, pos2(solid.right() - 12.0, solid.center().y));
assert!(h.state().stroke_enabled, "a deliberate preset choice can enable a zero-width stroke");
assert_eq!(h.state().changes.len(), 1);
assert!(h.state().current.dashes.is_empty());
}
#[test]
fn tab_and_space_operate_the_opener_and_the_complete_style_target() {
let mut h = harness();
h.key_press(Key::Tab);
h.run_steps(3);
assert!(h.get_by_role_and_label(Role::Button, "Stroke Options").is_focused());
h.key_press(Key::Space);
h.run_steps(3);
assert!(h.state().editor.is_some());
// Tab navigation reaches the radio targets without a nested sample button.
let mut reached = false;
for _ in 0..12 {
h.key_press(Key::Tab);
h.run_steps(2);
if h.get_by_role_and_label(Role::RadioButton, "Dotted").is_focused() {
reached = true;
break;
}
}
assert!(reached, "the complete Dotted target participates in keyboard navigation");
h.key_press(Key::Space);
h.run_steps(3);
assert_eq!(h.state().current.dashes, [0.0, 2.0]);
assert_eq!(h.state().changes.len(), 1);
assert!(h.state().editor.is_some());
}
#[test]
fn popup_is_content_sized_and_keeps_presets_inside_small_windows_in_all_themes() {
for theme in crate::theme::ThemeKind::ALL {
for scale in [1.0, 2.0] {
let mut h = sized_harness(vec2(720.0, 900.0), scale, theme);
open(&mut h);
let large = h.state().popup.expect("the parent popup response");
assert!(large.height() < 500.0, "{theme:?} {scale}x: empty space must not fill the 900pt window: {large:?}");
h.set_size(vec2(720.0, 1200.0));
h.run_steps(4);
let taller = h.state().popup.unwrap();
assert!((taller.height() - large.height()).abs() <= 1.0, "{theme:?} {scale}x: unchanged content has a stable natural height");
h.set_size(vec2(340.0, 380.0));
h.run_steps(5);
let small = h.state().popup.unwrap();
let viewport = Rect::from_min_size(Pos2::ZERO, vec2(340.0, 380.0)).expand(1.0);
assert!(viewport.contains_rect(small), "{theme:?} {scale}x: the popup fits the smaller viewport: {small:?}");
for name in ["Solid", "Dashed", "Dotted"] {
let row = h.get_by_role_and_label(Role::RadioButton, name).rect();
assert!(viewport.contains_rect(row), "{theme:?} {scale}x: {name} is clipped: {row:?}");
assert!(row.width() >= 230.0, "the sample and label retain a useful combined target");
}
let dotted = h.get_by_role_and_label(Role::RadioButton, "Dotted").rect();
click_at(&mut h, pos2(dotted.right() - 16.0, dotted.center().y));
assert_eq!(h.state().current.dashes, [0.0, 2.0]);
assert!(viewport.contains_rect(h.state().popup.unwrap()));
}
}
}
fn assert_bottom_sample_visible(h: &Harness<'_, State>) {
let popup = h.state().popup.unwrap();
let (sample, clip) = h
.output()
.shapes
.iter()
.find_map(|shape| {
let egui::Shape::Mesh(mesh) = &shape.shape else {
return None;
};
let rect = mesh.calc_bounds();
(rect.width() > 200.0 && rect.height() > 50.0).then_some((rect, shape.clip_rect))
})
.expect("the large rendered stroke/corner sample at the bottom of the popup");
assert!(popup.expand(0.5).contains_rect(sample), "bottom sample {sample:?} falls outside popup {popup:?}");
assert!(clip.expand(0.5).contains_rect(sample), "bottom sample {sample:?} is clipped by the previous popup size {clip:?}");
}
#[test]
fn adding_dash_controls_grows_the_popup_and_solid_shrinks_it_again() {
for theme in crate::theme::ThemeKind::ALL {
let mut h = sized_harness(vec2(720.0, 1000.0), 2.0, theme);
open(&mut h);
let solid_height = h.state().popup.unwrap().height();
assert_bottom_sample_visible(&h);
let dotted = h.get_by_role_and_label(Role::RadioButton, "Dotted").rect();
click_at(&mut h, pos2(dotted.right() - 16.0, dotted.center().y));
let dashed_popup = h.state().popup.unwrap();
assert!(dashed_popup.height() > solid_height + 60.0, "{theme:?}: dash controls must grow the popup beyond its old Solid size");
for (role, label) in [(Role::Button, "Add pair"), (Role::Button, "Remove pair"), (Role::Slider, "Dash offset")] {
let control = h.get_by_role_and_label(role, label).rect();
assert!(dashed_popup.contains_rect(control), "{theme:?}: {label} is outside the grown popup: {control:?} in {dashed_popup:?}");
}
assert_bottom_sample_visible(&h);
let add = h.get_by_role_and_label(Role::Button, "Add pair").rect();
click_at(&mut h, add.center());
assert_eq!(h.state().current.dashes.len(), 4);
assert!(h.state().popup.unwrap().height() > dashed_popup.height() + 20.0, "{theme:?}: an additional dash pair also grows the content");
assert_bottom_sample_visible(&h);
let remove = h.get_by_role_and_label(Role::Button, "Remove pair").rect();
click_at(&mut h, remove.center());
assert_eq!(h.state().current.dashes.len(), 2);
assert!((h.state().popup.unwrap().height() - dashed_popup.height()).abs() <= 1.0, "{theme:?}: removing a pair shrinks the popup");
let solid = h.get_by_role_and_label(Role::RadioButton, "Solid").rect();
click_at(&mut h, pos2(solid.right() - 16.0, solid.center().y));
assert!(h.state().current.dashes.is_empty());
assert!(h.query_by_label("Add pair").is_none());
assert!((h.state().popup.unwrap().height() - solid_height).abs() <= 1.0, "{theme:?}: Solid restores the natural height without empty space");
assert_bottom_sample_visible(&h);
}
}
#[test]
fn options_serde_defaults_keep_old_tool_settings_and_full_stroke() {
assert_eq!(serde_json::from_value::<StrokeOptions>(json!({})).unwrap(), StrokeOptions::default());
let mut style = StrokeOptions::default();
style.preset("Dotted");
style.dashes = vec![0.0, 2.0, 4.0, 1.0];
style.dash_offset = -0.5;
let stroke = style.stroke(5.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK));
assert_eq!(StrokeOptions::from(&stroke), style);
assert_eq!(serde_json::from_value::<StrokeOptions>(serde_json::to_value(&style).unwrap()).unwrap(), style);
}
#[test]
fn moving_the_editor_between_ui_roots_keeps_history_gestures_distinct() {
let mut h = Harness::builder().with_size(vec2(640.0, 800.0)).build_ui_state(
|ui, (root, state): &mut (u8, State)| {
let _language = crate::i18n::language_scope(crate::i18n::Lang::EN);
ui.push_id(*root, |ui| {
if let Some(change) = button(ui, "same-layer", &state.current, &mut state.editor, true, true, None, true) {
state.current = change.options;
state.changes.push(change.coalesce);
}
});
},
(0, State::default()),
);
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
h.run_steps(3);
for (root, preset) in [(0, "Dashed"), (1, "Dotted")] {
h.state_mut().0 = root;
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Stroke Options").click();
h.run_steps(3);
let row = h.get_by_role_and_label(Role::RadioButton, preset).rect();
click_at(&mut h, pos2(row.right() - 16.0, row.center().y));
h.key_press(Key::Escape);
h.run_steps(3);
}
let changes = &h.state().1.changes;
assert_eq!(changes.len(), 2);
assert_ne!(changes[0], changes[1], "independent dock/floating counters must not merge edits of the same layer");
}
fn properties_harness(mut params: Value, depth: u32) -> Harness<'static, crate::PhotocraftApp> {
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap();
params["fill"] = json!("#d92070");
params["stroke"] = json!({"width": 5, "color": "#21bb66"});
app.run("shape.create", params).unwrap();
let mut h = Harness::builder().with_size(vec2(640.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut crate::PhotocraftApp| {
let _language = crate::i18n::language_scope(crate::i18n::Lang::EN);
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) {
return;
}
if let Some(id) = app.session.active().and_then(|st| st.active_layer) {
crate::vector_ui::shape_properties(app, ui, id);
}
},
app,
);
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
h.run_steps(4);
h
}
fn selected_shape(h: &Harness<'_, crate::PhotocraftApp>) -> photocraft_doc::ShapeLayer {
let st = h.state().session.active().unwrap();
let layer = st.doc.layer(st.active_layer.unwrap()).unwrap();
match &layer.content {
photocraft_doc::LayerContent::Shape(shape) => shape.clone(),
_ => panic!("expected the selected vector shape"),
}
}
#[test]
fn selected_shape_presets_edit_live_preserve_paint_and_undo_at_all_depths() {
for depth in [8, 16, 32] {
for params in [
json!({"kind": "rect", "rect": [12, 12, 45, 30]}),
json!({"kind": "polygon", "sides": 3, "rect": [12, 12, 45, 30]}),
json!({"kind": "ellipse", "rect": [12, 12, 45, 30]}),
json!({"kind": "path", "path": {"subpaths": [{"closed": false, "knots": [[12, 12], [35, 42], [57, 12]]}]}}),
] {
let mut h = properties_harness(params, depth);
let original = selected_shape(&h);
let before = h.state().session.active().unwrap().history.past_len();
let opener = h.get_by_role_and_label(Role::Button, "Stroke Options").rect();
click_at(&mut h, opener.center());
assert_eq!(h.state().session.active().unwrap().history.past_len(), before);
let dotted = h.get_by_role_and_label(Role::RadioButton, "Dotted").rect();
click_at(&mut h, pos2(dotted.right() - 14.0, dotted.center().y));
let edited = selected_shape(&h);
assert_eq!(edited.path, original.path);
assert_eq!(edited.live, original.live);
assert_eq!(edited.fill, original.fill);
let stroke = edited.stroke.as_ref().unwrap();
assert_eq!(stroke.paint, original.stroke.as_ref().unwrap().paint);
assert_eq!(stroke.width, original.stroke.as_ref().unwrap().width);
assert_eq!(stroke.dashes, [0.0, 2.0]);
assert_eq!(stroke.cap, LineCap::Round);
assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 1);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().ui.stroke_editor.is_none());
assert_eq!(selected_shape(&h), edited, "Escape retains the directly applied style");
assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(selected_shape(&h), original);
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(selected_shape(&h), edited);
}
}
}
fn opacity_drag(h: &mut Harness<'_, crate::PhotocraftApp>, offsets: &[f32], expected_history: usize) -> f32 {
let start = h.get_by_role_and_label(Role::Slider, "Opacity").rect().center();
h.hover_at(start);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
let initial = selected_shape(h).stroke.unwrap().opacity;
let mut end = start;
for &offset in offsets {
end = start + vec2(offset, 0.0);
h.hover_at(end);
h.run_steps(2);
}
let live = selected_shape(h).stroke.unwrap().opacity;
assert!(live < initial, "the shape is updated while the pointer is still held: {initial} -> {live}");
assert_eq!(h.state().session.active().unwrap().history.past_len(), expected_history, "all updates in one drag coalesce");
h.event(egui::Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_eq!(selected_shape(h).stroke.unwrap().opacity, live);
assert_eq!(h.state().session.active().unwrap().history.past_len(), expected_history, "release does not add a second edit");
live
}
#[test]
fn selected_shape_numeric_drags_are_live_and_undo_as_separate_gestures() {
let mut h = properties_harness(json!({"kind": "rect", "rect": [12, 12, 45, 30]}), 16);
let before = h.state().session.active().unwrap().history.past_len();
let original = selected_shape(&h);
let opener = h.get_by_role_and_label(Role::Button, "Stroke Options").rect();
click_at(&mut h, opener.center());
let first = opacity_drag(&mut h, &[-8.0, -16.0, -28.0], before + 1);
let second = opacity_drag(&mut h, &[-8.0, -18.0, -32.0], before + 2);
assert!(second < first);
h.key_press(Key::Escape);
h.run_steps(3);
assert_eq!(selected_shape(&h).stroke.unwrap().opacity, second);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(selected_shape(&h).stroke.unwrap().opacity, first);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(selected_shape(&h), original);
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(selected_shape(&h).stroke.unwrap().opacity, first);
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(selected_shape(&h).stroke.unwrap().opacity, second);
}
struct CanvasScene {
app: crate::PhotocraftApp,
properties: bool,
show_controls: bool,
}
fn canvas_harness(properties: bool) -> Harness<'static, CanvasScene> {
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600})).unwrap();
app.ui.tool = crate::state::Tool::Rectangle;
app.ui.tool_options.vector_mode = "shape".into();
app.ui.tool_options.stroke_width = 3.0;
if properties {
app.run("shape.create", json!({"rect": [20, 20, 80, 60], "stroke": {"width": 3, "color": "#1188dd"}})).unwrap();
}
app.sync_views();
let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).with_max_steps(64).build_ui_state(
|ui, scene: &mut CanvasScene| {
let _language = crate::i18n::language_scope(crate::i18n::Lang::EN);
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
let app = &mut scene.app;
// Use the native ordering: logic, controls, live canvas input, then ownership cleanup.
sync_editor(app, ui.ctx());
crate::shortcuts::handle(app, ui.ctx());
if scene.show_controls {
if scene.properties {
ui.scope(|ui| {
ui.set_max_width(320.0);
let layer = app.session.active().unwrap().active_layer.unwrap();
crate::vector_ui::shape_properties(app, ui, layer);
});
} else {
let tool = app.ui.tool;
ui.horizontal(|ui| crate::vector_ui::options_bar(app, ui, tool));
}
}
let rect = Rect::from_min_max(pos2(350.0, 110.0), pos2(990.0, 740.0));
app.last_canvas_rect = rect;
let view = app.ui.views[0].clone();
crate::canvas::canvas_view(app, ui, 0, rect, view, true);
finish_frame(app, ui.ctx());
},
CanvasScene { app, properties, show_controls: true },
);
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
h.run_steps(4);
h
}
fn scene_open(h: &mut Harness<'_, CanvasScene>) {
let opener = h.get_by_role_and_label(Role::Button, "Stroke Options").rect();
click_at(h, opener.center());
assert!(h.state().app.ui.stroke_editor.is_some());
}
fn canvas_press(h: &mut Harness<'_, CanvasScene>, pos: Pos2, pressed: bool) {
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
#[test]
fn canvas_dismissal_gestures_neither_draw_shapes_nor_open_creation_dialogs() {
for properties in [false, true] {
let mut h = canvas_harness(properties);
let original = h.state().app.session.active().unwrap().doc.clone();
let history = h.state().app.session.active().unwrap().history.past_len();
let start = pos2(820.0, 550.0);
let end = pos2(880.0, 610.0);
// A press and release delivered in one frame must remain dismissal-only even though
// the options row has already dismissed its popup before the canvas handles the click.
scene_open(&mut h);
h.input_mut().events.extend([
egui::Event::PointerMoved(start),
egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
]);
h.run_steps(3);
assert!(h.state().app.ui.stroke_editor.is_none());
assert!(h.state().app.ui.dialogs.is_empty(), "the dismissal click must not open Create Rectangle");
assert_eq!(h.state().app.session.active().unwrap().doc, original);
scene_open(&mut h);
h.hover_at(start);
h.run_steps(1);
canvas_press(&mut h, start, true);
assert!(h.state().app.ui.stroke_editor.is_none(), "the canvas press dismisses the popup immediately");
for pos in [start + vec2(12.0, 12.0), end] {
h.hover_at(pos);
h.run_steps(2);
assert!(h.state().app.drag.is_none(), "no shape preview starts behind the dismissed popup");
}
canvas_press(&mut h, end, false);
h.run_steps(3);
assert!(h.state().app.ui.dialogs.is_empty());
assert_eq!(h.state().app.session.active().unwrap().doc, original);
assert_eq!(h.state().app.session.active().unwrap().history.past_len(), history);
// A new, deliberate gesture works normally once the dismissal pointer is released.
h.hover_at(start);
h.run_steps(1);
canvas_press(&mut h, start, true);
h.hover_at(end);
h.run_steps(2);
canvas_press(&mut h, end, false);
h.run_steps(3);
assert_eq!(h.state().app.session.active().unwrap().history.past_len(), history + 1);
assert!(h.state().app.drag.is_none());
}
}
#[test]
fn hidden_controls_and_collapsed_appearance_release_popup_and_keyboard_ownership() {
for collapsed in [false, true] {
let mut h = canvas_harness(true);
let original = h.state().app.session.active().unwrap().doc.clone();
scene_open(&mut h);
h.get_by_role_and_label(Role::ComboBox, "Alignment").click();
h.run_steps(3);
assert!(egui::Popup::is_any_open(&h.ctx));
if collapsed {
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("props-section", "appearance")), false));
} else {
h.state_mut().show_controls = false;
}
h.run_steps(1);
assert!(h.state().app.ui.stroke_editor.is_none(), "controls that were not drawn no longer own the keyboard");
assert!(!egui::Popup::is_any_open(&h.ctx), "their child popup must be cleaned up too");
assert_eq!(h.state().app.session.active().unwrap().doc, original);
h.key_press(Key::B);
h.run_steps(3);
assert_eq!(h.state().app.ui.tool, crate::state::Tool::Brush, "tool shortcuts resume after the hidden popup closes");
}
}
+7
View File
@@ -311,6 +311,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
if crate::menu_nav::is_open(ctx) || crate::widgets::dropdown_list_open(ctx) {
return;
}
// Stroke Options owns plain navigation/activation keys, including while its child
// dropdown is open. This runs before Popup::show registers the current pass, so use
// the editor's explicit state; otherwise Escape deselects or commits a pending Pen path.
// Modified commands such as Undo still follow the ordinary dispatch below.
if app.ui.stroke_editor.is_some() && !ctx.input(|i| i.modifiers.command || i.modifiers.ctrl) {
return;
}
let symmetry_editing = app.ui.symmetry_transform.is_some();
crate::symmetry_ui::track(app, ctx);
if symmetry_editing && (app.ui.symmetry_transform.is_none() || egui::Popup::is_any_open(ctx)) {
+287
View File
@@ -0,0 +1,287 @@
//! Pointer, keyboard, colour precision and history regressions for shape appearance controls.
use super::*;
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_doc::ShapeLayer;
fn app(mode: &str, depth: u32) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 120, "height": 100, "mode": mode, "depth": depth})).unwrap();
app.ui.tool = Tool::Rectangle;
app.sync_views();
app
}
fn shape(app: &PhotocraftApp) -> &ShapeLayer {
let st = app.session.active().unwrap();
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected editable shape") };
shape
}
fn assert_saved_path(saved: &Path, original: &Path) {
assert_eq!((saved.fill_rule, saved.inverted), (original.fill_rule, original.inverted));
assert_eq!(saved.subpaths.len(), original.subpaths.len());
for (saved, original) in saved.subpaths.iter().zip(&original.subpaths) {
assert_eq!((saved.closed, saved.op, saved.knots.len()), (original.closed, original.op, original.knots.len()));
for (saved, original) in saved.knots.iter().zip(&original.knots) {
assert_eq!(saved.smooth, original.smooth);
for (saved, original) in [(saved.anchor, original.anchor), (saved.in_ctrl, original.in_ctrl), (saved.out_ctrl, original.out_ctrl)] {
// Native manifests use JSON f64 coordinates, whose existing round trip may
// differ by one ulp for generated ellipse/polygon control points.
assert!((saved.x - original.x).abs() <= 1e-10 && (saved.y - original.y).abs() <= 1e-10, "{saved:?} differs from {original:?}");
}
}
}
}
fn harness(app: PhotocraftApp, properties: bool) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
crate::screen_picker::tick(app, ui.ctx());
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
if properties {
let id = app.session.active().unwrap().active_layer.unwrap();
ui.set_max_width(320.0);
shape_properties(app, ui, id);
} else {
let tool = app.ui.tool;
ui.horizontal(|ui| options_bar(app, ui, tool));
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(3);
h
}
fn pointer(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2, pressed: bool) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
#[test]
fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_paint() {
for label in ["Fill color", "Stroke color"] {
let mut app = app("rgb", 32);
let foreground = [0.123456, 0.345678, 0.567891, 0.372819];
let background = [0.891234, 0.678912, 0.456789, 0.0];
app.run("tools.setColors", json!({"foreground": foreground, "background": background})).unwrap();
let document = app.session.active().unwrap().doc.clone();
let mut h = harness(app, false);
h.get_by_label(label).click();
h.run_steps(4);
assert!(h.query_by_label("No Color").is_some(), "{label} opens its picker");
assert_eq!(h.state().session.tools.foreground, foreground);
assert_eq!(h.state().session.tools.background, background);
assert_eq!(h.state().ui.tool_options.stroke_width, 0.0);
assert_eq!(h.state().session.active().unwrap().doc, document);
h.key_press(Key::Escape);
h.run_steps(2);
assert!(h.query_by_label("No Color").is_none());
h.get_by_label(label).focus();
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert!(h.query_by_label("No Color").is_some(), "{label} opens from keyboard focus");
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().doc, document);
assert_eq!(h.state().session.tools.foreground, foreground);
assert_eq!(h.state().session.tools.background, background);
}
}
#[test]
fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() {
let mut app = app("rgb", 32);
let mut picks = std::collections::VecDeque::from([[0.91, 0.17, 0.32], [0.12, 0.68, 0.93]]);
app.services.screen_pick = Some(Box::new(move |_| {
let (tx, receiver) = std::sync::mpsc::channel();
tx.send(Ok(crate::screen_picker::Capture::Color(picks.pop_front()))).unwrap();
crate::screen_picker::Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
}));
app.ui.tool_options.shape_fill = false;
let mut h = harness(app, false);
for label in ["Fill color", "Stroke color"] {
h.get_by_label(label).click();
h.run_steps(3);
h.get_by_label("Pick screen color").click();
h.run_steps(4);
h.key_press(Key::Escape);
h.run_steps(2);
}
assert!(h.state().ui.tool_options.shape_fill);
assert_eq!(h.state().ui.tool_options.stroke_width, 3.0);
assert_eq!(h.state().session.tools.foreground, [0.91, 0.17, 0.32, 1.0]);
assert_eq!(h.state().session.tools.background, [0.12, 0.68, 0.93, 1.0]);
finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE);
assert_eq!(shape(h.state()).fill, Some(solid_paint([0.91, 0.17, 0.32, 1.0])));
assert_eq!(shape(h.state()).stroke.as_ref().unwrap().paint, solid_paint([0.12, 0.68, 0.93, 1.0]));
}
#[test]
fn no_colour_and_solid_choices_are_explicit_live_tool_defaults() {
let mut h = harness(app("rgb", 8), false);
h.get_by_label("Fill color").click();
h.run_steps(3);
h.get_by_label("No Color").click();
h.run_steps(2);
assert!(!h.state().ui.tool_options.shape_fill);
h.get_by_label("Solid").click();
h.run_steps(2);
assert!(h.state().ui.tool_options.shape_fill);
h.key_press(Key::Escape);
h.run_steps(2);
h.get_by_label("Stroke color").click();
h.run_steps(3);
h.get_by_label("Solid").click();
h.run_steps(2);
assert_eq!(h.state().ui.tool_options.stroke_width, 3.0, "choosing unchanged white enables a stroke");
h.get_by_label("No Color").click();
h.run_steps(2);
assert_eq!(h.state().ui.tool_options.stroke_width, 0.0);
assert_eq!(h.state().session.active().unwrap().history.past_len(), 0, "tool defaults do not edit the document");
}
#[test]
fn float_paints_match_preview_creation_native_roundtrip_and_document_depth() {
let foreground = [0.123456, 0.345678, 0.567891, 0.372819];
let background = [0.891234, 0.234567, 0.456789, 0.618237];
for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 16), ("cmyk", 8), ("lab", 16)] {
for tool in [Tool::Rectangle, Tool::Triangle, Tool::EllipseShape] {
let mut app = app(mode, depth);
set_tool_paint(&mut app, false, PaintChange::Color(foreground)).unwrap();
set_tool_paint(&mut app, true, PaintChange::Color(background)).unwrap();
app.ui.tool_options.shape_stroke.cap = photocraft_doc::LineCap::Round;
app.ui.tool_options.shape_stroke.dashes = vec![0.0, 2.0];
let preview = shape_preview(&app, tool, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE).unwrap();
finish_shape(&mut app, tool, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE);
let committed = shape(&app).clone();
assert_eq!(preview.fill, committed.fill);
assert_eq!(preview.stroke, committed.stroke);
assert_eq!(preview.path, committed.path);
assert_eq!(committed.fill, Some(solid_paint(foreground)));
assert_eq!(committed.stroke.as_ref().unwrap().paint, solid_paint(background));
let doc = &app.session.active().unwrap().doc;
let rendered = photocraft_vector::render_shape(&preview, doc.pixel_format(), doc.bounds());
assert_eq!(committed.cache.as_ref().unwrap(), &rendered, "{tool:?} {mode}/{depth}");
let bytes = photocraft_format::save_to_bytes(doc, &Default::default()).unwrap();
let loaded = photocraft_format::load_from_bytes(&bytes).unwrap();
let id = app.session.active().unwrap().active_layer.unwrap();
let LayerContent::Shape(saved) = &loaded.layer(id).unwrap().content else { panic!("saved editable shape") };
assert_eq!((&saved.fill, &saved.stroke), (&committed.fill, &committed.stroke));
assert_saved_path(&saved.path, &committed.path);
app.run("edit.undo", json!({})).unwrap();
app.run("edit.redo", json!({})).unwrap();
assert_eq!(shape(&app), &committed);
}
}
}
#[test]
fn transparent_and_absent_paints_remain_distinct_and_open_pen_keeps_foreground() {
let mut app = app("rgb", 32);
let transparent = [0.124578, 0.564321, 0.765432, 0.0];
set_tool_paint(&mut app, false, PaintChange::Color(transparent)).unwrap();
set_tool_paint(&mut app, true, PaintChange::Color(transparent)).unwrap();
let preview = shape_preview(&app, Tool::Rectangle, [20.0, 20.0], [80.0, 70.0], Modifiers::NONE).unwrap();
assert_eq!(preview.fill, Some(solid_paint(transparent)));
assert_eq!(preview.stroke.as_ref().unwrap().paint, solid_paint(transparent));
finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [80.0, 70.0], Modifiers::NONE);
assert_eq!(shape(&app).fill, preview.fill);
assert_eq!(shape(&app).stroke, preview.stroke);
assert_eq!(shape(&app).cache.as_ref().unwrap().pixel(40, 40), vec![0.0; 4]);
set_tool_paint(&mut app, false, PaintChange::None).unwrap();
set_tool_paint(&mut app, true, PaintChange::None).unwrap();
finish_shape(&mut app, Tool::Triangle, [20.0, 20.0], [80.0, 70.0], Modifiers::NONE);
assert!(shape(&app).fill.is_none() && shape(&app).stroke.is_none());
let foreground = [0.836521, 0.125468, 0.745632, 0.438271];
app.run("tools.setColors", json!({"foreground": foreground, "background": [0.0, 1.0, 0.0, 1.0]})).unwrap();
app.ui.tool_options.vector_mode = "shape".into();
app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[80.0, 60.0]; 3]], ..Default::default() });
pen_commit(&mut app, false);
assert!(shape(&app).fill.is_none());
assert!(!shape(&app).path.subpaths[0].closed);
assert_eq!(shape(&app).stroke.as_ref().unwrap().paint, solid_paint(foreground));
}
#[test]
fn selected_shape_style_is_available_when_absent_and_dismissal_does_not_enable_it() {
let mut app = app("rgb", 8);
app.run("shape.create", json!({"rect": [20,20,80,60], "stroke": null})).unwrap();
let doc = app.session.active().unwrap().doc.clone();
let history = app.session.active().unwrap().history.past_len();
let mut h = harness(app, true);
h.get_by_label("Stroke Options").click();
h.run_steps(3);
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().doc, doc);
h.get_by_label("Stroke Options").click();
h.run_steps(3);
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Dotted").click();
h.run_steps(3);
assert_eq!(shape(h.state()).stroke.as_ref().unwrap().width, 3.0);
assert_eq!(shape(h.state()).stroke.as_ref().unwrap().dashes, [0.0, 2.0]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1);
h.key_press(Key::Escape);
h.run_steps(2);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(shape(h.state()).stroke.is_none());
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(shape(h.state()).stroke.as_ref().unwrap().dashes, [0.0, 2.0]);
}
fn popup_rect(h: &Harness<'_, PhotocraftApp>) -> Rect {
h.ctx
.memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id)))
.expect("colour picker is open")
}
#[test]
fn selected_shape_colour_drags_apply_live_coalesce_per_gesture_and_keep_tool_defaults() {
let mut app = app("rgb", 32);
app.run("shape.create", json!({"rect": [20,20,80,60], "fill": "#3270c0", "stroke": {"width": 5, "color": "#ee3355"}})).unwrap();
let original = shape(&app).clone();
let defaults = (app.session.tools.foreground, app.session.tools.background, app.ui.tool_options.clone());
let history = app.session.active().unwrap().history.past_len();
let mut h = harness(app, true);
h.get_by_label("Set shape fill type").click();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
let rect = popup_rect(&h);
let start = rect.min + vec2(55.0, rect.height() * 0.5);
h.hover_at(start);
h.step();
pointer(&mut h, start, true);
for offset in [10.0, 20.0, 30.0] {
h.event(Event::PointerMoved(start + vec2(offset, 6.0)));
h.step();
}
pointer(&mut h, start + vec2(30.0, 6.0), false);
h.run_steps(2);
let first = shape(h.state()).clone();
assert_ne!(first.fill, original.fill);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1);
let next = start + vec2(60.0, 15.0);
h.hover_at(next);
h.step();
pointer(&mut h, next, true);
h.event(Event::PointerMoved(next + vec2(12.0, 4.0)));
h.step();
pointer(&mut h, next + vec2(12.0, 4.0), false);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2);
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!((h.state().session.tools.foreground, h.state().session.tools.background, h.state().ui.tool_options.clone()), defaults);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(shape(h.state()).fill, first.fill);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(shape(h.state()).fill, original.fill);
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(shape(h.state()).fill, first.fill);
}
+216 -86
View File
@@ -63,7 +63,7 @@ fn stroke_param(app: &PhotocraftApp) -> Value {
}
let mut p = o.shape_stroke.params();
p["width"] = json!(o.stroke_width);
p["color"] = json!(hex(app.session.tools.background));
p["color"] = json!(app.session.tools.background);
p
}
@@ -122,7 +122,7 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [
}
fn fill_param(app: &PhotocraftApp) -> Value {
if app.ui.tool_options.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null }
if app.ui.tool_options.shape_fill { json!(app.session.tools.foreground) } else { Value::Null }
}
/// Create a shape layer from `shape.create` geometry with the options bar's fill and stroke: the
@@ -133,38 +133,41 @@ pub fn create_shape(app: &mut PhotocraftApp, mut geometry: Value) -> Result<Valu
app.run("shape.create", geometry)
}
/// Shape-tool drag preview using the same path and vector stroke geometry as the commit.
pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
/// The preview and command both use the full float tool colours, including transparent paint.
fn shape_preview(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<photocraft_doc::ShapeLayer> {
let o = &app.ui.tool_options;
let Some(geometry) = shape_geometry(o, tool, start, end, mods) else { return };
let geometry = shape_geometry(o, tool, start, end, mods)?;
let path = if tool == Tool::CustomShape {
let Some(rect) = geometry.get("rect").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok()) else { return };
let rect = geometry.get("rect").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok())?;
let groups = photocraft_engine::presets::shapes::all_groups(&app.session);
let Some(shape) = groups.iter().flat_map(|g| &g.items).find(|s| s.name == app.ui.presets_ui.shape()) else { return };
let shape = groups.iter().flat_map(|g| &g.items).find(|s| s.name == app.ui.presets_ui.shape())?;
photocraft_engine::presets::shapes::fit(&shape.path, rect, mods.shift)
} else {
let Ok(path) = photocraft_engine::vector_cmds::shape_path(&geometry) else { return };
path
photocraft_engine::vector_cmds::shape_path(&geometry).ok()?
};
// ponytail: preview colours skip the canvas's colour management; the commit renders them exactly.
let fill = if o.shape_fill { rgb32(app.session.tools.foreground) } else { Color32::TRANSPARENT };
{
let origin = xf.to_screen(0.0, 0.0);
let x = xf.to_screen(1.0, 0.0) - origin;
let y = xf.to_screen(0.0, 1.0) - origin;
let transform = Affine { m: [x.x as f64, x.y as f64, y.x as f64, y.y as f64, origin.x as f64, origin.y as f64] };
let c = app.session.tools.background;
let shape = photocraft_doc::ShapeLayer {
path: path.transform(&transform),
fill: (fill != Color32::TRANSPARENT).then(|| {
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(fill.r() as f32 / 255.0, fill.g() as f32 / 255.0, fill.b() as f32 / 255.0, 1.0))
}),
stroke: (o.stroke_width > 0.0)
.then(|| o.shape_stroke.stroke(o.stroke_width * xf.zoom, photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3])))),
..Default::default()
};
crate::shape_stroke_ui::paint_shape_preview(painter, &shape);
Some(photocraft_doc::ShapeLayer {
path,
fill: o.shape_fill.then(|| solid_paint(app.session.tools.foreground)),
stroke: (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, solid_paint(app.session.tools.background))),
..Default::default()
})
}
/// Shape-tool drag preview using the same path and vector stroke geometry as the commit.
pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
let Some(mut shape) = shape_preview(app, tool, start, end, mods) else { return };
let path = shape.path.clone();
let origin = xf.to_screen(0.0, 0.0);
let x = xf.to_screen(1.0, 0.0) - origin;
let y = xf.to_screen(0.0, 1.0) - origin;
let transform = Affine { m: [x.x as f64, x.y as f64, y.x as f64, y.y as f64, origin.x as f64, origin.y as f64] };
shape.path = shape.path.transform(&transform);
if let Some(stroke) = &mut shape.stroke {
stroke.width *= xf.zoom;
}
// This lightweight screen overlay retains the existing display-colour behaviour; document
// rendering still goes through its format/profile path when the shape is committed.
crate::shape_stroke_ui::paint_shape_preview(painter, &shape);
let accent = Tokens::get(painter.ctx()).accent;
for (pts, _) in path_lines(&path, &|q| xf.to_screen(q[0] as f32, q[1] as f32)) {
// The accent path over a dark halo: visible on any pixels (#172).
@@ -270,13 +273,13 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) {
}
let path = pen_to_json(pen, closed);
let r = if app.ui.tool_options.vector_mode == "shape" {
let fill = if closed && app.ui.tool_options.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null };
let fill = if closed { fill_param(app) } else { Value::Null };
let stroke = if closed {
stroke_param(app)
} else {
let mut stroke = app.ui.tool_options.shape_stroke.params();
stroke["width"] = json!(app.ui.tool_options.stroke_width.max(1.0));
stroke["color"] = json!(hex(app.session.tools.foreground));
stroke["color"] = json!(app.session.tools.foreground);
stroke
};
app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke}))
@@ -705,8 +708,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
if o.vector_mode == "shape" {
crate::widgets::value_field(ui, &mut o.stroke_width, 0.0..=288.0, "px", 58.0);
if let Some(style) = crate::shape_stroke_ui::button(ui, "pen-stroke", &o.shape_stroke, &mut app.ui.stroke_editor, false, true, None) {
o.shape_stroke = style;
if let Some(change) =
crate::shape_stroke_ui::button(ui, "pen-stroke", &o.shape_stroke, &mut app.ui.stroke_editor, false, true, None, o.stroke_width > 0.0)
{
o.shape_stroke = change.options;
o.stroke_width = enabled_stroke_width(o.stroke_width);
}
}
let enter = crate::shortcuts::pretty("Enter");
@@ -733,15 +739,26 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
lbl(ui, tl!("Fill:"));
crate::widgets::checkbox(ui, &mut o.shape_fill, "");
let (r, _) = ui.allocate_exact_size(vec2(22.0, 16.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, rgb32(app.session.tools.foreground));
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
let fg = app.session.tools.foreground;
let fill = solid_paint(fg);
let fill_color = swatch(ui, o.shape_fill.then_some(&fill), fg, tl!("Fill color"));
lbl(ui, tl!("Stroke:"));
let bg = app.session.tools.background;
let stroke_color = swatch(ui, Some(&photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(bg[0], bg[1], bg[2], bg[3]))), tl!("Stroke color"));
let stroke = solid_paint(bg);
let stroke_color = swatch(ui, (o.stroke_width > 0.0).then_some(&stroke), bg, tl!("Stroke color"));
crate::widgets::value_field(ui, &mut o.stroke_width, 0.0..=288.0, "px", 58.0);
if let Some(style) = crate::shape_stroke_ui::button(ui, "tool-stroke", &o.shape_stroke, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, None) {
o.shape_stroke = style;
if let Some(change) = crate::shape_stroke_ui::button(
ui,
"tool-stroke",
&o.shape_stroke,
&mut app.ui.stroke_editor,
true,
tool != Tool::EllipseShape,
None,
o.stroke_width > 0.0,
) {
o.shape_stroke = change.options;
o.stroke_width = enabled_stroke_width(o.stroke_width);
}
match tool {
Tool::Rectangle => {
@@ -768,10 +785,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
}
_ => {}
}
if let Some(color) = stroke_color {
if color == "none" {
app.ui.tool_options.stroke_width = 0.0;
} else if let Err(e) = app.run("tools.setColors", json!({"background": color})) {
for (is_stroke, change) in [(false, fill_color), (true, stroke_color)] {
if let Some(change) = change
&& let Err(e) = set_tool_paint(app, is_stroke, change)
{
app.ui.status = e;
app.ui.status_error = true;
}
@@ -782,30 +799,77 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
// ---------------------------------------------------------------------------------------------
// Properties (shape layers)
fn color_of(f: &photocraft_doc::Fill) -> Option<Color32> {
fn solid_paint(c: [f32; 4]) -> photocraft_doc::Fill {
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3]))
}
fn color_of(f: &photocraft_doc::Fill) -> Option<[f32; 4]> {
match f {
photocraft_doc::Fill::Solid(c) => {
let v = c.to_rgba8();
Some(Color32::from_rgb(v[0], v[1], v[2]))
let [r, g, b] = c.to_rgb();
Some([r, g, b, c.alpha])
}
_ => None,
}
}
/// A colour swatch that opens a picker; returns the new `#rrggbb` when changed.
fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, tip: &str) -> Option<String> {
#[derive(Clone, Copy, Debug, PartialEq)]
enum PaintChange {
Color([f32; 4]),
None,
}
fn enabled_stroke_width(width: f32) -> f32 {
if width.is_finite() && width > 0.0 { width } else { 3.0 }
}
/// Tool-bar paint changes affect the next shape; Properties edits the selected shape instead.
fn set_tool_paint(app: &mut PhotocraftApp, stroke: bool, change: PaintChange) -> Result<(), String> {
if let PaintChange::Color(color) = change {
app.run("tools.setColors", if stroke { json!({"background": color}) } else { json!({"foreground": color}) })?;
}
if stroke {
app.ui.tool_options.stroke_width = if change == PaintChange::None { 0.0 } else { enabled_stroke_width(app.ui.tool_options.stroke_width) };
} else {
app.ui.tool_options.shape_fill = change != PaintChange::None;
}
Ok(())
}
fn rgba32(c: [f32; 4]) -> Color32 {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))
}
fn picker_color(c: [f32; 4]) -> egui::ecolor::Hsva {
let linear = egui::ecolor::linear_from_gamma;
let display = c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
egui::ecolor::Hsva::from_rgba_unmultiplied(linear(display[0]), linear(display[1]), linear(display[2]), display[3])
}
fn picker_rgba(hsva: egui::ecolor::Hsva) -> [f32; 4] {
let [r, g, b, a] = hsva.to_rgba_unmultiplied();
let gamma = egui::ecolor::gamma_from_linear;
[gamma(r), gamma(g), gamma(b), a]
}
/// The shared compact picker keeps the float RGB paint and its alpha. Color32 is only display
/// data: opening the picker must never quantize an existing high-depth colour or enable paint.
fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32; 4], tip: &str) -> Option<PaintChange> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 18.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 22.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), tip));
let current = fill.and_then(color_of);
crate::widgets::checker(ui.painter(), r.shrink(1.0), 4.0);
match (current, fill) {
(Some(c), _) => {
ui.painter().rect_filled(r, 2.0, c);
ui.painter().rect_filled(r.shrink(1.0), t.radius_sm, rgba32(c));
}
(None, Some(photocraft_doc::Fill::Gradient { stops, .. })) if !stops.is_empty() => {
// Gradient fills preview as a left-to-right ramp through their stops.
let rgb = |c: &photocraft_doc::Color| {
let v = c.to_rgba8();
Color32::from_rgb(v[0], v[1], v[2])
Color32::from_rgba_unmultiplied(v[0], v[1], v[2], v[3])
};
let mut mesh = egui::Mesh::default();
let mut ramp: Vec<(f32, Color32)> = stops.iter().map(|(p, c)| (p.clamp(0.0, 1.0), rgb(c))).collect();
@@ -823,32 +887,61 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, tip: &str) ->
}
ui.painter().add(mesh);
}
(None, Some(_)) => {
// Pattern fill: a neutral checker hint.
crate::widgets::checker(ui.painter(), r, 4.0);
}
(None, Some(_)) => {}
(None, None) => {
// "No colour": white with a red slash, like Photoshop.
ui.painter().rect_filled(r, 2.0, Color32::WHITE);
ui.painter().line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(220, 40, 40)));
ui.painter().rect_filled(r.shrink(1.0), t.radius_sm, t.field);
ui.painter().line_segment([r.shrink(3.0).left_bottom(), r.shrink(3.0).right_top()], Stroke::new(1.5, t.danger));
}
};
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
let border = if resp.has_focus() {
t.accent
} else if resp.hovered() {
t.text_dim
} else {
t.field_border
};
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, border), egui::StrokeKind::Inside);
let resp = resp.on_hover_text(tip);
let screen_id = resp.id.with("screen-color");
let mut out = crate::screen_picker::take(ui.ctx(), screen_id).map(crate::color_picker_ui::hex);
let original = current.unwrap_or(fallback);
let sampled = |rgb: [f32; 3]| PaintChange::Color([rgb[0], rgb[1], rgb[2], original[3]]);
let mut out = crate::screen_picker::take(ui.ctx(), screen_id).map(sampled);
let cache = resp.id.with("shape-paint-hsva");
let mut hsva = ui
.data(|d| d.get_temp::<([f32; 4], egui::ecolor::Hsva)>(cache))
.filter(|(previous, _)| *previous == original)
.map_or_else(|| picker_color(original), |(_, hsva)| hsva);
if let Some(PaintChange::Color(color)) = out {
hsva = picker_color(color);
}
crate::widgets::swatch_popup(&resp).show(|ui| {
let mut c = current.unwrap_or(Color32::BLACK);
if egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque) {
out = Some(format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()));
ui.horizontal(|ui| {
if ui.selectable_label(fill.is_none(), tl!("No Color")).clicked() && fill.is_some() {
out = Some(PaintChange::None);
}
if ui.selectable_label(current.is_some(), tl!("Solid")).clicked() && current.is_none() {
out = Some(PaintChange::Color(original));
}
});
ui.separator();
let before = picker_rgba(hsva);
if egui::color_picker::color_picker_hsva_2d(ui, &mut hsva, egui::color_picker::Alpha::OnlyBlend) {
let color = picker_rgba(hsva);
// egui's HSV gamma/linear round trip can move a float by an ulp without input.
if color.iter().zip(before).any(|(a, b)| (a - b).abs() > 0.000001) {
out = Some(PaintChange::Color(color));
}
}
if let Some(rgb) = crate::screen_picker::button(ui, screen_id) {
out = Some(crate::color_picker_ui::hex(rgb));
}
if fill.is_some() && ui.button(tl!("No Color")).clicked() {
out = Some("none".into());
out = Some(sampled(rgb));
hsva = picker_color([rgb[0], rgb[1], rgb[2], original[3]]);
}
});
let cached_color = match out {
Some(PaintChange::Color(color)) => color,
_ => original,
};
ui.data_mut(|d| d.insert_temp(cache, (cached_color, hsva)));
out
}
@@ -862,6 +955,21 @@ fn corner_radii_patch(radii: [f64; 4], corner: usize, radius: f32) -> Value {
json!({"radii": next})
}
/// Follow the existing brush/mask continuous-edit convention: one history state per press or
/// keyboard edit, with every value tick of a held-pointer gesture sharing its key.
fn paint_gesture(ui: &egui::Ui, key: egui::Id) -> u64 {
let pass = ui.ctx().cumulative_pass_nr();
let pressed = ui.input(|i| i.pointer.any_pressed());
ui.data_mut(|d| {
let gesture = d.get_temp_mut_or_default::<(u64, u64)>(key);
if pressed && gesture.1 != pass {
gesture.0 = gesture.0.wrapping_add(1);
gesture.1 = pass;
}
gesture.0
})
}
/// Properties panel for a shape layer: Appearance (fill, stroke) and live shape geometry.
pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocraft_doc::LayerId) {
if app.session.active().is_some_and(|st| {
@@ -880,40 +988,52 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
let mut edit: Option<Value> = None;
let doc_id = app.session.active().map(|st| st.doc.id);
let key = |k: &str| format!("shape-{doc_id:?}-{}-{k}", id.0);
let gesture_key = ui.id().with(key("paint-gesture"));
let gesture = paint_gesture(ui, gesture_key);
let coalesce = |field: &str| format!("{}:{}:{gesture}", key(field), gesture_key.value());
// The shared collapsible section headers (#155).
if crate::props_layout::section(ui, "appearance", tl!("Appearance")) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Fill")).color(t.text_dim).size(12.0));
if let Some(c) = swatch(ui, sh.fill.as_ref(), tl!("Set shape fill type")) {
edit = Some(if c == "none" { json!({"fill": null}) } else { json!({"fill": c, "coalesce": key("fill")}) });
if let Some(change) = swatch(ui, sh.fill.as_ref(), app.session.tools.foreground, tl!("Set shape fill type")) {
let fill = match change {
PaintChange::None => Value::Null,
PaintChange::Color(color) => json!(color),
};
edit = Some(json!({"fill": fill, "coalesce": coalesce("fill")}));
}
ui.add_space(12.0);
ui.label(egui::RichText::new(tl!("Stroke")).color(t.text_dim).size(12.0));
if let Some(c) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), tl!("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(change) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), app.session.tools.foreground, tl!("Set shape stroke type")) {
let stroke = match change {
PaintChange::None => Value::Null,
PaintChange::Color(color) => json!({"color": color, "width": enabled_stroke_width(sh.stroke.as_ref().map_or(0.0, |s| s.width))}),
};
edit = Some(json!({"stroke": stroke, "coalesce": coalesce("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")}) });
let stroke = if w <= 0.0 { Value::Null } else { json!({"width": w}) };
edit = Some(json!({"stroke": stroke, "coalesce": coalesce("stroke-w")}));
}
});
if let Some(stroke) = &sh.stroke {
let style = crate::shape_stroke_ui::StrokeOptions::from(stroke);
if let Some(next) = crate::shape_stroke_ui::button(
ui,
&key("style"),
&style,
&mut app.ui.stroke_editor,
sh.path.subpaths.iter().all(|s| s.closed),
!matches!(sh.live, Some(photocraft_doc::LiveShape::Ellipse { .. })),
app.session.active().map(|st| (st.doc.id, id)),
) {
edit = Some(json!({"stroke": next.params()}));
let style = sh.stroke.as_ref().map(crate::shape_stroke_ui::StrokeOptions::from).unwrap_or_default();
let stroke_enabled = sh.stroke.as_ref().is_some_and(|stroke| stroke.width > 0.0);
if let Some(change) = crate::shape_stroke_ui::button(
ui,
&key("style"),
&style,
&mut app.ui.stroke_editor,
sh.path.subpaths.iter().all(|s| s.closed),
!matches!(sh.live, Some(photocraft_doc::LiveShape::Ellipse { .. })),
app.session.active().map(|st| (st.doc.id, id)),
stroke_enabled,
) {
let mut stroke = change.options.params();
if !stroke_enabled {
stroke["width"] = json!(3.0);
}
edit = Some(json!({"stroke": stroke, "coalesce": change.coalesce}));
}
}
if let Some(live) = &sh.live
@@ -991,6 +1111,12 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
app.ui.status = e;
app.ui.status_error = true;
}
if !ui.input(|i| i.pointer.any_down()) {
ui.data_mut(|d| {
let gesture = d.get_temp_mut_or_default::<(u64, u64)>(gesture_key);
gesture.0 = gesture.0.wrapping_add(1);
});
}
}
}
@@ -1724,3 +1850,7 @@ mod tests {
#[cfg(test)]
#[path = "path_selection_tests.rs"]
mod path_selection_tests;
#[cfg(test)]
#[path = "vector_paint_tests.rs"]
mod paint_tests;
+2
View File
@@ -4,6 +4,8 @@ Shape stroke defaults are drivable with `ui.set {shapeStroke: {width, align, cap
miterLimit, dashes, dashOffset, opacity}}`; partial fields merge, null disables the stroke.
Values use the existing `shape.edit` names and units. Colour uses `tools.setColors`.
`ui.inspect` exposes `toolOptions.shape_stroke`, `stroke_width`, and `strokeEditor`.
Stroke Options applies valid UI edits immediately; `strokeEditor` is the open editor state,
not an uncommitted document preview. Escape/outside clicks close it without reverting edits.
See [Shape stroke options](shape-strokes.md) for limits, preview and persistence behavior.
The desktop app listens on `127.0.0.1:<port>` (loopback only). Start it with a token file so the credential is not exposed in the process command line:
+33 -12
View File
@@ -1,17 +1,28 @@
# Shape stroke options
Updated 2026-10-11: direct editing, compact layout, and clickable colour/style controls.
PhotoCraft's Shape tool options bar has a Stroke Options button beside the existing width
and colour controls. It sets defaults for new Rectangle, Ellipse, Triangle, Polygon, Line
and Custom Shape layers. Pen in Shape mode uses the same style defaults; open Pen shapes retain
their existing foreground colour and minimum 1 px width. To edit an existing shape,
use Properties → Appearance → Stroke Options.
Fill and Stroke swatches are clickable and open the existing compact colour picker. Choosing
a colour preserves its alpha; No Color removes the corresponding paint. Tool-bar fill uses
the foreground colour and stroke uses the background colour, as before. Choosing a stroke
colour or style deliberately enables a 3 px stroke when no stroke is present. The style button
remains available with no stroke, but opening or dismissing it alone does not enable one.
Properties edits the selected shape; the options bar continues to set defaults for new shapes.
The editor offers Solid, Dashed (4 widths on, 2 off), Dotted (zero-length dashes with round
caps, spaced every 2 widths), and custom repeating dash/gap sequences. Dash lengths and
offsets are multiples of the stroke width; width itself is in document pixels. Odd-length
imported sequences repeat twice, following the existing renderer convention. Up to 16
dash/gap pairs can be edited, with Add pair / Remove pair. Zero-length dashes draw dots with
Round caps and squares with Square caps; Butt caps draw nothing at a zero-length dash.
Each preset's full row, including its line sample, is one clickable target with a visible
selected state and an accessible label.
Caps are Butt / Flat, Round, and Square. They affect open endpoints and individual dashed
segments. Closed solid shapes have no endpoints, so their cap control is disabled. Corners
@@ -26,22 +37,27 @@ fill rule and component operations. Open and mixed open/closed paths use Center;
alignment control is disabled. Dash lengths continue to use the specified width, including
for Inside and Outside. Opacity is independent of width and paint.
The editor stages changes. Its compact line and rectangle previews use the real vector
renderer. Properties also previews the selected shape on the regular colour-managed canvas,
cached by document revision and draft. Apply commits one `shape.edit` history step; Cancel,
Escape, or dismissing the popup discards the preview. Changing documents/layers, locking the
target, or changing the source style options invalidates the staged editor. Tool-default changes
do not edit the document. Shape
drag previews use the vector renderer, with a cached screen proxy capped at 2048 pixels on
the longest axis; like the prior drag overlay, its colour swatches bypass monitor-profile
conversion. Committed shapes and the Properties preview use the normal compositor.
Stroke Options applies valid changes immediately and has no Apply or Cancel buttons.
Properties sends changes through `shape.edit`; one continuous numeric drag is one undo step.
Escape or a click outside closes the popup and retains already applied changes. Opening or
closing it without changing a value creates no history entry. Invalid pending numeric values
do not replace the last valid style. Child dropdowns can open and select an option without
dismissing Stroke Options; Escape closes the child before the parent. The popup fits its
content and scrolls only when the available height requires it.
The compact line and rectangle previews use the real vector renderer. Tool-default changes
do not edit the document. Shape drag previews use the vector renderer, with a cached screen
proxy capped at 2048 pixels on the longest axis; like the prior drag overlay, their colours
bypass monitor-profile conversion. Committed shapes, including live Properties edits, use
the normal colour-managed compositor.
Finite values are required: width 0–1,000,000 px, opacity 0–100%, dash/gap lengths
0–10,000 widths, offset ±1,000,000 widths, at most 32 lengths, and a nonempty pattern total
of at least 0.01 widths. Interactive commands reject geometry beyond ±1,000,000 px,
over 4096 knots/subpaths, more than 100,000 tessellation points, or a dash workload over
100,000 steps. File-supplied invalid/dense patterns are bounded during rendering. There are
no custom saved preset libraries in this change; style defaults use existing serde UI state.
no custom saved preset libraries in this change. Style defaults use existing serde UI state,
but the app does not save and restore these defaults across restarts.
All fields already exist in `ShapeStroke` and the native manifest/conversion. Old `.pcraft`
files retain their serde defaults; saves preserve all style fields. PSD import/export maps
@@ -55,11 +71,16 @@ Automation: `shape.create`, `shape.edit`, and `shape.info` expose document strok
control channel also supports `ui.set {shapeStroke: {width, align, cap, join, miterLimit,
dashes, dashOffset, opacity}}` for tool defaults, merging partial fields; null sets width to
zero. Set tool colours with `tools.setColors`. `ui.inspect` reports `toolOptions.shape_stroke`,
`toolOptions.stroke_width`, and the staged `strokeEditor`. Keyboard focus, standard number
fields, ComboBoxes, and Apply/Cancel buttons remain native egui controls.
`toolOptions.stroke_width`, and the transient `strokeEditor`. Keyboard focus, standard number
fields, colour pickers, and ComboBoxes remain native egui controls.
## Upstream and public references
- [PR #2218](https://github.com/storytold/photocraft/pull/2218) introduced these controls with
staged previews. The direct-edit interaction above replaces that Apply/Cancel workflow.
- [Issue #2496](https://github.com/storytold/photocraft/issues/2496) tracks broader Shape tool
parity, including selected-shape editing from the options bar and additional fill types;
those remain separate work.
- [Issue #1053](https://github.com/storytold/photocraft/issues/1053) documents the missing
stroke controls and existing engine/model support. Gradient fill UI remains separate work.
- [Issue #1931](https://github.com/storytold/photocraft/issues/1931) requests sharp stroke options.
+11
View File
@@ -56,6 +56,17 @@ curl -sfL -o assets/icons/<name>.svg https://raw.githubusercontent.com/lucide-ic
| Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. |
| Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. |
| Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. |
| Shape Stroke Options | `shape_stroke_ui.rs`, `vector_ui.rs` | Direct editing: valid changes apply immediately, with one numeric drag per undo step in Properties. Escape/outside click closes the popup and retains changes; a child dropdown keeps the parent open. The options bar sets new-shape defaults; Properties edits the selected shape. |
Shape Stroke Options (updated 2026-10-11) sizes to its content and scrolls only when necessary.
It has no Apply/Cancel footer. Solid, Dashed and Dotted are full clickable rows with line
samples and visible selection; opening the editor alone never changes a shape. Invalid pending
numeric input leaves the last valid style in effect. Fill and Stroke use clickable swatches
and the existing compact colour picker, preserving alpha and offering No Color. The style
button remains available when no stroke is set; deliberately choosing a stroke colour or
style enables a 3 px stroke. Colours, borders and selection treatments come from theme tokens.
These tool defaults are serde state, but are not restored across app restarts. See
[`shape-strokes.md`](shape-strokes.md) for geometry, colour-management and persistence limits.
While editing type, hold Ctrl (Windows/Linux) or Command (macOS) for an oriented transform
frame. Drag inside to move, a corner to scale (Shift keeps proportions; Alt/Option scales about