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:
Marco Arnulfo authored and GitHub committed 2026-10-18 17:23:16 -04:00
1 parent 0eb0387d53
commit 8736098d38
6 files changed
+307 -2

No files matched your search

+228
View File
@@ -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();
+1 -1
View File
@@ -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)
+20 -1
View File
@@ -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))
+7
View File
@@ -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 { .. }) {
+50
View File
@@ -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.
+1
View File
@@ -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)