mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
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:
12 files changed
+1749
-444
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user