mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 02:10:49 -05:00
Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (#7473)
* Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (part of #7325) With Image > Adjustments > Curves open, a Cmd-click (Ctrl-click off the Mac) on the image adds a point to the channel shown in the dialog, at the pixel's tone and at the curve's current output there; Shift adds one point on each of Red, Green and Blue. Only the press adds, an armed eyedropper still wins, and the control channel's ui.pointer does the same with command: true. * Curves: Cmd/Ctrl-click adds the point with the Hand tool too; Space and middle button still pan The Hand tool took the Cmd-click press for a pan, so no point was added; the press now counts before the Hand. On the control channel, ui.pointer with space or the middle button pans as the mouse does instead of adding a point. * Curves: the Cmd/Ctrl-click point's comment says where the curve can still change
This commit is contained in:
1 parent
0eb0387d53
commit
8736098d38
6 files changed
+307
-2
No files matched your search
@@ -79,6 +79,101 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the top modal is the Curves dialog.
|
||||
pub fn curves_on_top(app: &PhotocraftApp) -> bool {
|
||||
app.ui.dialogs.last().is_some_and(|d| d.fields.get("__adjust").and_then(Value::as_str) == Some("curves"))
|
||||
}
|
||||
|
||||
/// ⌘/Ctrl-click on the image with Curves on top (#7325): a point on the curve shown in the dialog
|
||||
/// at the clicked pixel's tone, at the output the curve gives there rounded to a whole level, so the
|
||||
/// image barely changes (where the curve is clipped at 0 or 255 the point re-shapes it, as a click on
|
||||
/// the graph there does); with `every_channel` (Shift) one on each colour channel instead, at that
|
||||
/// channel's value.
|
||||
/// The pixel comes from the same merged composite as the eyedroppers. Returns true when Curves is
|
||||
/// on top: the click is the dialog's even when nothing is added (pencil curve, Lab or CMYK, a mask
|
||||
/// or saved channel target, outside the image, a full curve).
|
||||
pub fn add_point_at(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, every_channel: bool) -> bool {
|
||||
if !curves_on_top(app) {
|
||||
return false;
|
||||
}
|
||||
let Some((id, mut values)) = app.ui.dialogs.last().map(|d| (d.id, crate::filter_dialog::params_of(&d.fields))) else { return false };
|
||||
if values.get("pencil").and_then(Value::as_bool) == Some(true) {
|
||||
return true;
|
||||
}
|
||||
let Some(mode) = app.session.active().map(|s| s.doc.mode) else { return true };
|
||||
if !matches!(mode, photocraft_doc::ColorMode::Rgb | photocraft_doc::ColorMode::Grayscale) {
|
||||
app.ui.status = tl!("Unavailable in this color mode").into();
|
||||
return true;
|
||||
}
|
||||
// A mask, saved channel or Quick Mask holds its own gray levels, not the composite's.
|
||||
let isolated = isolated(app);
|
||||
if isolated.as_ref().is_some_and(|c| c.gray.is_some()) {
|
||||
return true;
|
||||
}
|
||||
let Some(sample) = crate::canvas::composite_color(app, x, y) else { return true };
|
||||
let gray = adjust_editors::is_gray(mode);
|
||||
let (shown, shown_key) = match &isolated {
|
||||
Some(c) => (0, c.key),
|
||||
None => {
|
||||
let ch: usize = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves-ch"))).unwrap_or(0);
|
||||
let chans = adjust_editors::tone_channels(adjust_editors::space_of(&values), gray);
|
||||
(ch, chans.get(ch).map_or("", |c| c.key))
|
||||
}
|
||||
};
|
||||
let keys: &[&str] = if every_channel && !gray && isolated.is_none() { &["red", "green", "blue"] } else { &[shown_key] };
|
||||
let mut selected = None;
|
||||
let mut changed = false;
|
||||
for &key in keys {
|
||||
let level = match key {
|
||||
"red" => sample[0],
|
||||
"green" => sample[1],
|
||||
"blue" => sample[2],
|
||||
// The composite's tone: Gray's mean, as the Grayscale eyedroppers read it, and RGB's
|
||||
// Rec. 709 luma, as the Neutral Gray eyedropper does (`adjust_params::rgb_luma`).
|
||||
_ if gray => (sample[0] + sample[1] + sample[2]) / 3.0,
|
||||
_ => sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722,
|
||||
};
|
||||
let input = (level.clamp(0.0, 1.0) * 255.0).round();
|
||||
let field = if key.is_empty() { "points" } else { key };
|
||||
let mut pts = adjust_editors::read_curve(&values, field);
|
||||
let curve: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect();
|
||||
let lut = photocraft_compose::adjust::curve_lut(&curve);
|
||||
let at = (input / 255.0 * lut.len().saturating_sub(1) as f32).round() as usize;
|
||||
let Some(output) = lut.get(at).map(|o| (o * 255.0).round()) else { continue };
|
||||
let before = pts.len();
|
||||
let Some(i) = crate::point_curve::insert(&mut pts, [input, output]) else { continue };
|
||||
if pts.len() > before {
|
||||
// Whole levels, as the editor writes a point clicked on the graph.
|
||||
values[field] = json!(pts.iter().map(|q| [q[0].round(), q[1].round()]).collect::<Vec<_>>());
|
||||
changed = true;
|
||||
}
|
||||
if key == shown_key {
|
||||
selected = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = selected {
|
||||
let state = dialog_mem("curves").with("curves");
|
||||
ctx.data_mut(|d| {
|
||||
let mut st: crate::state::CurvesEditorState = d.get_temp(state).unwrap_or_default();
|
||||
st.channel = shown;
|
||||
st.gesture = crate::state::PointCurveState { selected: Some(i), drag: None };
|
||||
d.insert_temp(state, st);
|
||||
});
|
||||
}
|
||||
if changed
|
||||
&& let Some(dialog) = app.ui.dialog_mut(id)
|
||||
&& let Value::Object(v) = values
|
||||
{
|
||||
dialog.fields.retain(|k, _| k.starts_with("__"));
|
||||
dialog.fields.extend(v);
|
||||
if picker(dialog.fields.get(CURVE_PICKER)).is_some() {
|
||||
let base = crate::filter_dialog::params_of(&dialog.fields);
|
||||
dialog.fields.insert(CURVE_PICKER_BASE.into(), base);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, tooltip: &str, color: egui::Color32) -> egui::Response {
|
||||
let response = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, "pipette", 28.0, selected, tooltip)).inner;
|
||||
let tokens = crate::theme::Tokens::get(ui.ctx());
|
||||
@@ -767,6 +862,139 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn pointer_down(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, modifiers: Value) {
|
||||
let (request, _receiver) =
|
||||
crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": x, "y": y}], "modifiers": modifiers}));
|
||||
let _ = crate::control::handle(app, ctx, &request);
|
||||
}
|
||||
|
||||
/// The Curves curve `pts` (0-255 levels) as a 256-entry 0-255 map.
|
||||
fn curve_levels(pts: &[[f64; 2]]) -> Vec<f32> {
|
||||
let cp: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] as f32 / 255.0, output: q[1] as f32 / 255.0 }).collect();
|
||||
let lut = photocraft_compose::adjust::curve_lut(&cp);
|
||||
(0..256).map(|k| lut[(k as f32 / 255.0 * (lut.len() - 1) as f32).round() as usize] * 255.0).collect()
|
||||
}
|
||||
|
||||
/// #7325: ⌘/Ctrl-click on the image puts a point on the shown curve at the pixel's tone
|
||||
/// (#b04020: Rec. 709 luma 85.5 → level 86) and at the curve's own output there, so the
|
||||
/// image does not change; the new point is the selected one, and the document waits for OK.
|
||||
#[test]
|
||||
fn command_click_on_the_image_adds_a_point_on_the_composite_curve() {
|
||||
let mut app = app_with_image();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
let id = open(&mut app, "image.adjustments.curves").unwrap();
|
||||
let base = [[0.0, 0.0], [128.0, 200.0], [255.0, 255.0]];
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(base));
|
||||
let ctx = egui::Context::default();
|
||||
let sample = crate::canvas::composite_color(&mut app, 8.0, 8.0).unwrap();
|
||||
let level = ((sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722) * 255.0).round();
|
||||
assert_eq!(level, 86.0);
|
||||
let output = f64::from(curve_levels(&base)[86].round());
|
||||
assert_ne!(output, 86.0, "the base curve is not the identity at the sampled level");
|
||||
|
||||
pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true}));
|
||||
let points = channel_points(&app, "points");
|
||||
assert_eq!(points, vec![base[0], [86.0, output], base[1], base[2]]);
|
||||
let (before, after) = (curve_levels(&base), curve_levels(&points));
|
||||
assert!(before.iter().zip(&after).all(|(a, b)| (a - b).abs() <= 0.5), "the curve keeps its shape:\n{before:?}\n{after:?}");
|
||||
let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap();
|
||||
assert_eq!((st.channel, st.gesture.selected), (0, Some(1)), "the new point is selected");
|
||||
for key in ["red", "green", "blue"] {
|
||||
assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched");
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1);
|
||||
}
|
||||
|
||||
/// #7325: Shift+⌘-click puts a point on each colour channel at that channel's own value
|
||||
/// (176, 64, 32), and leaves the composite curve alone.
|
||||
#[test]
|
||||
fn shift_command_click_adds_a_point_on_each_colour_channel() {
|
||||
let mut app = app_with_image();
|
||||
open(&mut app, "image.adjustments.curves").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let composite = channel_points(&app, "points");
|
||||
pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true, "shift": true}));
|
||||
for (key, level) in [("red", 176.0), ("green", 64.0), ("blue", 32.0)] {
|
||||
assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [level, level], [255.0, 255.0]], "{key}");
|
||||
}
|
||||
assert_eq!(channel_points(&app, "points"), composite);
|
||||
}
|
||||
|
||||
/// #7325: with Red shown in the Channel popup the point goes on the red curve, at red's value.
|
||||
#[test]
|
||||
fn command_click_adds_the_point_on_the_channel_shown_in_the_dialog() {
|
||||
let mut app = app_with_image();
|
||||
open(&mut app, "image.adjustments.curves").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.data_mut(|d| d.insert_temp(dialog_mem("curves").with("curves-ch"), 1usize));
|
||||
let composite = channel_points(&app, "points");
|
||||
pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true}));
|
||||
assert_eq!(channel_points(&app, "red"), vec![[0.0, 0.0], [176.0, 176.0], [255.0, 255.0]]);
|
||||
for key in ["green", "blue"] {
|
||||
assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched");
|
||||
}
|
||||
assert_eq!(channel_points(&app, "points"), composite);
|
||||
let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap();
|
||||
assert_eq!((st.channel, st.gesture.selected), (1, Some(1)));
|
||||
}
|
||||
|
||||
/// #7325: a plain click, a full curve and an armed eyedropper add nothing.
|
||||
#[test]
|
||||
fn command_click_adds_nothing_without_the_modifier_on_a_full_curve_or_with_an_eyedropper() {
|
||||
let mut app = app_with_image();
|
||||
let id = open(&mut app, "image.adjustments.curves").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let before = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields);
|
||||
pointer_down(&mut app, &ctx, 8.0, 8.0, json!({}));
|
||||
assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "no modifier, no point");
|
||||
// Space and the middle button pan, as with the mouse.
|
||||
for extra in [json!({"modifiers": {"command": true, "space": true}}), json!({"command": true, "button": "middle"})] {
|
||||
let mut params = json!({"events": [{"kind": "down", "x": 8, "y": 8}]});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
let (request, _receiver) = crate::control::ControlRequest::new("ui.pointer", params);
|
||||
let _ = crate::control::handle(&mut app, &ctx, &request);
|
||||
assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "{extra}");
|
||||
}
|
||||
|
||||
// Sixteen points (the most a curve holds), none within reach of level 86.
|
||||
let full: Vec<[f64; 2]> =
|
||||
[0, 10, 20, 30, 40, 50, 60, 70, 80, 100, 120, 140, 160, 180, 200, 255].iter().map(|&i| [f64::from(i), f64::from(i)]).collect();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(full));
|
||||
pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true}));
|
||||
assert_eq!(channel_points(&app, "points"), full, "a full curve takes no more points and moves none");
|
||||
|
||||
let mut app = app_with_image();
|
||||
let id = open(&mut app, "image.adjustments.curves").unwrap();
|
||||
arm(&mut app, id, "black");
|
||||
pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true}));
|
||||
assert_eq!(channel_points(&app, "points").len(), 2, "the armed eyedropper samples instead");
|
||||
assert_eq!(channel_points(&app, "red")[0], [176.0, 0.0]);
|
||||
}
|
||||
|
||||
/// #7325: Lab and CMYK curves and a targeted mask are left to a later change: nothing is added.
|
||||
#[test]
|
||||
fn command_click_adds_nothing_in_lab_or_on_a_targeted_mask() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut lab = app_in_mode("lab");
|
||||
lab.run("edit.fill", json!({"color": "#b04020"})).unwrap();
|
||||
let id = open(&mut lab, "image.adjustments.curves").unwrap();
|
||||
let before = crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields);
|
||||
assert!(add_point_at(&mut lab, &ctx, 4.0, 4.0, false));
|
||||
assert_eq!(crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields), before);
|
||||
|
||||
let mut masked = app_with_image();
|
||||
masked.run("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
masked.run("channel.target", json!({"channel": "mask"})).unwrap();
|
||||
let id = open(&mut masked, "image.adjustments.curves").unwrap();
|
||||
let before = crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields);
|
||||
assert!(add_point_at(&mut masked, &ctx, 8.0, 8.0, false));
|
||||
assert_eq!(crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_pointer_drives_an_armed_curves_picker() {
|
||||
let mut app = app_with_image();
|
||||
|
||||
@@ -362,7 +362,7 @@ fn curve_graph(full: Rect, side: f32) -> Rect {
|
||||
Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING))
|
||||
}
|
||||
|
||||
fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> {
|
||||
pub(crate) fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> {
|
||||
let pts: Vec<[f32; 2]> = v
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
|
||||
@@ -3438,7 +3438,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701).
|
||||
let shadow_drag =
|
||||
!picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields));
|
||||
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag;
|
||||
// ⌘/Ctrl-click with Curves open: a point at the pixel's tone (#7325), whatever the tool. The
|
||||
// press's own modifiers decide, and only the press adds: a drag would add one per frame.
|
||||
let curve_point_press = (primary && !middle && crate::adjust_dialog::curves_on_top(app))
|
||||
.then(|| {
|
||||
ctx.input(|i| {
|
||||
i.events.iter().rev().find_map(|e| match e {
|
||||
egui::Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
.map(|m| crate::workspace_ui::sticky_mods(app, m))
|
||||
.filter(|m| m.command);
|
||||
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none();
|
||||
if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) {
|
||||
let d = xf.unmap_vec(d) / (view.zoom / ppp);
|
||||
view.center[0] -= d.x;
|
||||
@@ -3483,6 +3497,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
{
|
||||
let press = crate::dialogs::free_press(&ctx, rect).map(|q| xf.to_doc(q));
|
||||
crate::select_mask_view::canvas_brush(app, &painter, p, press, xf.zoom_x());
|
||||
} else if let Some(mods) = curve_point_press
|
||||
&& let Some(p) = crate::dialogs::free_press(&ctx, rect)
|
||||
{
|
||||
let d = xf.to_doc(p);
|
||||
crate::adjust_dialog::add_point_at(app, &ctx, d[0], d[1], mods.shift);
|
||||
} else if primary
|
||||
&& !middle
|
||||
&& let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect).filter(|p| img_rect.contains(*p))
|
||||
|
||||
@@ -1043,6 +1043,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ⌘/Ctrl-click with Curves open adds a point at the pixel's tone, on press only (#7325).
|
||||
if !secondary && !space && button != "middle" && mods.command && crate::adjust_dialog::curves_on_top(app) {
|
||||
if matches!(ev, ToolEvent::Down { .. }) {
|
||||
crate::adjust_dialog::add_point_at(app, ctx, x, y, mods.shift);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A Properties Levels/Curves eyedropper owns the primary button the same way.
|
||||
if app.ui.dialogs.is_empty() && !matches!(s("button"), Some("secondary" | "right")) && crate::layer_picker::armed(app).is_some() {
|
||||
if matches!(ev, ToolEvent::Down { .. }) {
|
||||
|
||||
@@ -317,6 +317,56 @@ fn curves_picker_samples_document_coordinates_through_the_view_transform() {
|
||||
assert!((red - 32.0).abs() < 0.6, "sampled document-right blue patch through the flipped 400% view: {red}");
|
||||
}
|
||||
|
||||
/// #7325: ⌘/Ctrl-click on the image with Curves open adds a point at the pixel's tone. The
|
||||
/// modifier is the press's own, a drag onto another tone adds nothing more, and the Hand tool
|
||||
/// does not take the press for a pan.
|
||||
#[test]
|
||||
fn command_click_under_curves_adds_one_point_per_press_not_per_frame() {
|
||||
// The Hand tool too: the press is the dialog's, not the start of a pan.
|
||||
for hand in [false, true] {
|
||||
let mut h = harness();
|
||||
if hand {
|
||||
h.state_mut().ui.tool = crate::state::Tool::Hand;
|
||||
}
|
||||
h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 80, "height": 300})).unwrap();
|
||||
h.state_mut().run("edit.fill", json!({"color": "#804020"})).unwrap();
|
||||
h.state_mut().run("select.rect", json!({"x": 80, "y": 0, "width": 320, "height": 300})).unwrap();
|
||||
h.state_mut().run("edit.fill", json!({"color": "#20a0e0"})).unwrap();
|
||||
h.state_mut().run("select.deselect", json!({})).unwrap();
|
||||
let view = &mut h.state_mut().ui.views[0];
|
||||
(view.zoom, view.center, view.fit_pending) = (4.0, [200.0, 150.0], false);
|
||||
let dialog = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.curves").unwrap();
|
||||
h.run_steps(3);
|
||||
let points = |h: &Harness<'static, PhotocraftApp>| {
|
||||
let d = h.state().ui.dialogs.iter().find(|d| d.id == dialog).unwrap();
|
||||
d.fields.get("points").and_then(serde_json::Value::as_array).map_or(0, Vec::len)
|
||||
};
|
||||
let canvas = h.state().last_canvas_rect;
|
||||
// Document x 65 (the brown strip) and 100 (the blue), both left of the centred dialog.
|
||||
let (brown, blue) = (pos2(canvas.left() + 60.0, canvas.center().y), pos2(canvas.left() + 200.0, canvas.center().y));
|
||||
h.hover_at(brown);
|
||||
h.run_steps(1);
|
||||
press(&mut h, brown, true);
|
||||
press(&mut h, brown, false);
|
||||
assert_eq!(points(&h), 2, "a plain click adds no point (hand: {hand})");
|
||||
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::COMMAND };
|
||||
// ⌘ stays held through the drag, so only the press can tell the frames apart.
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
h.event(button(brown, true));
|
||||
h.run_steps(1);
|
||||
assert_eq!(points(&h), 3, "the press adds the point (hand: {hand})");
|
||||
for t in [0.25, 0.5, 0.75, 1.0] {
|
||||
h.event(egui::Event::PointerMoved(brown + (blue - brown) * t));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(button(blue, false));
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
assert_eq!(points(&h), 3, "dragging onto another tone adds nothing more (hand: {hand})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type tool (#206): Alt+←/→ at a collapsed caret kerns the pair before it by 20/1000 em (100
|
||||
/// with ⌘/Ctrl), one history step per press; ⌘/Ctrl+←/→ moves by word; Alt+Shift+→ extends
|
||||
/// the selection by a word.
|
||||
|
||||
@@ -75,6 +75,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point.
|
||||
- Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape.
|
||||
- Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted.
|
||||
- Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it.
|
||||
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988)
|
||||
|
||||
Reference in new issue
Block a user