mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-31 21:37:08 -06:00
Merge approved PhotoCraft PR #4985: feat(ui): add Shift+right-click brush mode picker
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
//! Brush-only Shift+right-click Mode picker. Shares the options bar's painting modes.
|
||||
|
||||
use egui::{Context, Id};
|
||||
use photocraft_doc::BlendMode;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{PhotocraftApp, state::Tool, theme::Tokens};
|
||||
|
||||
/// The options bar and canvas picker use the same order, including painting-only Clear.
|
||||
pub(crate) fn choices() -> Vec<(Option<BlendMode>, &'static str)> {
|
||||
let mut choices: Vec<_> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect();
|
||||
let at = choices.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(choices.len(), |i| i + 1);
|
||||
choices.insert(at, (None, "Clear"));
|
||||
choices
|
||||
}
|
||||
|
||||
pub(crate) fn is_gesture(tool: Tool, mods: egui::Modifiers) -> bool {
|
||||
tool == Tool::Brush && mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd
|
||||
}
|
||||
|
||||
/// The modifiers that decide a right-button gesture: the ones held when the button went down.
|
||||
/// egui reports `secondary_clicked` on release, so letting go of Shift a moment before the button
|
||||
/// must not turn Shift+right-click into a plain right-click (an erase dab, with the Erase
|
||||
/// preference). The control path decides on the press too.
|
||||
pub(crate) fn gesture_mods(ctx: &Context, mods: egui::Modifiers) -> egui::Modifiers {
|
||||
let key = menu_id().with("press-mods");
|
||||
let (pressed, down, released) = ctx.input(|i| {
|
||||
(i.pointer.button_pressed(egui::PointerButton::Secondary), i.pointer.secondary_down(), i.pointer.button_released(egui::PointerButton::Secondary))
|
||||
});
|
||||
if pressed {
|
||||
ctx.data_mut(|d| d.insert_temp(key, mods));
|
||||
return mods;
|
||||
}
|
||||
if !down && !released {
|
||||
ctx.data_mut(|d| d.remove::<egui::Modifiers>(key));
|
||||
return mods;
|
||||
}
|
||||
let held = ctx.data(|d| d.get_temp::<egui::Modifiers>(key));
|
||||
if released {
|
||||
ctx.data_mut(|d| d.remove::<egui::Modifiers>(key));
|
||||
}
|
||||
held.unwrap_or(mods)
|
||||
}
|
||||
|
||||
pub(crate) fn menu_id() -> Id {
|
||||
Id::unique("canvas-brush-mode-menu")
|
||||
}
|
||||
|
||||
pub(crate) fn open(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) {
|
||||
if !pos.iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
app.ui.brush_picker = None;
|
||||
app.ui.layer_menu = None;
|
||||
app.ui.canvas_tool_menu = None;
|
||||
app.ui.brush_mode_menu = Some(pos);
|
||||
crate::canvas_popup::mark_open(ctx, menu_id());
|
||||
}
|
||||
|
||||
/// A press outside dismisses the popup, and consumes that entire canvas gesture through Up.
|
||||
pub(crate) fn blocks_canvas(app: &PhotocraftApp, ctx: &Context) -> bool {
|
||||
let key = menu_id().with("dismiss-pointer");
|
||||
if ctx.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) {
|
||||
if !ctx.input(|i| i.pointer.any_down()) {
|
||||
ctx.data_mut(|d| d.remove::<bool>(key));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if app.ui.brush_mode_menu.is_some() {
|
||||
if let Some(rect) = ctx.memory(|m| m.area_rect(menu_id()))
|
||||
&& crate::canvas_popup::outside_pressed(ctx, menu_id(), rect)
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(key, true));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Document-coordinate control pointers dismiss; menu rows are chosen with screen-space ui.click.
|
||||
pub(crate) fn blocks_control(app: &mut PhotocraftApp, ctx: &Context, ev: crate::canvas::ToolEvent) -> bool {
|
||||
use crate::canvas::ToolEvent;
|
||||
let key = menu_id().with("control-dismiss");
|
||||
let blocked = ctx.data(|d| d.get_temp::<bool>(key)).unwrap_or(false);
|
||||
if blocked {
|
||||
if matches!(ev, ToolEvent::Up { .. }) {
|
||||
ctx.data_mut(|d| d.remove::<bool>(key));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if app.ui.brush_mode_menu.is_some() {
|
||||
if app.ui.tool != Tool::Brush {
|
||||
app.ui.brush_mode_menu = None;
|
||||
return false;
|
||||
}
|
||||
if matches!(ev, ToolEvent::Down { .. }) {
|
||||
app.ui.brush_mode_menu = None;
|
||||
ctx.data_mut(|d| d.insert_temp(key, true));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn choose(app: &mut PhotocraftApp, mode: Option<BlendMode>) {
|
||||
// Clear is the existing painting-only UI flag; keep the underlying blend mode just as the
|
||||
// options bar does. Journal the choice through the same brush command.
|
||||
let selected = mode.unwrap_or(app.session.tools.brush.mode);
|
||||
match app.run("tools.setBrush", json!({"brush": {"mode": selected}})) {
|
||||
Ok(_) => app.ui.tool_options.brush_clear = mode.is_none(),
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
app.ui.brush_mode_menu = None;
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
let Some([x, y]) = app.ui.brush_mode_menu else { return };
|
||||
if app.ui.tool != Tool::Brush || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.brush_mode_menu = None;
|
||||
// Escape also consumes the release of a right-button gesture still in flight.
|
||||
if app.ui.tool == Tool::Brush && ctx.input(|i| i.pointer.secondary_down()) {
|
||||
ctx.data_mut(|d| d.insert_temp(menu_id().with("dismiss-pointer"), true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let choices = choices();
|
||||
let current = if app.ui.tool_options.brush_clear { None } else { Some(app.session.tools.brush.mode) };
|
||||
let screen = crate::work_area::visible_rect(ctx);
|
||||
let id = menu_id();
|
||||
let margin = ctx.global_style().spacing.menu_margin.sum() + egui::Vec2::splat(2.0);
|
||||
let height = (choices.len() as f32 * 22.0).min((screen.height() - margin.y).max(0.0));
|
||||
let width = (screen.width() - margin.x).clamp(0.0, 190.0);
|
||||
let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(width + margin.x, height + margin.y), |r| r.size());
|
||||
let pos = egui::pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top()));
|
||||
let mut picked = None;
|
||||
let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).constrain_to(screen).show(ctx, |ui| {
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Frame::menu(ui.style()).fill(t.card).stroke(egui::Stroke::new(1.0, t.card_border)).show(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
crate::widgets::menu_scroll(ui, |ui| {
|
||||
for (mode, label) in choices {
|
||||
let selected = mode == current;
|
||||
let color = if selected && t.bevel { t.accent_text } else { t.text };
|
||||
let row = egui::Button::selectable(selected, egui::RichText::new(tl!(label)).color(color)).min_size(egui::vec2(width, 22.0));
|
||||
if ui.add(row).clicked() {
|
||||
picked = Some(mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(mode) = picked {
|
||||
choose(app, mode);
|
||||
} else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) {
|
||||
app.ui.brush_mode_menu = None;
|
||||
}
|
||||
}
|
||||
@@ -980,6 +980,7 @@ fn handle_canvas_tool_stroke(
|
||||
&& app.drag.is_none()
|
||||
&& response.is_pointer_button_down_on()
|
||||
&& ui.input(|i| i.pointer.primary_pressed())
|
||||
&& !crate::brush_mode_menu::blocks_canvas(app, ctx)
|
||||
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p))
|
||||
{
|
||||
let d = xf.to_doc(p);
|
||||
|
||||
@@ -114,6 +114,10 @@ pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool {
|
||||
/// brush with Alt held (#297), erases (Erase preference) or opens the Brush Preset picker. Arms
|
||||
/// `secondary_erase` or `brush_resize_armed` for this frame's `Down`.
|
||||
pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) -> Buttons {
|
||||
if crate::brush_mode_menu::blocks_canvas(app, &response.ctx) {
|
||||
app.secondary_erase = false;
|
||||
return Buttons::default();
|
||||
}
|
||||
let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f());
|
||||
let (mods, mut right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down()));
|
||||
right_down |= physical_right;
|
||||
@@ -137,6 +141,22 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool)
|
||||
app.brush_resize_armed = resize_start || (physical_right && resizing);
|
||||
// ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307).
|
||||
let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods);
|
||||
// Decided by the modifiers held when the button went down, not by those of the release frame that reports the click.
|
||||
let mode_menu = crate::brush_mode_menu::is_gesture(tool, crate::workspace_ui::sticky_mods(app, crate::brush_mode_menu::gesture_mods(&response.ctx, mods)));
|
||||
if mode_menu && !resizing && app.drag.is_none() {
|
||||
app.secondary_erase = false;
|
||||
if response.secondary_clicked()
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
crate::brush_mode_menu::open(app, &response.ctx, [p.x, p.y]);
|
||||
}
|
||||
return Buttons {
|
||||
started: response.drag_started_by(PointerButton::Primary),
|
||||
dragged: response.dragged_by(PointerButton::Primary),
|
||||
stopped: response.drag_stopped_by(PointerButton::Primary),
|
||||
clicked: response.clicked(),
|
||||
};
|
||||
}
|
||||
let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu;
|
||||
let right_stroke = erase && app.drag.is_some();
|
||||
let right_start = erase && right_started;
|
||||
@@ -170,6 +190,13 @@ pub fn pointer_secondary(app: &mut PhotocraftApp, ctx: &egui::Context, down: boo
|
||||
app.brush_resize_armed = down && app.drag.is_none();
|
||||
return true;
|
||||
}
|
||||
if crate::brush_mode_menu::is_gesture(tool, mods) && app.drag.is_none() && app.brush_resize.is_none() {
|
||||
app.secondary_erase = false;
|
||||
if down {
|
||||
crate::brush_mode_menu::open(app, ctx, at);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if right_erases(app, tool) {
|
||||
app.secondary_erase = down;
|
||||
return true;
|
||||
|
||||
@@ -65,6 +65,179 @@ fn strokes(h: &Harness<'static, PhotocraftApp>) -> Vec<serde_json::Value> {
|
||||
h.state().session.journal.iter().filter(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).collect()
|
||||
}
|
||||
|
||||
fn mode_right_click(h: &mut Harness<'static, PhotocraftApp>, mods: Modifiers) {
|
||||
let p = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::ModifiersChanged(mods));
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: mods });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: mods });
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_right_mode_picker_selects_journals_and_clear_erases() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = harness(Some("erase"));
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
let journal = h.state().session.journal.len();
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
assert!(h.state().ui.brush_mode_menu.is_some());
|
||||
assert!(h.state().ui.brush_picker.is_none() && h.state().drag.is_none());
|
||||
assert_eq!(h.state().session.journal.len(), journal);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
h.get_by_label("Multiply").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Multiply);
|
||||
assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Multiply"}})));
|
||||
let (a, b) = drag(&mut h, PointerButton::Primary);
|
||||
let mid = a + (b - a) * 0.5;
|
||||
assert!(alpha_at(&h, mid) > 0.9);
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
h.get_by_label("Clear").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.tool_options.brush_clear);
|
||||
assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Multiply, "Clear keeps the underlying mode, as the options bar does");
|
||||
drag(&mut h, PointerButton::Primary);
|
||||
assert_eq!(alpha_at(&h, mid), 0.0);
|
||||
assert!(strokes(&h).last().unwrap()["erase"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_picker_outside_dismissal_does_not_paint_or_erase() {
|
||||
for button in [PointerButton::Primary, PointerButton::Secondary] {
|
||||
let mut h = harness(Some("erase"));
|
||||
drag(&mut h, PointerButton::Primary);
|
||||
let journal = h.state().session.journal.len();
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
// A full outside drag must only dismiss, including its release click.
|
||||
drag(&mut h, button);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
assert_eq!(h.state().session.journal.len(), journal, "dismissal must not paint/erase with {button:?}");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_control_can_pick_a_row_with_ui_click() {
|
||||
use crate::control::{ControlRequest, Outcome, handle};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = harness(Some("erase"));
|
||||
let ctx = h.ctx.clone();
|
||||
let before = h.state().session.journal.len();
|
||||
let (req, _) = ControlRequest::new(
|
||||
"ui.pointer",
|
||||
json!({"button": "right", "shift": true,
|
||||
"events": [{"kind": "down", "x": 200, "y": 150}, {"kind": "up", "x": 200, "y": 150}]}),
|
||||
);
|
||||
assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::Done(_)));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_some());
|
||||
assert_eq!(h.state().session.journal.len(), before);
|
||||
let at = h.get_by_label("Darken").rect().center();
|
||||
let (req, _) = ControlRequest::new("ui.click", json!({"x": at.x, "y": at.y}));
|
||||
assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::AfterInput));
|
||||
// The app injects these queued control events into its next frame's input.
|
||||
let events = std::mem::take(&mut h.state_mut().synthetic);
|
||||
for event in events {
|
||||
h.event(event);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Darken);
|
||||
assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Darken"}})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_right_mode_picker_cancels_and_other_gestures_keep_their_routes() {
|
||||
let mut h = harness(None);
|
||||
let journal = h.state().session.journal.len();
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
let outside = h.state().last_canvas_rect.min + vec2(2.0, 2.0);
|
||||
h.event(egui::Event::PointerMoved(outside));
|
||||
press(&mut h, outside, PointerButton::Secondary, true);
|
||||
press(&mut h, outside, PointerButton::Secondary, false);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
h.state_mut().ui.tool = Tool::Pencil;
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none() && h.state().ui.brush_picker.is_some());
|
||||
h.state_mut().ui.brush_picker = None;
|
||||
h.state_mut().ui.tool = Tool::Brush;
|
||||
mode_right_click(&mut h, Modifiers::NONE);
|
||||
assert!(h.state().ui.brush_picker.is_some() && h.state().ui.brush_mode_menu.is_none());
|
||||
assert_eq!(h.state().session.journal.len(), journal, "opening/cancelling never changes brush or pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_dismissal_consumes_a_held_secondary_release() {
|
||||
for release_shift in [false, true] {
|
||||
let mut h = harness(Some("erase"));
|
||||
let journal = h.state().session.journal.len();
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
let p = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT));
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT });
|
||||
h.run_steps(1);
|
||||
// A quick second press can arrive in the frame that opens the menu for the first
|
||||
// click's release, leaving the secondary button held while the picker is visible.
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::SHIFT });
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT });
|
||||
h.run_steps(1);
|
||||
assert!(h.state().ui.brush_mode_menu.is_some());
|
||||
assert!(h.ctx.input(|i| i.pointer.secondary_down()));
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(1);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
let mods = if release_shift { Modifiers::NONE } else { Modifiers::SHIFT };
|
||||
h.event(egui::Event::ModifiersChanged(mods));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: mods });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none(), "Escape must consume the pending release");
|
||||
assert!(h.state().ui.brush_picker.is_none() && h.state().drag.is_none() && !h.state().secondary_erase);
|
||||
assert_eq!(h.state().session.journal.len(), journal);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
assert!(h.state().ui.brush_mode_menu.is_some(), "the next gesture must still work");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_released_before_the_right_button_still_opens_the_mode_picker() {
|
||||
// egui reports `secondary_clicked` on release. With Erase on, a click that lost Shift a
|
||||
// frame early must open the menu, not erase a dab.
|
||||
let mut h = harness(Some("erase"));
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
let journal = h.state().session.journal.len();
|
||||
let p = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT));
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT });
|
||||
h.run_steps(1);
|
||||
// Shift goes up in a frame of its own while the right button is still down.
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_some(), "the press decided the gesture");
|
||||
assert!(h.state().drag.is_none() && !h.state().secondary_erase);
|
||||
assert_eq!(h.state().session.journal.len(), journal, "no erase dab");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_drag_paints() {
|
||||
let mut h = harness(None);
|
||||
|
||||
@@ -10,6 +10,7 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::layer_transfer::finish(app, ui.ctx());
|
||||
// Once a frame, not per document view: the right-click or options-bar Brush Preset picker.
|
||||
crate::paint_mouse::show_picker(app, ui.ctx());
|
||||
crate::brush_mode_menu::show(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the
|
||||
|
||||
@@ -1110,6 +1110,9 @@ fn pointer(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
mods: modifiers_from(&parsed.modifiers),
|
||||
};
|
||||
for sample in parsed.events {
|
||||
if crate::brush_mode_menu::blocks_control(app, ctx, sample.ev) {
|
||||
continue;
|
||||
}
|
||||
if !pointer_event_intercepted(app, ctx, &gesture, &sample) {
|
||||
forward_pointer_event(app, &gesture, sample);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
||||
"camTransform": app.ui.cam_transform,
|
||||
"layerMenu": app.ui.layer_menu,
|
||||
"layerPicker": app.ui.layer_picker,
|
||||
"brushModeMenu": app.ui.brush_mode_menu.map(|pos| json!({
|
||||
"pos": pos,
|
||||
"mode": if app.ui.tool_options.brush_clear { "Clear" } else { app.session.tools.brush.mode.label() },
|
||||
"choices": crate::brush_mode_menu::choices().iter().map(|(_, label)| *label).collect::<Vec<_>>(),
|
||||
})),
|
||||
"brushPicker": app.ui.brush_picker.map(|pos| json!({
|
||||
"pos": pos,
|
||||
// The picker's content size once its corner grip was dragged (null: the default).
|
||||
|
||||
@@ -8,6 +8,74 @@ fn call(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Valu
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_right_brush_does_not_open_presets_or_paint() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.ui.tool = crate::state::Tool::Brush;
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.journal.len();
|
||||
call(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"ui.pointer",
|
||||
json!({"button": "right", "shift": true,
|
||||
"events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "up", "x": 20, "y": 20}]}),
|
||||
);
|
||||
assert!(app.ui.brush_picker.is_none(), "Shift+right opens Mode, not Brush Presets");
|
||||
assert!(app.drag.is_none());
|
||||
assert_eq!(app.session.journal.len(), before, "opening a menu does not paint or journal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_mode_control_routes_modifiers_and_reports_choices_without_history() {
|
||||
let ctx = egui::Context::default();
|
||||
for (tool, modifiers, expected) in [
|
||||
("brush", json!({"shift": true}), "mode"),
|
||||
("brush", json!({}), "erase"),
|
||||
("brush", json!({"shift": true, "alt": true}), "resize"),
|
||||
("brush", json!({"shift": true, "ctrl": true}), if cfg!(target_os = "macos") { "erase" } else { "layer" }),
|
||||
("brush", json!({"shift": true, "command": true}), "layer"),
|
||||
("pencil", json!({"shift": true}), "preset"),
|
||||
] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap();
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
call(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"ui.pointer",
|
||||
json!({"tool": tool, "button": "right", "modifiers": modifiers,
|
||||
"events": [{"kind": "down", "x": 20, "y": 20}]}),
|
||||
);
|
||||
assert_eq!(app.ui.brush_mode_menu.is_some(), expected == "mode", "{tool}: {modifiers}");
|
||||
match expected {
|
||||
"mode" => {
|
||||
assert!(app.drag.is_none() && !app.secondary_erase);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
let inspect = inspect(&app, &ctx);
|
||||
assert_eq!(inspect["brushModeMenu"]["choices"], json!(crate::brush_mode_menu::choices().iter().map(|(_, label)| *label).collect::<Vec<_>>()));
|
||||
let journal = app.session.journal.len();
|
||||
call(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"ui.pointer",
|
||||
json!({"events": [
|
||||
{"kind": "down", "x": 10, "y": 10}, {"kind": "move", "x": 30, "y": 30}, {"kind": "up", "x": 30, "y": 30}]}),
|
||||
);
|
||||
assert!(app.ui.brush_mode_menu.is_none() && app.drag.is_none());
|
||||
assert_eq!(app.session.journal.len(), journal, "control dismissal cannot paint");
|
||||
}
|
||||
"preset" => assert!(app.ui.brush_picker.is_some()),
|
||||
"layer" => assert!(app.ui.layer_menu.is_some()),
|
||||
"resize" => assert!(app.brush_resize.is_some()),
|
||||
"erase" => assert!(app.drag.as_ref().is_some_and(|d| d.erase)),
|
||||
_ => panic!("unknown fixture route: {expected}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -13,6 +13,7 @@ macro_rules! tl {
|
||||
$crate::i18n::t($s)
|
||||
};
|
||||
}
|
||||
mod brush_mode_menu;
|
||||
pub mod canvas;
|
||||
pub mod control;
|
||||
pub mod dialogs;
|
||||
|
||||
@@ -26,9 +26,7 @@ pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option<Bl
|
||||
/// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while
|
||||
/// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061).
|
||||
pub(super) fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) {
|
||||
let mut opts: Vec<(Option<BlendMode>, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect();
|
||||
let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1);
|
||||
opts.insert(at, (None, "Clear"));
|
||||
let opts = crate::brush_mode_menu::choices();
|
||||
let mut mode = if *clear || held { None } else { Some(b.mode) };
|
||||
if !widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0.is_empty() {
|
||||
*clear = mode.is_none();
|
||||
|
||||
@@ -273,6 +273,9 @@ pub struct UiState {
|
||||
/// its screen position (points).
|
||||
#[serde(default)]
|
||||
pub brush_picker: Option<[f32; 2]>,
|
||||
/// Brush Mode menu opened by Shift+right-click: screen position in points.
|
||||
#[serde(default)]
|
||||
pub brush_mode_menu: Option<[f32; 2]>,
|
||||
/// The Brush Preset picker's preset list: search, collapsed groups, view, a rename in progress.
|
||||
#[serde(default = "crate::brush_picker::list_state")]
|
||||
pub brush_picker_list: crate::brush_panel::BrushesPanelState,
|
||||
@@ -508,6 +511,7 @@ impl Default for UiState {
|
||||
filter_mask_target: None,
|
||||
properties_layer: None,
|
||||
brush_picker: None,
|
||||
brush_mode_menu: None,
|
||||
brush_picker_list: crate::brush_picker::list_state(),
|
||||
brush_picker_size: None,
|
||||
layer_menu: None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brush and painting parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Painting is where Photoshop users notice differences first, so it gets its own checklist: the
|
||||
brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI
|
||||
@@ -76,6 +76,7 @@ tablets against Photoshop side by side.
|
||||
| ⇧-click draws a straight line from the last point | done | |
|
||||
| Hold `~` with the Brush or Pencil to erase with its brush: Clear mode while held (rebindable under Tools › Temporary) | done | `hold_keys.rs`, #3439 |
|
||||
| Clear painting mode in the Brush and Pencil Mode menus | done | `paint_mode_dropdown` in `panels.rs`, `"mode": "clear"`, #3439 |
|
||||
| Shift+right-click with Brush opens its Mode menu, including Clear; Alt and Command/Ctrl keep their existing gestures | done | #4929; shared options-bar choices, `tools.setBrush`, canvas/control interaction regressions and synthetic offscreen checks |
|
||||
| Right-click opens the Brush Preset picker | done | [`context-menu-parity.md`](context-menu-parity.md); XP-Pen right-click not (#1980), macOS issue #2360 |
|
||||
| Precise / normal / full-size tip cursors, crosshair in the tip | partial | Caps Lock precise cursor not audited; round Pencil cursor requested (#2662, #1057) |
|
||||
| Brush choice kept per tool (Brush, Eraser, Blur each their own) | partial | #2542 |
|
||||
@@ -130,7 +131,9 @@ tablets against Photoshop side by side.
|
||||
| 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction |
|
||||
| 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. |
|
||||
| 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. |
|
||||
| 2026-10-13 | minor | Brush-only Shift+right-click Mode menu shares options-bar choices, including Clear; preserves existing right-click gestures and consumes outside dismissal without painting (#4929). No brush-feel estimate changed. |
|
||||
| 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. |
|
||||
| 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. |
|
||||
| 2026-10-14 | minor | Options-bar Mode for Clone Stamp, Pattern Stamp, History Brush and Healing (kept in each tool's brush) and Paint Bucket (with Clear); each tool's first brush starts in Normal. No brush-feel estimate changed. |
|
||||
| 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. |
|
||||
| 2026-10-23 | minor | Escape also consumes a held right-button release after a rapid second press, so the Brush Mode picker stays dismissed without painting or changing history (#4929). |
|
||||
|
||||
@@ -77,11 +77,13 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
|
||||
- Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type.
|
||||
- `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.
|
||||
- `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. For Brush Shift+right-click, see the Mode menu exception below. 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.
|
||||
- With Brush, Shift+right-click (without Alt, Ctrl or Command) opens the Mode menu even when right-click painting is set to Erase. `ui.inspect.brushModeMenu` reports its screen position, current mode and choices. Pick a row with `ui.click`; the choice uses `tools.setBrush`. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document.
|
||||
- `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)
|
||||
- `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default
|
||||
- Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted).
|
||||
|
||||
Reference in New Issue
Block a user