* type: transform editable text with temporary Ctrl/Cmd handles * type: cover temporary transforms, editable files and preview costs * docs: describe temporary Type transforms and automation * type: release temporary previews immediately when gestures end * test: benchmark the active 24 MP Type document
This commit is contained in:
parent
ef9d075f23
commit
46fb94f813
|
|
@ -606,6 +606,9 @@ pub(crate) const GPU_OUTPUT: u32 = u32::MAX;
|
|||
|
||||
/// The document to render: the committed one, or a clone with the live adjustment preview applied.
|
||||
fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, u64) {
|
||||
if let Some(shown) = crate::type_transform::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
// Puppet / Perspective Warp previews hide the layer they draw on a mesh.
|
||||
if let Some(shown) = crate::distort_ui::display_doc(app, idx) {
|
||||
return shown;
|
||||
|
|
@ -810,6 +813,12 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
|
|||
if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 {
|
||||
return last_damage;
|
||||
}
|
||||
if seen.0 == now.0
|
||||
&& let Some(st) = app.session.documents().get(idx)
|
||||
&& let Some(r) = crate::type_transform::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
|
||||
{
|
||||
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
|
||||
}
|
||||
// Between an adjustment dialog's previews (and the document): the target's area.
|
||||
if seen.0 == now.0
|
||||
&& let Some(st) = app.session.documents().get(idx)
|
||||
|
|
@ -2009,7 +2018,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
d.reposition = reposition;
|
||||
drawing = true;
|
||||
}
|
||||
let temporary = crate::hold_keys::for_frame(app, &ctx, drawing);
|
||||
let temporary = crate::hold_keys::for_frame(app, &ctx, drawing || crate::type_transform::active(app));
|
||||
let space_pan = temporary == Some(crate::hold_keys::Temporary::Hand);
|
||||
let middle = ui.input(|i| i.pointer.middle_down());
|
||||
let tool = match temporary {
|
||||
|
|
@ -2064,6 +2073,26 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
// (Preferences › Tools, `paint_mouse`).
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
// Capture temporary Type transforms at the actual press, before egui's drag threshold.
|
||||
// Releasing Command before the first recognised move must not turn it into text selection.
|
||||
let type_press = egui::Id::new("type-pointer-press-modifiers");
|
||||
if tool.is_type()
|
||||
&& let Some((p, press_mods)) = ui.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers } if rect.contains(*pos) => {
|
||||
Some((*pos, *modifiers))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
{
|
||||
let press_mods = crate::workspace_ui::sticky_mods(app, press_mods);
|
||||
ctx.data_mut(|d| d.insert_temp(type_press, press_mods));
|
||||
if press_mods.command && app.ui.text_edit.is_some() {
|
||||
let d = xf.to_doc(p);
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods);
|
||||
}
|
||||
}
|
||||
// Right-click while transforming: switch the box's mode (Free Transform, Scale, Rotate,
|
||||
// Skew, Distort, Perspective).
|
||||
let transforming = app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none());
|
||||
|
|
@ -2115,7 +2144,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
begin_transform_controls_at(app, &ctx, &xf, p);
|
||||
}
|
||||
let d = xf.to_doc(p);
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, mods);
|
||||
let press_mods = if tool.is_type() { ctx.data(|d| d.get_temp(type_press)).unwrap_or(mods) } else { mods };
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods);
|
||||
}
|
||||
if buttons.dragged || buttons.stopped {
|
||||
// Feed every pointer move the OS delivered this frame, not just the latest position, so
|
||||
|
|
@ -2211,7 +2241,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
if app.ui.transform.is_some() && response.double_clicked() {
|
||||
crate::transform_tool::commit(app);
|
||||
}
|
||||
if tool.is_type() && response.double_clicked() {
|
||||
if tool.is_type() && response.double_clicked() && !crate::type_transform::visible(app, crate::workspace_ui::sticky_mods(app, mods)) {
|
||||
crate::type_tool::select_word(app);
|
||||
}
|
||||
if app.ui.extras.grid && app.ui.view.extras {
|
||||
|
|
@ -2247,6 +2277,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
} else if let Some(c) = response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt))) {
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if let Some(c) = response.hover_pos().filter(|_| tool.is_type()).and_then(|p| {
|
||||
let mods = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers));
|
||||
crate::type_transform::cursor(app, &ctx, &xf, p, mods)
|
||||
}) {
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Crop).and_then(|p| crate::crop_ui::cursor(app, xf.to_doc(p))) {
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if app.drag.as_ref().is_some_and(|d| d.sel_move.is_some())
|
||||
|
|
@ -2735,6 +2770,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
|||
// consume the event, so it never outlives the press it was armed for (#297).
|
||||
let armed = std::mem::take(&mut app.brush_resize_armed);
|
||||
crate::transform_tool::end_if_left(app);
|
||||
let mods = crate::workspace_ui::sticky_mods(app, mods);
|
||||
if app.ui.transform.is_none() && crate::type_transform::pointer(app, ev, mods) {
|
||||
return;
|
||||
}
|
||||
// View › Snap / Snap To and smart guides (snap_ui.rs).
|
||||
let raw = ev;
|
||||
// A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first.
|
||||
|
|
|
|||
|
|
@ -626,6 +626,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
|||
"toolOptions": app.ui.tool_options,
|
||||
"magnetic": app.ui.magnetic,
|
||||
"textEdit": app.ui.text_edit,
|
||||
"typeTransform": app.ui.type_transform,
|
||||
"layerMenu": app.ui.layer_menu,
|
||||
"canvasToolMenu": app.ui.canvas_tool_menu.as_ref().map(|menu| {
|
||||
json!({
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ pub mod transform_tex;
|
|||
pub mod transform_tool;
|
||||
pub mod type_panels_ui;
|
||||
pub mod type_tool;
|
||||
mod type_transform;
|
||||
mod variables_ui;
|
||||
pub mod vector_ui;
|
||||
pub mod view_cmds;
|
||||
|
|
@ -439,6 +440,7 @@ pub struct PhotocraftApp {
|
|||
pub(crate) crop: crop_ui::CropState,
|
||||
/// Type tool layout cache: ((doc, revision, layer), layout).
|
||||
pub(crate) type_layout: Option<((u64, u64, u64), std::sync::Arc<photocraft_text::TextLayout>)>,
|
||||
pub(crate) type_transform_preview: Option<type_transform::Preview>,
|
||||
/// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels.
|
||||
channel_thumbs: Option<(DocId, std::sync::Weak<Document>, Vec<egui::TextureHandle>)>,
|
||||
/// Channels panel overlays / channel views drawn over the canvas, per document id.
|
||||
|
|
@ -527,6 +529,7 @@ impl PhotocraftApp {
|
|||
channel_thumbs: None,
|
||||
channel_views: HashMap::new(),
|
||||
type_layout: None,
|
||||
type_transform_preview: None,
|
||||
guide_drag: None,
|
||||
crop: Default::default(),
|
||||
hover_doc: None,
|
||||
|
|
@ -695,6 +698,7 @@ impl PhotocraftApp {
|
|||
/// Keep one view per document, in tab order: a view and its windows stay with their document
|
||||
/// when tabs move (`document.move`) or close.
|
||||
pub fn sync_views(&mut self) {
|
||||
type_transform::cancel_stale(self);
|
||||
crate::lasso_ui::cancel_stale(self);
|
||||
let ids: Vec<DocId> = self.session.documents().iter().map(|d| d.doc.id).collect();
|
||||
// Where the document of view `i` is now. Views not tracked yet keep their index.
|
||||
|
|
|
|||
|
|
@ -666,6 +666,16 @@ pub struct TextEdit {
|
|||
pub preedit: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Temporary Type-tool drag. The document is unchanged until release; this frame is view state.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TypeTransform {
|
||||
pub document: u64,
|
||||
pub revision: u64,
|
||||
pub original: photocraft_geom::Affine,
|
||||
pub frame: TransformSession,
|
||||
pub(crate) gesture: crate::transform_tool::Gesture,
|
||||
}
|
||||
|
||||
/// View-menu overlays.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
|
|
@ -715,6 +725,12 @@ pub struct UiState {
|
|||
/// Inline type editing session, if any.
|
||||
#[serde(default)]
|
||||
pub text_edit: Option<TextEdit>,
|
||||
/// Ctrl/Cmd Type-tool gesture, exposed to automation but never restored as a held pointer.
|
||||
#[serde(default, skip_deserializing)]
|
||||
pub type_transform: Option<TypeTransform>,
|
||||
/// Reference point for the current type editing session (document coordinates).
|
||||
#[serde(skip)]
|
||||
pub type_transform_pivot: Option<[f64; 2]>,
|
||||
/// Free Transform session, if any.
|
||||
#[serde(default)]
|
||||
pub transform: Option<TransformSession>,
|
||||
|
|
@ -858,6 +874,8 @@ impl Default for UiState {
|
|||
tool: Tool::Brush,
|
||||
recent_files: Vec::new(),
|
||||
text_edit: None,
|
||||
type_transform: None,
|
||||
type_transform_pivot: None,
|
||||
transform: None,
|
||||
mask_target: false,
|
||||
vector_mask_target: false,
|
||||
|
|
|
|||
|
|
@ -641,8 +641,8 @@ fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value {
|
|||
}
|
||||
|
||||
/// Which part of the box a document point hits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Hit {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) enum Hit {
|
||||
Corner(usize),
|
||||
Edge(usize),
|
||||
Pivot,
|
||||
|
|
@ -652,7 +652,7 @@ enum Hit {
|
|||
|
||||
/// The nearest handle within `tol` (on a small box their grab areas overlap), else inside or
|
||||
/// outside the box.
|
||||
fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit {
|
||||
pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit {
|
||||
let d = |a: [f64; 2]| ((a[0] - p[0]).powi(2) + (a[1] - p[1]).powi(2)).sqrt();
|
||||
let mid = |i: usize| {
|
||||
let (a, b) = (t.quad[i], t.quad[(i + 1) % 4]);
|
||||
|
|
@ -679,12 +679,12 @@ fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool {
|
|||
}
|
||||
|
||||
/// Transient drag state (kept in egui memory; not part of the serialised session).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Gesture {
|
||||
hit: Hit,
|
||||
start: [f64; 2],
|
||||
quad0: [[f64; 2]; 4],
|
||||
pivot0: [f64; 2],
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct Gesture {
|
||||
pub(crate) hit: Hit,
|
||||
pub(crate) start: [f64; 2],
|
||||
pub(crate) quad0: [[f64; 2]; 4],
|
||||
pub(crate) pivot0: [f64; 2],
|
||||
}
|
||||
|
||||
/// Pointer input while transforming. Returns false when no transform is active.
|
||||
|
|
@ -787,7 +787,7 @@ fn keep_convex(from: [[f64; 2]; 4], to: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
|
|||
lerp(lo)
|
||||
}
|
||||
|
||||
fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Modifiers) {
|
||||
pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Modifiers) {
|
||||
let (dx, dy) = (p[0] - g.start[0], p[1] - g.start[1]);
|
||||
match g.hit {
|
||||
Hit::Inside => {
|
||||
|
|
|
|||
|
|
@ -34,15 +34,16 @@ pub fn layout(app: &mut PhotocraftApp, id: LayerId) -> Option<(Arc<TextLayout>,
|
|||
let st = app.session.active()?;
|
||||
let (doc, rev) = (st.doc.clone(), st.revision);
|
||||
let t = text_layer(&doc, id)?;
|
||||
let transform = crate::type_transform::current_transform(app, id).unwrap_or(t.transform);
|
||||
let key = (doc.id.0, rev, id.0);
|
||||
if let Some((k, l)) = &app.type_layout
|
||||
&& *k == key
|
||||
{
|
||||
return Some((l.clone(), t.transform, t.text.clone()));
|
||||
return Some((l.clone(), transform, t.text.clone()));
|
||||
}
|
||||
let l = Arc::new(photocraft_text::shared().lock().ok()?.layout(t, doc.resolution_dpi));
|
||||
app.type_layout = Some((key, l.clone()));
|
||||
Some((l, t.transform, t.text.clone()))
|
||||
Some((l, transform, t.text.clone()))
|
||||
}
|
||||
|
||||
fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) {
|
||||
|
|
@ -145,7 +146,7 @@ fn handle_sides(i: u8) -> (bool, bool, bool, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> {
|
||||
pub(crate) fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> {
|
||||
match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape))? {
|
||||
photocraft_doc::text::TextShape::Box { x, y, width, height } => Some((x, y, width, height)),
|
||||
_ => None,
|
||||
|
|
@ -153,7 +154,7 @@ fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> {
|
|||
}
|
||||
|
||||
/// The paragraph-box handle under the document point, if the layer is paragraph text.
|
||||
fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> Option<u8> {
|
||||
pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> Option<u8> {
|
||||
let (bx, by, w, h) = box_shape(app, id)?;
|
||||
let (_, aff, _) = layout(app, id)?;
|
||||
let (r, b) = (bx + w, by + h);
|
||||
|
|
@ -300,6 +301,7 @@ fn insert(app: &mut PhotocraftApp, s: &str) {
|
|||
if a == b && s.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::type_transform::finish(app);
|
||||
if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": a, "end": b, "text": s}, "coalesce": ed.session})).is_ok()
|
||||
&& let Some(e) = app.ui.text_edit.as_mut()
|
||||
{
|
||||
|
|
@ -317,6 +319,7 @@ fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) {
|
|||
if before.is_none_or(|c| c == '\n') || after.is_none_or(|c| c == '\n') {
|
||||
return;
|
||||
}
|
||||
crate::type_transform::finish(app);
|
||||
let _ = app.run("type.edit", json!({"layer": id.0, "kernPair": {"at": caret, "by": by}}));
|
||||
}
|
||||
|
||||
|
|
@ -338,6 +341,7 @@ fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) {
|
|||
return;
|
||||
}
|
||||
let len = s.chars().count();
|
||||
crate::type_transform::finish(app);
|
||||
if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": s}, "coalesce": ed.session})).is_ok()
|
||||
&& let Some(e) = app.ui.text_edit.as_mut()
|
||||
{
|
||||
|
|
@ -407,6 +411,10 @@ fn line_edge(app: &mut PhotocraftApp, id: LayerId, caret: usize, end: bool) -> u
|
|||
/// Keyboard input while editing. Returns true when a type edit session is active (single-key
|
||||
/// tool shortcuts must then be skipped). Handled events are removed from the frame's input.
|
||||
pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
crate::type_transform::cancel_stale(app);
|
||||
if !ctx.input(|i| i.focused) {
|
||||
crate::type_transform::cancel_drag(app);
|
||||
}
|
||||
let Some(ed) = app.ui.text_edit.clone() else { return false };
|
||||
let id = LayerId(ed.layer);
|
||||
let Some(text) = current_text(app, id) else {
|
||||
|
|
@ -511,6 +519,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
|||
}
|
||||
Key::Enter if m.command => commit(app),
|
||||
Key::Enter => insert(app, "\n"),
|
||||
Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app),
|
||||
Key::Escape => commit(app),
|
||||
Key::A if m.command => {
|
||||
if let Some(e) = app.ui.text_edit.as_mut() {
|
||||
|
|
@ -524,7 +533,12 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
|||
let _ = crate::menus::invoke(app, ctx, "window.panel.character", json!({}));
|
||||
}
|
||||
// Other command shortcuts (⌘Z, ⌘S, …) pass through to the menus.
|
||||
_ if m.command => handled[k] = false,
|
||||
_ if m.command => {
|
||||
if *key == Key::Z {
|
||||
crate::type_transform::cancel_drag(app);
|
||||
}
|
||||
handled[k] = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -547,6 +561,10 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
|||
|
||||
/// End the editing session. A new layer left empty is deleted; a new layer is named after its text.
|
||||
pub fn commit(app: &mut PhotocraftApp) {
|
||||
if app.ui.tool.is_type() {
|
||||
crate::type_transform::finish(app);
|
||||
}
|
||||
crate::type_transform::reset(app);
|
||||
let Some(ed) = app.ui.text_edit.take() else { return };
|
||||
let Some(text) = current_text(app, LayerId(ed.layer)) else { return };
|
||||
if text.trim().is_empty() && ed.created {
|
||||
|
|
@ -583,6 +601,10 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
|
|||
});
|
||||
}
|
||||
// Frame: paragraph text shows its box with handles; point text an underline per line.
|
||||
let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers));
|
||||
if crate::type_transform::draw(app, painter, xf, mods) {
|
||||
return;
|
||||
}
|
||||
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape));
|
||||
let frame = Stroke::new(1.0, t.accent);
|
||||
match shape {
|
||||
|
|
@ -1346,6 +1368,7 @@ fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
|
||||
/// Cancel the editing session: undo it back to where it started (removes a new layer).
|
||||
pub fn cancel(app: &mut PhotocraftApp) {
|
||||
crate::type_transform::reset(app);
|
||||
let Some(ed) = app.ui.text_edit.take() else { return };
|
||||
let coalesced = app.session.active().is_some_and(|s| s.coalesce.as_deref() == Some(ed.session.as_str()));
|
||||
if coalesced {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ use crate::PhotocraftApp;
|
|||
use crate::canvas::ViewXform;
|
||||
|
||||
fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state(
|
||||
make_harness(ppp, app, false)
|
||||
}
|
||||
|
||||
fn make_harness(ppp: f32, app: PhotocraftApp, render: bool) -> Harness<'static, PhotocraftApp> {
|
||||
let builder = Harness::builder().with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp);
|
||||
let builder = if render { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
|
|
@ -452,3 +458,586 @@ fn command_t_while_typing_toggles_the_character_panel() {
|
|||
assert!(h.state().ui.text_edit.is_some(), "still editing");
|
||||
assert_ne!(crate::view_cmds::checked(h.state(), "window.panel.character"), before, "the Character panel toggled");
|
||||
}
|
||||
|
||||
fn type_command() -> Modifiers {
|
||||
Modifiers { command: true, ctrl: true, ..Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn hold(h: &mut Harness<'static, PhotocraftApp>, mods: Modifiers) {
|
||||
h.event(egui::Event::ModifiersChanged(mods));
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
fn transform_press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, mods: Modifiers) {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: mods });
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
fn transform_drag(h: &mut Harness<'static, PhotocraftApp>, a: Pos2, b: Pos2, mods: Modifiers, release_command: bool) {
|
||||
hold(h, mods);
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
transform_press(h, a, true, mods);
|
||||
for k in 1..=6 {
|
||||
if release_command && k == 4 {
|
||||
hold(h, Modifiers { command: false, ctrl: false, mac_cmd: false, ..mods });
|
||||
}
|
||||
h.hover_at(a + (b - a) * (k as f32 / 6.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
transform_press(h, b, false, if release_command { Modifiers::NONE } else { mods });
|
||||
hold(h, Modifiers::NONE);
|
||||
}
|
||||
|
||||
fn transform_app(paragraph: bool, vertical: bool, transform: Affine) -> (PhotocraftApp, LayerId) {
|
||||
let mut app = new_app();
|
||||
let mut p = json!({"text": "Hello é世界\nSecond line", "size": 32, "x": 300, "y": 420});
|
||||
if paragraph {
|
||||
p["box"] = json!([300, 300, 240, 180]);
|
||||
}
|
||||
if vertical {
|
||||
p["orientation"] = json!("vertical");
|
||||
}
|
||||
let id = LayerId(app.run("type.create", p).unwrap()["layer"].as_u64().unwrap());
|
||||
app.run("type.edit", json!({"layer": id.0, "transform": transform.m})).unwrap();
|
||||
super::edit_active(&mut app).unwrap();
|
||||
let ed = app.ui.text_edit.as_mut().unwrap();
|
||||
ed.caret = 3;
|
||||
ed.anchor = 3;
|
||||
(app, id)
|
||||
}
|
||||
|
||||
fn frame_point(app: &PhotocraftApp, p: [f64; 2]) -> Pos2 {
|
||||
xf(app).to_screen(p[0] as f32, p[1] as f32)
|
||||
}
|
||||
|
||||
fn assert_affine(a: Affine, b: Affine) {
|
||||
for (x, y) in a.m.into_iter().zip(b.m) {
|
||||
assert!((x - y).abs() < 2e-4, "{a:?} != {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1091: modifier changes alone draw/hide the oriented frame without touching editing/history.
|
||||
#[test]
|
||||
fn temporary_type_transform_visibility_and_cursors() {
|
||||
let (app, id) = transform_app(false, false, Affine::translate(500.0, 350.0));
|
||||
let mut h = harness(1.0, app);
|
||||
let before = h.state().session.active().unwrap().history.entries().len();
|
||||
let original = text(h.state(), id);
|
||||
assert!(!crate::type_transform::visible(h.state(), Modifiers::NONE));
|
||||
hold(&mut h, type_command());
|
||||
assert!(crate::type_transform::visible(h.state(), h.ctx.input(|i| i.modifiers)));
|
||||
let frame = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let mut positions: Vec<[f64; 2]> = frame.quad.to_vec();
|
||||
positions.extend((0..4).map(|i| [(frame.quad[i][0] + frame.quad[(i + 1) % 4][0]) / 2.0, (frame.quad[i][1] + frame.quad[(i + 1) % 4][1]) / 2.0]));
|
||||
for (i, q) in positions.into_iter().enumerate() {
|
||||
let p = frame_point(h.state(), q);
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
let cursor = h.output().platform_output.cursor_icon;
|
||||
if i < 4 {
|
||||
assert!(matches!(
|
||||
cursor,
|
||||
egui::CursorIcon::ResizeNwSe | egui::CursorIcon::ResizeNeSw | egui::CursorIcon::ResizeHorizontal | egui::CursorIcon::ResizeVertical
|
||||
));
|
||||
} else {
|
||||
assert_eq!(cursor, egui::CursorIcon::None, "skew arrowhead");
|
||||
}
|
||||
}
|
||||
let middle = [(frame.quad[0][0] + frame.quad[1][0]) / 2.0, frame.quad[0][1] - 20.0 / f64::from(h.state().current_zoom())];
|
||||
h.hover_at(frame_point(h.state(), middle));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "curved rotation cursor");
|
||||
hold(&mut h, Modifiers::NONE);
|
||||
assert!(!crate::type_transform::visible(h.state(), Modifiers::NONE));
|
||||
assert_eq!(selection(&h), (3, 3));
|
||||
assert_eq!(text(h.state(), id).transform, original.transform);
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), before);
|
||||
assert!(h.state().ui.transform.is_none());
|
||||
assert!(h.state().ui.type_transform.is_none());
|
||||
// macOS's real Control is not the logical Command modifier.
|
||||
assert!(!crate::type_transform::visible(h.state(), Modifiers { ctrl: true, command: false, ..Modifiers::NONE }));
|
||||
h.state_mut().ui.shell.sticky_command = true;
|
||||
let sticky = crate::workspace_ui::sticky_mods(h.state(), Modifiers::NONE);
|
||||
assert!(crate::type_transform::visible(h.state(), sticky));
|
||||
}
|
||||
|
||||
/// Real canvas input: layer-local scaling stays correct across orientation, zoom, HiDPI and flip.
|
||||
#[test]
|
||||
fn temporary_type_transform_scales_without_reflow_or_leaving_type() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
for zoom in [0.25, 1.0, 2.0, 8.0] {
|
||||
for (paragraph, vertical, flip) in [(false, false, false), (true, false, true), (false, true, true)] {
|
||||
let transform = Affine { m: [1.2, 0.25, 0.18, 0.9, 500.0, 360.0] };
|
||||
let (app, id) = transform_app(paragraph, vertical, transform);
|
||||
let mut h = harness(ppp, app);
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let center = f.pivot;
|
||||
h.state_mut().ui.view.flip_horizontal = flip;
|
||||
let view = &mut h.state_mut().ui.views[0];
|
||||
// At high zoom, show the dragged corner; a release beyond the canvas is valid.
|
||||
view.center = if zoom >= 2.0 { [f.quad[2][0] as f32, f.quad[2][1] as f32] } else { [center[0] as f32 + 17.0, center[1] as f32 - 9.0] };
|
||||
view.zoom = zoom;
|
||||
view.fit_pending = false;
|
||||
h.run_steps(2);
|
||||
let before = text(h.state(), id);
|
||||
let count = h.state().session.active().unwrap().history.entries().len();
|
||||
let (a, b) = (f.quad[2], [f.quad[0][0] + 1.25 * (f.quad[2][0] - f.quad[0][0]), f.quad[0][1] + 1.25 * (f.quad[2][1] - f.quad[0][1])]);
|
||||
let (a, b) = (frame_point(h.state(), a), frame_point(h.state(), b));
|
||||
assert!(xf(h.state()).rect.contains(a));
|
||||
transform_drag(&mut h, a, b, type_command() | Modifiers::SHIFT, true);
|
||||
let after = text(h.state(), id);
|
||||
assert_affine(
|
||||
after.transform,
|
||||
transform.mul(&Affine::translate(f.rect[0], f.rect[1])).mul(&Affine::scale(1.25)).mul(&Affine::translate(-f.rect[0], -f.rect[1])),
|
||||
);
|
||||
assert_eq!(
|
||||
(after.text.as_str(), &after.runs, &after.paragraphs, after.orientation, after.shape),
|
||||
(before.text.as_str(), &before.runs, &before.paragraphs, before.orientation, before.shape)
|
||||
);
|
||||
assert_eq!(selection(&h), (3, 3));
|
||||
assert_eq!(h.state().ui.text_edit.as_ref().unwrap().layer, id.0);
|
||||
assert!(h.state().ui.tool.is_type());
|
||||
assert!(h.state().ui.transform.is_none());
|
||||
assert!(h.state().ui.type_transform.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), count + 1);
|
||||
h.event(egui::Event::Text("!".into()));
|
||||
h.run_steps(2);
|
||||
assert!(text(h.state(), id).text.starts_with("Hel!lo"));
|
||||
h.key_press(if vertical { egui::Key::ArrowUp } else { egui::Key::ArrowLeft });
|
||||
h.run_steps(1);
|
||||
assert_eq!(selection(&h), (3, 3));
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), count + 1, "one edit session");
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_affine(text(h.state(), id).transform, before.transform);
|
||||
assert_eq!(text(h.state(), id).text, before.text);
|
||||
h.state_mut().run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(text(h.state(), id).transform, after.transform);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_type_transform_free_scale_center_scale_move_skew_and_rotation() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
for gesture in ["free", "center", "move", "skew", "rotate", "snap"] {
|
||||
let initial = Affine::translate(400.0, 350.0).mul(&Affine::rotate(0.2));
|
||||
let (mut app, id) = transform_app(true, false, initial);
|
||||
let frame = crate::type_transform::frame(&mut app).unwrap();
|
||||
let before = text(&app, id);
|
||||
let (a, b, mods) = match gesture {
|
||||
"move" => (frame.quad[0].map(|v| v + 20.0), frame.quad[0].map(|v| v + 45.0), type_command()),
|
||||
"skew" => {
|
||||
let p = [(frame.quad[0][0] + frame.quad[1][0]) / 2.0, (frame.quad[0][1] + frame.quad[1][1]) / 2.0];
|
||||
(p, [p[0] + 30.0, p[1] + 12.0], type_command())
|
||||
}
|
||||
"rotate" | "snap" => {
|
||||
let a = [frame.quad[1][0] + 20.0, frame.quad[1][1] - 10.0];
|
||||
let da: f64 = 0.42;
|
||||
let (x, y) = (a[0] - frame.pivot[0], a[1] - frame.pivot[1]);
|
||||
let b = [frame.pivot[0] + x * da.cos() - y * da.sin(), frame.pivot[1] + x * da.sin() + y * da.cos()];
|
||||
(a, b, if gesture == "snap" { type_command() | Modifiers::SHIFT } else { type_command() })
|
||||
}
|
||||
_ => {
|
||||
let a = frame.quad[2];
|
||||
let local = [frame.rect[2] + 60.0, frame.rect[3] + 18.0];
|
||||
let p = initial.apply(Point::new(local[0], local[1]));
|
||||
(a, [p.x, p.y], if gesture == "center" { type_command() | Modifiers::ALT } else { type_command() })
|
||||
}
|
||||
};
|
||||
let count = app.session.active().unwrap().history.entries().len();
|
||||
tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, mods);
|
||||
for k in 1..=5 {
|
||||
let f = f64::from(k) / 5.0;
|
||||
tool_event(&mut app, ToolEvent::Move { x: a[0] + (b[0] - a[0]) * f, y: a[1] + (b[1] - a[1]) * f, pressure: 1.0 }, mods);
|
||||
assert!(crate::type_transform::display_doc(&mut app, 0).is_some());
|
||||
assert_eq!(text(&app, id).transform, initial, "preview leaves engine untouched");
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), count);
|
||||
tool_event(&mut app, ToolEvent::Up { x: b[0], y: b[1] }, mods);
|
||||
let after = text(&app, id);
|
||||
assert_ne!(after.transform, initial, "{gesture}");
|
||||
assert_eq!(after.shape, before.shape, "no paragraph reflow");
|
||||
assert_eq!(selection(&harness(1.0, app)), (3, 3));
|
||||
// Assertions below use the matrix itself to check the chosen gesture.
|
||||
if gesture == "move" {
|
||||
assert_affine(after.transform, Affine::translate(25.0, 25.0).mul(&initial));
|
||||
} else if gesture == "rotate" {
|
||||
assert_affine(
|
||||
after.transform,
|
||||
Affine::translate(frame.pivot[0], frame.pivot[1])
|
||||
.mul(&Affine::rotate(0.42))
|
||||
.mul(&Affine::translate(-frame.pivot[0], -frame.pivot[1]))
|
||||
.mul(&initial),
|
||||
);
|
||||
} else if gesture == "snap" {
|
||||
let angle = after.transform.m[1].atan2(after.transform.m[0]).to_degrees();
|
||||
assert!((angle / 15.0 - (angle / 15.0).round()).abs() < 1e-8);
|
||||
} else if gesture == "center" {
|
||||
let p = after.transform.apply(Point::new((frame.rect[0] + frame.rect[2]) / 2.0, (frame.rect[1] + frame.rect[3]) / 2.0));
|
||||
assert!((p.x - frame.pivot[0]).abs() < 1e-8 && (p.y - frame.pivot[1]).abs() < 1e-8);
|
||||
} else if gesture == "free" {
|
||||
let ratio_x = after.transform.m[0].hypot(after.transform.m[1]);
|
||||
let ratio_y = after.transform.m[2].hypot(after.transform.m[3]);
|
||||
assert!((ratio_x - ratio_y).abs() > 0.05, "unconstrained corners");
|
||||
} else if gesture == "skew" {
|
||||
assert_ne!(after.transform.m[2], before.transform.m[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_type_transform_noop_cancel_and_stale_capture() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
for stop in ["noop", "escape", "focus", "tool", "undo", "delete", "document", "close"] {
|
||||
let original = Affine::translate(400.0, 350.0);
|
||||
let (app, id) = transform_app(false, false, original);
|
||||
let mut h = harness(1.0, app);
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let a = f.quad[2];
|
||||
let count = h.state().session.active().unwrap().history.entries().len();
|
||||
tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command());
|
||||
tool_event(h.state_mut(), ToolEvent::Move { x: a[0] + 50.0, y: a[1] + 20.0, pressure: 1.0 }, type_command());
|
||||
let preview = {
|
||||
let (shown, _) = crate::type_transform::display_doc(h.state_mut(), 0).unwrap();
|
||||
std::sync::Arc::downgrade(&shown)
|
||||
};
|
||||
assert_eq!(text(h.state(), id).transform, original);
|
||||
match stop {
|
||||
"noop" => tool_event(h.state_mut(), ToolEvent::Up { x: a[0], y: a[1] }, Modifiers::NONE),
|
||||
"escape" => {
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
}
|
||||
"focus" => {
|
||||
h.input_mut().focused = false;
|
||||
h.run_steps(2);
|
||||
}
|
||||
"tool" => {
|
||||
h.state_mut().ui.tool = crate::state::Tool::Brush;
|
||||
h.state_mut().sync_views();
|
||||
}
|
||||
"undo" => {
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
"delete" => {
|
||||
h.state_mut().run("layer.delete", json!({"layer": id.0})).unwrap();
|
||||
}
|
||||
"close" => {
|
||||
h.state_mut().run("file.close", json!({})).unwrap();
|
||||
}
|
||||
_ => {
|
||||
h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(h.state().ui.type_transform.is_none(), "{stop}");
|
||||
assert!(preview.upgrade().is_none(), "{stop}: release rendered preview without waiting for another frame");
|
||||
if ["noop", "escape", "focus", "tool"].contains(&stop) {
|
||||
assert_eq!(text(h.state(), id).transform, original);
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), count);
|
||||
}
|
||||
if stop == "escape" {
|
||||
assert!(h.state().ui.text_edit.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_type_transform_control_roundtrip_and_input_safety() {
|
||||
use crate::control::{ControlRequest, Outcome};
|
||||
let initial = Affine::translate(400.0, 350.0);
|
||||
let (mut app, id) = transform_app(true, false, initial);
|
||||
// A document selection must not restrict a text-layer transformation.
|
||||
app.run("select.rect", json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap();
|
||||
let frame = crate::type_transform::frame(&mut app).unwrap();
|
||||
let a = frame.quad[2];
|
||||
let ctx = egui::Context::default();
|
||||
let call = |app: &mut PhotocraftApp, params| {
|
||||
let (req, _) = ControlRequest::new("ui.pointer", params);
|
||||
let Outcome::Done(result) = crate::control::handle(app, &ctx, &req) else { panic!("expected immediate pointer reply") };
|
||||
assert_eq!(result["ok"], true, "{result}");
|
||||
};
|
||||
call(&mut app, json!({"command": true, "events": [{"kind": "down", "x": a[0], "y": a[1]}]}));
|
||||
let inspected = crate::control::inspect(&app, &ctx);
|
||||
assert_eq!(inspected["typeTransform"]["frame"]["layer"], id.0);
|
||||
call(&mut app, json!({"command": true, "events": [{"kind": "move", "x": a[0]+60.0, "y": a[1]+30.0}]}));
|
||||
call(&mut app, json!({"events": [{"kind": "up", "x": a[0]+60.0, "y": a[1]+30.0}]}));
|
||||
assert_ne!(text(&app, id).transform, initial);
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
for name in ["type.pcraft", "type.psd"] {
|
||||
let out = photocraft_io::export(doc, name, &Default::default()).unwrap();
|
||||
let back = photocraft_io::import(name, &out.bytes).unwrap().document;
|
||||
let t = back.walk().into_iter().find_map(|(_, _, l)| if let LayerContent::Text(t) = &l.content { Some(t) } else { None }).unwrap();
|
||||
assert_affine(t.transform, text(&app, id).transform);
|
||||
assert_eq!(t.text, text(&app, id).text);
|
||||
assert_eq!(t.shape, text(&app, id).shape);
|
||||
}
|
||||
for content in ["", " ", "é👩💻世界", "\n\n"] {
|
||||
app.run("type.edit", json!({"layer": id.0, "text": content, "point": [400, 350]})).unwrap();
|
||||
assert!(crate::type_transform::frame(&mut app).is_some());
|
||||
call(
|
||||
&mut app,
|
||||
json!({"command": true, "events": [{"kind": "down", "x": 400, "y": 350}, {"kind": "move", "x": 1e300, "y": -1e300}, {"kind": "up", "x": 400, "y": 350}]}),
|
||||
);
|
||||
assert!(text(&app, id).transform.m.iter().all(|v| v.is_finite()));
|
||||
assert!(app.ui.type_transform.is_none());
|
||||
}
|
||||
app.run("layer.lockLayers", json!({"position": true})).unwrap();
|
||||
assert!(crate::type_transform::frame(&mut app).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_type_transform_preserves_depth_and_color_model() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (16, "gray"), (8, "cmyk"), (16, "lab")] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 1200, "height": 900, "depth": depth, "mode": mode})).unwrap();
|
||||
let id = LayerId(app.run("type.create", json!({"text": "PhotoCraft é世界", "size": 32, "x": 300, "y": 350})).unwrap()["layer"].as_u64().unwrap());
|
||||
super::edit_active(&mut app).unwrap();
|
||||
let format = app.session.active().unwrap().doc.pixel_format();
|
||||
let a = crate::type_transform::frame(&mut app).unwrap().quad[2];
|
||||
tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command());
|
||||
tool_event(&mut app, ToolEvent::Move { x: a[0] + 30.0, y: a[1] + 15.0, pressure: 1.0 }, type_command());
|
||||
let (shown, _) = crate::type_transform::display_doc(&mut app, 0).unwrap();
|
||||
assert_eq!(shown.pixel_format(), format);
|
||||
assert_eq!(text_layer(&shown, id).unwrap().cache.as_ref().unwrap().format(), format);
|
||||
tool_event(&mut app, ToolEvent::Up { x: a[0] + 30.0, y: a[1] + 15.0 }, Modifiers::NONE);
|
||||
assert_eq!(text(&app, id).cache.as_ref().unwrap().format(), format);
|
||||
assert_eq!(text(&app, id).text, "PhotoCraft é世界");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_type_transform_preserves_paragraph_resize_pivot_ime_and_shortcuts() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let (app, id) = transform_app(true, false, Affine::translate(350.0, 300.0));
|
||||
let mut h = harness(1.0, app);
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let before = text(h.state(), id);
|
||||
let (a, b) = (frame_point(h.state(), f.quad[2]), frame_point(h.state(), [f.quad[2][0] + 40.0, f.quad[2][1] + 20.0]));
|
||||
h.hover_at(a);
|
||||
h.run_steps(2);
|
||||
assert!(matches!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe | egui::CursorIcon::ResizeNeSw));
|
||||
drag(&mut h, a, b);
|
||||
let after = text(h.state(), id);
|
||||
assert_ne!(after.shape, before.shape);
|
||||
assert_eq!(after.transform, before.transform, "plain drag resizes the frame");
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let count = h.state().session.active().unwrap().history.entries().len();
|
||||
let pivot = [f.pivot[0] + 45.0, f.pivot[1] - 15.0];
|
||||
tool_event(h.state_mut(), ToolEvent::Down { x: f.pivot[0], y: f.pivot[1], pressure: 1.0 }, type_command());
|
||||
tool_event(h.state_mut(), ToolEvent::Up { x: pivot[0], y: pivot[1] }, type_command());
|
||||
assert_eq!(h.state().ui.type_transform_pivot, Some(pivot));
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), count, "moving the pivot is view state");
|
||||
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(2);
|
||||
assert_eq!(selection(&h), (0, after.text.chars().count()));
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "候補".into(), active_range_chars: None }));
|
||||
h.run_steps(2);
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let a = f.quad[2];
|
||||
tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command());
|
||||
tool_event(h.state_mut(), ToolEvent::Up { x: a[0] + 20.0, y: a[1] + 10.0 }, type_command());
|
||||
assert_eq!(h.state().ui.text_edit.as_ref().unwrap().preedit, Some((0, 2)));
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let a = f.quad[2];
|
||||
tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command());
|
||||
tool_event(h.state_mut(), ToolEvent::Move { x: a[0] + 15.0, y: a[1] + 10.0, pressure: 1.0 }, type_command());
|
||||
let composed_transform = crate::type_transform::current_transform(h.state(), id).unwrap();
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Commit("確定".into())));
|
||||
h.run_steps(2);
|
||||
assert_eq!(text(h.state(), id).text, "確定");
|
||||
assert_affine(text(h.state(), id).transform, composed_transform);
|
||||
assert!(h.state().ui.type_transform.is_none(), "composition commits a pending drag before editing");
|
||||
assert!(h.state().ui.text_edit.as_ref().unwrap().preedit.is_none());
|
||||
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.text_edit.is_none());
|
||||
super::edit_active(h.state_mut()).unwrap();
|
||||
assert!(h.state().ui.text_edit.is_some());
|
||||
super::commit(h.state_mut());
|
||||
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::T);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.transform.is_some(), "outside typing: ordinary Free Transform");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_type_transform_captures_press_and_rejects_collapsed_geometry() {
|
||||
let (app, id) = transform_app(false, false, Affine::translate(450.0, 350.0));
|
||||
let mut h = harness(1.0, app);
|
||||
let frame = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let a = frame_point(h.state(), frame.quad[2]);
|
||||
let b = a + vec2(50.0, 20.0);
|
||||
hold(&mut h, type_command());
|
||||
h.hover_at(a);
|
||||
transform_press(&mut h, a, true, type_command());
|
||||
assert!(h.state().ui.type_transform.is_some(), "capture before any movement");
|
||||
hold(&mut h, Modifiers::NONE);
|
||||
h.hover_at(b);
|
||||
h.run_steps(2);
|
||||
transform_press(&mut h, b, false, Modifiers::NONE);
|
||||
assert_ne!(text(h.state(), id).transform, Affine::translate(450.0, 350.0));
|
||||
assert_eq!(selection(&h), (3, 3));
|
||||
let frame = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let before = text(h.state(), id).transform;
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let (a, b) = (frame.quad[2], frame.quad[0]);
|
||||
tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command());
|
||||
tool_event(h.state_mut(), ToolEvent::Up { x: b[0], y: b[1] }, type_command());
|
||||
assert_eq!(text(h.state(), id).transform, before, "collapsed scale is rejected");
|
||||
let mut saved = serde_json::to_value(&h.state().ui).unwrap();
|
||||
saved["type_transform"] = json!({"invalid": "restored pointer capture"});
|
||||
let restored: crate::state::UiState = serde_json::from_value(saved).unwrap();
|
||||
assert!(restored.type_transform.is_none());
|
||||
}
|
||||
|
||||
/// Render on demand with the same offscreen wgpu infrastructure as the snapshot example.
|
||||
#[test]
|
||||
#[ignore = "writes visual evidence to target/issue-1091; run explicitly for visual QA"]
|
||||
fn temporary_type_transform_visual_evidence() {
|
||||
let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/issue-1091");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() {
|
||||
let (app, _) = transform_app(false, false, Affine::translate(450.0, 350.0));
|
||||
let mut h = make_harness(if index == 0 { 2.0 } else { 1.0 }, app, true);
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, theme);
|
||||
h.run_steps(4);
|
||||
h.render().unwrap().save(out.join(format!("{index}-normal.png"))).unwrap();
|
||||
hold(&mut h, type_command());
|
||||
h.render().unwrap().save(out.join(format!("{index}-handles.png"))).unwrap();
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let mut handles = f.quad.to_vec();
|
||||
handles.extend((0..4).map(|i| [(f.quad[i][0] + f.quad[(i + 1) % 4][0]) / 2.0, (f.quad[i][1] + f.quad[(i + 1) % 4][1]) / 2.0]));
|
||||
for (i, q) in handles.into_iter().enumerate() {
|
||||
h.hover_at(frame_point(h.state(), q));
|
||||
h.run_steps(2);
|
||||
h.render().unwrap().save(out.join(format!("{index}-handle-{i}.png"))).unwrap();
|
||||
}
|
||||
let a = frame_point(h.state(), f.quad[2]);
|
||||
transform_press(&mut h, a, true, type_command());
|
||||
h.hover_at(a + vec2(70.0, 35.0));
|
||||
h.run_steps(2);
|
||||
h.render().unwrap().save(out.join(format!("{index}-scaling.png"))).unwrap();
|
||||
crate::type_transform::cancel_drag(h.state_mut());
|
||||
transform_press(&mut h, a + vec2(70.0, 35.0), false, type_command());
|
||||
h.state_mut().ui.type_transform_pivot = None;
|
||||
let ed = h.state().ui.text_edit.as_ref().unwrap().layer;
|
||||
h.state_mut().run("type.edit", json!({"layer": ed, "transform": Affine::translate(450.0, 350.0).mul(&Affine::rotate(-0.3)).m})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.render().unwrap().save(out.join(format!("{index}-rotated.png"))).unwrap();
|
||||
let f = crate::type_transform::frame(h.state_mut()).unwrap();
|
||||
let q = [f.quad[1][0] + 20.0, f.quad[1][1] - 15.0];
|
||||
let a = frame_point(h.state(), q);
|
||||
h.hover_at(a);
|
||||
h.run_steps(2);
|
||||
transform_press(&mut h, a, true, type_command());
|
||||
let b = frame_point(h.state(), [q[0] + 35.0, q[1] + 60.0]);
|
||||
h.hover_at(b);
|
||||
h.run_steps(2);
|
||||
h.render().unwrap().save(out.join(format!("{index}-rotating.png"))).unwrap();
|
||||
transform_press(&mut h, b, false, type_command());
|
||||
h.state_mut().run("type.edit", json!({"layer": ed, "box": [400, 300, 260, 180]})).unwrap();
|
||||
crate::type_transform::reset(h.state_mut());
|
||||
hold(&mut h, Modifiers::NONE);
|
||||
h.run_steps(3);
|
||||
h.render().unwrap().save(out.join(format!("{index}-paragraph.png"))).unwrap();
|
||||
hold(&mut h, type_command());
|
||||
h.render().unwrap().save(out.join(format!("{index}-paragraph-transform.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_type_transform_imported_psd_retains_editability_and_styles() {
|
||||
let (mut app, original_id) = transform_app(false, false, Affine { m: [1.5, 0.2, 0.1, 0.8, 400.0, 350.0] });
|
||||
app.run("type.setStyle", json!({"layer": original_id.0, "range": [0, 5], "fauxBold": true, "color": "#3366cc", "tracking": 25, "align": "center"}))
|
||||
.unwrap();
|
||||
app.run("layer.renameLayer", json!({"layer": original_id.0, "name": "Styled caption"})).unwrap();
|
||||
app.run("layer.layerStyle.dropShadow", json!({"layer": original_id.0, "distance": 8, "size": 4})).unwrap();
|
||||
let exported = photocraft_io::export(&app.session.active().unwrap().doc, "type.psd", &Default::default()).unwrap();
|
||||
let imported = photocraft_io::import("type.psd", &exported.bytes).unwrap().document;
|
||||
let mut app = new_app();
|
||||
app.session.add_document(imported, None);
|
||||
app.sync_views();
|
||||
let id = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).unwrap().2.id;
|
||||
app.session.select_layer(id).unwrap();
|
||||
super::edit_active(&mut app).unwrap();
|
||||
let before = text(&app, id);
|
||||
assert!(before.runs.len() > 1, "mixed character styles were imported");
|
||||
let layer_before = app.session.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
assert!(!layer_before.effects.items.is_empty());
|
||||
let f = crate::type_transform::frame(&mut app).unwrap();
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let a = f.quad[2];
|
||||
tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command());
|
||||
tool_event(&mut app, ToolEvent::Up { x: a[0] + 35.0, y: a[1] + 10.0 }, type_command());
|
||||
let after = text(&app, id);
|
||||
assert_eq!((after.text.as_str(), &after.runs, &after.paragraphs), (before.text.as_str(), &before.runs, &before.paragraphs));
|
||||
assert!(after.psd_raw.is_some());
|
||||
let layer_after = app.session.active().unwrap().doc.layer(id).unwrap();
|
||||
assert_eq!(layer_after.name, layer_before.name);
|
||||
assert_eq!(layer_after.effects, layer_before.effects);
|
||||
assert!(app.ui.text_edit.is_some());
|
||||
assert!(app.ui.transform.is_none());
|
||||
super::cancel(&mut app);
|
||||
assert_affine(text(&app, id).transform, before.transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release benchmark for issue #1091: 24 MP, eight text layers, 60 preview updates"]
|
||||
fn temporary_type_transform_large_document_preview() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 6000, "height": 4000})).unwrap();
|
||||
for i in 0..8 {
|
||||
app.run("type.create", json!({"text": "PhotoCraft transform preview", "size": 60, "x": 300, "y": 300 + i * 200})).unwrap();
|
||||
}
|
||||
super::edit_active(&mut app).unwrap();
|
||||
let frame = crate::type_transform::frame(&mut app).unwrap();
|
||||
let a = frame.quad[2];
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let doc = app.session.active().unwrap().doc.id;
|
||||
let index = app.session.active_index().unwrap();
|
||||
let count = app.session.active().unwrap().history.entries().len();
|
||||
let source = (*app.session.active().unwrap().doc).clone();
|
||||
let layer = app.ui.text_edit.as_ref().unwrap().layer;
|
||||
tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command());
|
||||
let mut times = Vec::new();
|
||||
let mut transforms = Vec::new();
|
||||
let mut before = 0;
|
||||
for i in 1..=60 {
|
||||
let started = std::time::Instant::now();
|
||||
tool_event(&mut app, ToolEvent::Move { x: a[0] + f64::from(i) * 2.0, y: a[1] + f64::from(i), pressure: 1.0 }, type_command());
|
||||
let (_, after) = crate::type_transform::display_doc(&mut app, index).unwrap();
|
||||
times.push(started.elapsed().as_secs_f64() * 1000.0);
|
||||
transforms.push(crate::type_transform::current_transform(&app, LayerId(layer)).unwrap());
|
||||
let damage = crate::type_transform::damage(&app, doc, revision, before, after).unwrap();
|
||||
assert!(i64::from(damage.width()) * i64::from(damage.height()) < 2_000_000, "preview only damages the type layer");
|
||||
let (_, same) = crate::type_transform::display_doc(&mut app, index).unwrap();
|
||||
assert_eq!(same, after, "stationary pointer reuses the rendered preview");
|
||||
before = after;
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), count);
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"24 MP / 8 type layers / 60 previews: p50 {:.3} ms, p95 {:.3} ms, max {:.3} ms (render + gesture; composite excluded)",
|
||||
times[29], times[56], times[59]
|
||||
);
|
||||
// Compare the same matrices with the existing live-command path on the same document.
|
||||
let mut commands = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
commands.session.add_document(source, None);
|
||||
let mut command_times = Vec::new();
|
||||
for transform in transforms {
|
||||
let started = std::time::Instant::now();
|
||||
commands.run("type.edit", json!({"layer": layer, "transform": transform.m, "coalesce": "1091-bench"})).unwrap();
|
||||
command_times.push(started.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
command_times.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"existing type.edit per update: p50 {:.3} ms, p95 {:.3} ms, max {:.3} ms (command + render; composite excluded)",
|
||||
command_times[29], command_times[56], command_times[59]
|
||||
);
|
||||
crate::type_transform::cancel_drag(&mut app);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,418 @@
|
|||
//! Ctrl/Cmd while typing: an oriented frame and a temporary affine drag, without ending typing.
|
||||
//! The existing Free Transform geometry is reused with Type's modifier rules (Ctrl exposes the
|
||||
//! frame; it must never distort a corner). Only release dispatches `type.edit`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{CursorIcon, Modifiers, Pos2, Stroke, vec2};
|
||||
use photocraft_doc::{DocId, Document, LayerContent, LayerId, text::TextShape};
|
||||
use photocraft_geom::{Affine, Point, Rect};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, ViewXform};
|
||||
use crate::state::{TransformSession, TypeTransform};
|
||||
use crate::transform_tool::{Gesture, Hit};
|
||||
|
||||
const PREVIEW_BASE: u64 = 1 << 37;
|
||||
const ROTATE_PX: f64 = 28.0;
|
||||
|
||||
/// Cached rendered preview and bounded damage history. Unchanged pointer positions render nothing.
|
||||
pub(crate) struct Preview {
|
||||
document: DocId,
|
||||
revision: u64,
|
||||
transform: Affine,
|
||||
shown: Option<Arc<Document>>,
|
||||
key: u64,
|
||||
original_area: Rect,
|
||||
areas: VecDeque<(u64, Rect)>,
|
||||
}
|
||||
|
||||
pub(crate) fn active(app: &PhotocraftApp) -> bool {
|
||||
app.ui.type_transform.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn visible(app: &PhotocraftApp, mods: Modifiers) -> bool {
|
||||
app.ui.tool.is_type() && app.ui.text_edit.is_some() && (mods.command || active(app))
|
||||
}
|
||||
|
||||
/// Drop pointer capture when its source changed externally. Never apply to another document.
|
||||
pub(crate) fn cancel_stale(app: &mut PhotocraftApp) {
|
||||
let Some(t) = &app.ui.type_transform else { return };
|
||||
let valid = app.ui.tool.is_type()
|
||||
&& app.ui.text_edit.as_ref().is_some_and(|ed| ed.layer == t.frame.layer)
|
||||
&& app.session.active().is_some_and(|st| {
|
||||
st.doc.id.0 == t.document
|
||||
&& st.revision == t.revision
|
||||
&& st.doc.layer(LayerId(t.frame.layer)).is_some_and(|l| !l.locks.all && !l.locks.position && matches!(l.content, LayerContent::Text(_)))
|
||||
});
|
||||
if !valid {
|
||||
let lost_layer = app.session.active().is_none_or(|st| st.doc.id.0 != t.document || st.doc.layer(LayerId(t.frame.layer)).is_none());
|
||||
cancel_drag(app);
|
||||
if lost_layer {
|
||||
app.ui.text_edit = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_drag(app: &mut PhotocraftApp) {
|
||||
app.ui.type_transform = None;
|
||||
if let Some(p) = app.type_transform_preview.as_mut() {
|
||||
// A closed last document has no next canvas frame to release its preview's tiles.
|
||||
p.shown = None;
|
||||
}
|
||||
if let Some(ed) = app.ui.text_edit.as_mut() {
|
||||
ed.dragging = false;
|
||||
ed.resize = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset(app: &mut PhotocraftApp) {
|
||||
cancel_drag(app);
|
||||
app.ui.type_transform_pivot = None;
|
||||
}
|
||||
|
||||
/// Logical text-space bounds, mapped by the layer's existing affine (including rotation/skew).
|
||||
pub(crate) fn frame(app: &mut PhotocraftApp) -> Option<TransformSession> {
|
||||
if let Some(t) = &app.ui.type_transform {
|
||||
return Some(t.frame.clone());
|
||||
}
|
||||
let id = LayerId(app.ui.text_edit.as_ref()?.layer);
|
||||
let st = app.session.active()?;
|
||||
let layer = st.doc.layer(id)?;
|
||||
if layer.locks.all || layer.locks.position || !layer.visible {
|
||||
return None;
|
||||
}
|
||||
let LayerContent::Text(text) = &layer.content else { return None };
|
||||
let shape = text.shape;
|
||||
let size = text.size_pt;
|
||||
let (layout, aff, _) = crate::type_tool::layout(app, id)?;
|
||||
if !aff.m.iter().all(|v| v.is_finite()) || aff.inverse().is_none() {
|
||||
return None;
|
||||
}
|
||||
let rect = match shape {
|
||||
TextShape::Box { x, y, width, height } => [x, y, x + width, y + height].map(f64::from),
|
||||
_ => {
|
||||
let mut r = layout.bounds().unwrap_or([0.0, -size * layout.px_per_pt, 1.0, 0.0]).map(f64::from);
|
||||
// Empty lines still have a caret and can be moved/scaled, without inventing glyphs.
|
||||
r[2] = r[2].max(r[0] + 1.0);
|
||||
r[3] = r[3].max(r[1] + 1.0);
|
||||
r
|
||||
}
|
||||
};
|
||||
if !rect.iter().all(|v| v.is_finite()) || rect[2] <= rect[0] || rect[3] <= rect[1] {
|
||||
return None;
|
||||
}
|
||||
let quad = [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]].map(|q| {
|
||||
let p = aff.apply(Point::new(q[0], q[1]));
|
||||
[p.x, p.y]
|
||||
});
|
||||
if !quad.iter().flatten().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let pivot = app.ui.type_transform_pivot.unwrap_or([(quad[0][0] + quad[2][0]) / 2.0, (quad[0][1] + quad[2][1]) / 2.0]);
|
||||
Some(TransformSession {
|
||||
session: 0,
|
||||
layer: id.0,
|
||||
rect,
|
||||
quad,
|
||||
pivot,
|
||||
interpolation: "bicubic".into(),
|
||||
warp: None,
|
||||
selection: false,
|
||||
target: None,
|
||||
made: None,
|
||||
mode: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool {
|
||||
t.quad.iter().zip(t.quad.iter().cycle().skip(1)).take(4).any(|(a, b)| {
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = dx * dx + dy * dy;
|
||||
let f = if len > 0.0 { ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len } else { 0.0 }.clamp(0.0, 1.0);
|
||||
(p[0] - a[0] - f * dx).hypot(p[1] - a[1] - f * dy) <= tol
|
||||
})
|
||||
}
|
||||
|
||||
fn hit(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> Option<Hit> {
|
||||
let h = crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app));
|
||||
(h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.current_zoom().max(0.01)))).then_some(h)
|
||||
}
|
||||
|
||||
/// Type uses legacy scaling: Shift constrains; Ctrl/Cmd is the mode key, never corner Distort.
|
||||
fn gesture_mods(h: Hit, mut mods: Modifiers) -> Modifiers {
|
||||
mods.command = matches!(h, Hit::Edge(_));
|
||||
mods.ctrl = false;
|
||||
mods.mac_cmd = false;
|
||||
if matches!(h, Hit::Corner(_)) {
|
||||
mods.shift = !mods.shift;
|
||||
}
|
||||
mods
|
||||
}
|
||||
|
||||
fn affine(t: &TypeTransform) -> Option<Affine> {
|
||||
if t.frame.quad == t.gesture.quad0 {
|
||||
return Some(t.original);
|
||||
}
|
||||
let [x0, y0, x1, y1] = t.frame.rect;
|
||||
let [a, b, _, d] = t.frame.quad;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (ax, ay, bx, by) = ((b[0] - a[0]) / w, (b[1] - a[1]) / w, (d[0] - a[0]) / h, (d[1] - a[1]) / h);
|
||||
let out = Affine { m: [ax, ay, bx, by, a[0] - ax * x0 - bx * y0, a[1] - ay * x0 - by * y0] };
|
||||
let xs = t.frame.quad.map(|p| p[0]);
|
||||
let ys = t.frame.quad.map(|p| p[1]);
|
||||
let width = xs.into_iter().fold(f64::MIN, f64::max) - xs.into_iter().fold(f64::MAX, f64::min);
|
||||
let height = ys.into_iter().fold(f64::MIN, f64::max) - ys.into_iter().fold(f64::MAX, f64::min);
|
||||
// Reject collapsed/nonfinite geometry and bound input-sized text raster allocations.
|
||||
(out.m.iter().all(|v| v.is_finite())
|
||||
&& out.determinant().abs() >= 1e-8
|
||||
&& t.frame.quad.iter().flatten().all(|v| v.is_finite() && v.abs() <= 1e7)
|
||||
&& width >= 0.05
|
||||
&& height >= 0.05
|
||||
&& width * height <= 64.0 * 1024.0 * 1024.0)
|
||||
.then_some(out)
|
||||
}
|
||||
|
||||
pub(crate) fn current_transform(app: &PhotocraftApp, id: LayerId) -> Option<Affine> {
|
||||
affine(app.ui.type_transform.as_ref().filter(|t| t.frame.layer == id.0)?)
|
||||
}
|
||||
|
||||
fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) {
|
||||
if !p.iter().all(|v| v.is_finite() && v.abs() <= 1e7) {
|
||||
return;
|
||||
}
|
||||
let Some(t) = app.ui.type_transform.as_mut() else { return };
|
||||
let before = t.frame.clone();
|
||||
if p == t.gesture.start {
|
||||
t.frame.quad = t.gesture.quad0;
|
||||
t.frame.pivot = t.gesture.pivot0;
|
||||
} else {
|
||||
// A handle grabbed off-centre follows the pointer delta, without a jump to its hotspot.
|
||||
let target = match t.gesture.hit {
|
||||
Hit::Corner(i) => t.gesture.quad0.get(i).copied(),
|
||||
Hit::Pivot => Some(t.gesture.pivot0),
|
||||
_ => None,
|
||||
};
|
||||
let p = target.map_or(p, |q| [q[0] + p[0] - t.gesture.start[0], q[1] + p[1] - t.gesture.start[1]]);
|
||||
crate::transform_tool::apply_drag(&mut t.frame, t.gesture, p, gesture_mods(t.gesture.hit, mods));
|
||||
}
|
||||
if affine(t).is_none() {
|
||||
t.frame = before;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish(app: &mut PhotocraftApp) {
|
||||
cancel_stale(app);
|
||||
let Some(t) = app.ui.type_transform.take() else { return };
|
||||
cancel_drag(app);
|
||||
app.ui.type_transform_pivot = Some(t.frame.pivot);
|
||||
let Some(transform) = affine(&t) else { return };
|
||||
// Pivot-only and out-and-back gestures leave no history entry, even in an untouched session.
|
||||
if transform.m.iter().zip(t.original.m).all(|(a, b)| (a - b).abs() < 1e-9) {
|
||||
return;
|
||||
}
|
||||
let Some(ed) = app.ui.text_edit.as_ref() else { return };
|
||||
let params = json!({"layer": ed.layer, "transform": transform.m, "coalesce": ed.session});
|
||||
if app.run("type.edit", params).is_ok()
|
||||
&& let Some(preview) = app.type_transform_preview.take().filter(|p| p.transform == transform)
|
||||
{
|
||||
crate::canvas::shown_as_document(app, preview.document, |key| key == preview.key);
|
||||
}
|
||||
}
|
||||
|
||||
/// First refusal before snapping, caret selection, paragraph resizing or clicking out to commit.
|
||||
pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool {
|
||||
cancel_stale(app);
|
||||
if !app.ui.tool.is_type() || app.ui.text_edit.is_none() {
|
||||
return false;
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { .. } if active(app) => true,
|
||||
ToolEvent::Down { x, y, .. } if mods.command => {
|
||||
let p = [x, y];
|
||||
if !p.iter().all(|v| v.is_finite()) {
|
||||
return true;
|
||||
}
|
||||
let Some(mut frame) = frame(app) else { return true };
|
||||
let Some(h) = hit(app, &frame, p) else { return true };
|
||||
let Some((_, original, _)) = crate::type_tool::layout(app, LayerId(frame.layer)) else { return true };
|
||||
let Some(st) = app.session.active() else { return true };
|
||||
let (document, revision) = (st.doc.id.0, st.revision);
|
||||
frame.session = app.ui.alloc_id();
|
||||
let gesture = Gesture { hit: h, start: p, quad0: frame.quad, pivot0: frame.pivot };
|
||||
app.ui.type_transform = Some(TypeTransform { document, revision, original, frame, gesture });
|
||||
if let Some(ed) = app.ui.text_edit.as_mut() {
|
||||
ed.dragging = false;
|
||||
ed.resize = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } if active(app) => {
|
||||
update(app, [x, y], mods);
|
||||
if matches!(ev, ToolEvent::Up { .. }) {
|
||||
finish(app);
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview via the existing text renderer; all glyphs/styles/effects remain native document data.
|
||||
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
|
||||
cancel_stale(app);
|
||||
if !active(app) {
|
||||
if let Some(p) = app.type_transform_preview.as_mut() {
|
||||
// Retain damage bookkeeping for the return to the document, not its rendered tiles.
|
||||
p.shown = None;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let t = app.ui.type_transform.as_ref()?;
|
||||
let st = app.session.documents().get(idx)?;
|
||||
if st.doc.id.0 != t.document || st.revision != t.revision {
|
||||
return None;
|
||||
}
|
||||
let transform = affine(t)?;
|
||||
if transform == t.original {
|
||||
return None;
|
||||
}
|
||||
let (id, revision, source, layer) = (st.doc.id, st.revision, st.doc.clone(), LayerId(t.frame.layer));
|
||||
if let Some(p) = &app.type_transform_preview
|
||||
&& p.document == id
|
||||
&& p.revision == revision
|
||||
&& p.transform == transform
|
||||
&& let Some(shown) = &p.shown
|
||||
{
|
||||
return Some((shown.clone(), p.key));
|
||||
}
|
||||
let original_area = source.layer(layer)?.surface().map_or(Rect::EMPTY, |s| s.tile_bounds());
|
||||
let mut shown = (*source).clone();
|
||||
let LayerContent::Text(text) = &mut shown.layer_mut(layer)?.content else { return None };
|
||||
text.transform = transform;
|
||||
let started = crate::gpu_canvas::now_ms();
|
||||
photocraft_engine::type_cmds::refresh(&source, text);
|
||||
let area = text.cache.as_ref().map_or(Rect::EMPTY, |s| s.tile_bounds());
|
||||
let shown = Arc::new(photocraft_engine::mode_cmds::display_document(&shown).unwrap_or(shown));
|
||||
let key = PREVIEW_BASE | (app.ui.alloc_id() & (PREVIEW_BASE - 1));
|
||||
let mut areas = app.type_transform_preview.take().filter(|p| p.document == id && p.revision == revision).map(|p| p.areas).unwrap_or_default();
|
||||
if areas.len() >= 128 {
|
||||
areas.pop_front();
|
||||
}
|
||||
areas.push_back((key, area));
|
||||
app.type_transform_preview = Some(Preview { document: id, revision, transform, shown: Some(shown.clone()), key, original_area, areas });
|
||||
app.perf.span("type transform preview", crate::gpu_canvas::now_ms() - started);
|
||||
Some((shown, key))
|
||||
}
|
||||
|
||||
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, before: u64, after: u64) -> Option<Rect> {
|
||||
let p = app.type_transform_preview.as_ref().filter(|p| p.document == doc && p.revision == revision)?;
|
||||
let area = |key| if key == 0 { Some(p.original_area) } else { p.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) };
|
||||
Some(area(before)?.union(&area(after)?))
|
||||
}
|
||||
|
||||
pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, mods: Modifiers) -> bool {
|
||||
if !visible(app, mods) {
|
||||
return false;
|
||||
}
|
||||
let Some(t) = frame(app) else { return false };
|
||||
let tokens = crate::theme::Tokens::get(painter.ctx());
|
||||
let pts = t.quad.map(|p| xf.to_screen(p[0] as f32, p[1] as f32));
|
||||
painter.add(egui::Shape::closed_line(pts.to_vec(), Stroke::new(3.0, tokens.shadow)));
|
||||
painter.add(egui::Shape::closed_line(pts.to_vec(), Stroke::new(1.0, tokens.accent)));
|
||||
let mids = [pts[0].lerp(pts[1], 0.5), pts[1].lerp(pts[2], 0.5), pts[2].lerp(pts[3], 0.5), pts[3].lerp(pts[0], 0.5)];
|
||||
for p in pts.iter().chain(mids.iter()) {
|
||||
let r = egui::Rect::from_center_size(*p, vec2(7.0, 7.0));
|
||||
painter.rect_filled(r, 0.0, tokens.accent_text);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, tokens.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
let c = xf.to_screen(t.pivot[0] as f32, t.pivot[1] as f32);
|
||||
painter.circle_stroke(c, 4.0, Stroke::new(2.0, tokens.shadow));
|
||||
painter.circle_stroke(c, 4.0, Stroke::new(1.0, tokens.accent_text));
|
||||
for axis in [vec2(6.0, 0.0), vec2(0.0, 6.0)] {
|
||||
painter.line_segment([c - axis, c + axis], Stroke::new(1.0, tokens.accent));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn resize_cursor(d: [f64; 2], flip: bool) -> CursorIcon {
|
||||
let angle = d[1].atan2(if flip { -d[0] } else { d[0] }).rem_euclid(std::f64::consts::PI);
|
||||
match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 {
|
||||
0 => CursorIcon::ResizeHorizontal,
|
||||
1 => CursorIcon::ResizeNwSe,
|
||||
2 => CursorIcon::ResizeVertical,
|
||||
_ => CursorIcon::ResizeNeSw,
|
||||
}
|
||||
}
|
||||
|
||||
fn rotation_cursor(ctx: &egui::Context, p: Pos2) {
|
||||
let tokens = crate::theme::Tokens::get(ctx);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("type-rotate-cursor")));
|
||||
let first = -std::f32::consts::FRAC_PI_4;
|
||||
let last = std::f32::consts::PI + std::f32::consts::FRAC_PI_4;
|
||||
let points: Vec<Pos2> = (0..=24)
|
||||
.map(|i| {
|
||||
let angle = first + (last - first) * i as f32 / 24.0;
|
||||
p + vec2(angle.cos(), angle.sin()) * 7.0
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(points.clone(), Stroke::new(3.0, tokens.shadow)));
|
||||
painter.add(egui::Shape::line(points, Stroke::new(1.25, tokens.accent_text)));
|
||||
for (angle, sign) in [(first, -1.0), (last, 1.0)] {
|
||||
let tip = p + vec2(angle.cos(), angle.sin()) * 7.0;
|
||||
let tangent = vec2(-angle.sin(), angle.cos()) * sign;
|
||||
let normal = vec2(angle.cos(), angle.sin());
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
vec![tip + tangent * 3.0, tip - tangent * 3.0 + normal * 3.0, tip - tangent * 3.0 - normal * 3.0],
|
||||
tokens.accent_text,
|
||||
Stroke::new(1.0, tokens.shadow),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor and press share the exact same hit test, including a bounded outside rotation ring.
|
||||
pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2, mods: Modifiers) -> Option<CursorIcon> {
|
||||
if !visible(app, mods) {
|
||||
let id = LayerId(app.ui.text_edit.as_ref()?.layer);
|
||||
let q = xf.to_doc(p);
|
||||
let handle = crate::type_tool::box_handle_at(app, id, q[0], q[1])?;
|
||||
let t = frame(app)?;
|
||||
let direction = if handle < 4 {
|
||||
let a = *t.quad.get(usize::from(handle))?;
|
||||
let b = *t.quad.get((usize::from(handle) + 2) % 4)?;
|
||||
[a[0] - b[0], a[1] - b[1]]
|
||||
} else {
|
||||
let b = if handle.is_multiple_of(2) { t.quad[3] } else { t.quad[1] };
|
||||
[b[0] - t.quad[0][0], b[1] - t.quad[0][1]]
|
||||
};
|
||||
return Some(resize_cursor(direction, xf.flip));
|
||||
}
|
||||
let Some(t) = frame(app) else { return Some(CursorIcon::Default) };
|
||||
let h = app.ui.type_transform.as_ref().map(|t| t.gesture.hit).or_else(|| hit(app, &t, xf.to_doc(p)));
|
||||
Some(match h {
|
||||
Some(Hit::Corner(i)) => {
|
||||
let q = *t.quad.get(i)?;
|
||||
let opposite = *t.quad.get((i + 2) % 4)?;
|
||||
resize_cursor([q[0] - opposite[0], q[1] - opposite[1]], xf.flip)
|
||||
}
|
||||
Some(Hit::Edge(_)) => {
|
||||
// Original arrowhead indicator, above the canvas, without a proprietary cursor asset.
|
||||
let tokens = crate::theme::Tokens::get(ctx);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("type-skew-cursor")));
|
||||
painter.add(egui::Shape::convex_polygon(vec![p, p + vec2(14.0, 6.0), p + vec2(6.0, 14.0)], tokens.accent_text, Stroke::new(1.0, tokens.shadow)));
|
||||
CursorIcon::None
|
||||
}
|
||||
Some(Hit::Outside) => {
|
||||
rotation_cursor(ctx, p);
|
||||
CursorIcon::None
|
||||
}
|
||||
Some(Hit::Pivot) => CursorIcon::Crosshair,
|
||||
Some(Hit::Inside) => CursorIcon::Move,
|
||||
None => CursorIcon::Default,
|
||||
})
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
|||
- `engine.commands`: list commands with enablement
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
|
||||
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
|
||||
- `ui.menu.invoke {id, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them.
|
||||
|
|
|
|||
|
|
@ -45,6 +45,22 @@ curl -sfL -o assets/icons/<name>.svg https://raw.githubusercontent.com/lucide-ic
|
|||
| 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 and bucket 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. |
|
||||
|
||||
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
|
||||
the reference point), a middle handle to skew, or just outside the frame to rotate (Shift snaps
|
||||
to 15°). Drag the reference point to move the rotation centre. These Type gestures follow
|
||||
[Adobe's type guide](https://helpx.adobe.com/photoshop/using/creating-type.html), independently
|
||||
of the Free Transform preference: Ctrl/Cmd never distorts a Type corner.
|
||||
|
||||
Without Ctrl/Cmd, paragraph handles resize the container and reflow its text. Temporary
|
||||
transforms keep its logical dimensions and all character/paragraph styles. A drag begun with
|
||||
Ctrl/Cmd owns the pointer until button release, even if the modifier is released first. Its
|
||||
preview does not change the document; release applies one coalesced `type.edit`, and typing
|
||||
continues in the same layer. Escape during a drag cancels that preview; focus loss or changes
|
||||
to its source also discard it. Holding the modifier, moving the reference point, or returning
|
||||
the pointer to its start creates no history entry. Ctrl/Cmd+T while typing still toggles the
|
||||
Character panel.
|
||||
|
||||
Where the font lacks a symbol (e.g. ∠ ↦ ▔), draw it with the painter or use a Lucide icon; never ship
|
||||
missing-glyph boxes. Check every new panel with the offscreen snapshot tool (`docs/development.md`).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue