Compare commits

...
Author SHA1 Message Date
Brandon Thomas 8ce5770c06 Ctrl+Alt-drag resizes the brush on the canvas (#231) (#271) 2026-10-05 18:28:30 -04:00
3 changed files with 306 additions and 24 deletions

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
//! Photoshop's on-canvas brush resize (#231): with a painting tool, Control+Alt+drag (Windows,
//! Linux) or Control+Option+drag (macOS) changes the brush instead of painting. Left and right
//! set the diameter (the circle's edge follows the pointer), up and down the hardness (down makes
//! it harder). The brush circle stays where the drag began, tinted to show the hardness, with a
//! Diameter/Hardness readout beside the pointer.
//!
//! The change is `tools.setBrush` calls sharing one `coalesce` key, so the whole drag is one
//! journal entry (Rule 1) and, the brush being tool state, no history step. Krita's ⇧+drag (the
//! request in #231) is not used: ⇧ is Photoshop's straight-line modifier for painting tools.
use std::sync::atomic::{AtomicU64, Ordering};
use egui::{Color32, Pos2, Stroke, vec2};
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform};
use crate::state::Tool;
/// Screen points of vertical drag that take the hardness from 0 % to 100 %.
pub const HARDNESS_TRAVEL: f32 = 200.0;
/// The brush size range (`tools.setBrush` accepts the same).
const SIZE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
/// A resize drag in progress.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Resize {
/// Where the drag began, in document pixels: the circle stays there.
pub anchor: [f64; 2],
/// The brush's diameter and hardness when it began.
pub start: (f32, f32),
/// The `coalesce` key of this drag's `tools.setBrush` calls.
key: u64,
}
/// Tools the gesture resizes: every painting tool, and Quick Selection (it has a brush too).
pub fn applies(tool: Tool) -> bool {
tool.is_brushlike() || tool == Tool::QuickSelection
}
/// Control+Alt (Control+Option on a Mac): `ctrl` is the Control key on every platform.
pub fn is_gesture(mods: egui::Modifiers) -> bool {
mods.ctrl && mods.alt
}
/// The diameter and hardness a drag from `start` by (`dx`, `dy`) document pixels gives at `zoom`.
pub fn resized(start: (f32, f32), dx: f64, dy: f64, zoom: f32) -> (f32, f32) {
let dx = if dx.is_finite() { dx as f32 } else { 0.0 };
let dy = if dy.is_finite() { dy as f32 } else { 0.0 };
let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 };
let size = (start.0 + 2.0 * dx).round().clamp(*SIZE.start(), *SIZE.end());
let hardness = (start.1 + dy * zoom / HARDNESS_TRAVEL).clamp(0.0, 1.0);
// Whole percent, like the readout and the options bar.
(size, (hardness * 100.0).round() / 100.0)
}
/// Route a tool event: true when it belongs to a resize drag (begun here with Control+Alt held,
/// or already in progress), so the tool never sees it.
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
static KEY: AtomicU64 = AtomicU64::new(0);
match ev {
ToolEvent::Down { x, y, .. } => {
if app.drag.is_some() || !applies(app.ui.tool) || !is_gesture(mods) {
return false;
}
let b = &app.session.tools.brush;
app.brush_resize = Some(Resize { anchor: [x, y], start: (b.size, b.hardness), key: KEY.fetch_add(1, Ordering::Relaxed) });
true
}
ToolEvent::Move { x, y, .. } => match app.brush_resize {
Some(r) => {
update(app, r, x, y);
true
}
None => false,
},
ToolEvent::Up { x, y } => match app.brush_resize.take() {
Some(r) => {
update(app, r, x, y);
true
}
None => false,
},
}
}
fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) {
// Right is bigger on screen, also in a flipped view (View › Flip Horizontal).
let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 };
let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.current_zoom());
let b = &app.session.tools.brush;
if b.size == size && b.hardness == hardness {
return;
}
let p = json!({ "brush": { "size": size, "hardness": hardness }, "coalesce": format!("brush-resize:{}", r.key) });
if let Err(e) = app.run("tools.setBrush", p) {
app.ui.status = e;
}
}
/// Draw the resize feedback: the brush circle at the anchor, filled with a red tip preview whose
/// solid core shows the hardness (Photoshop's), and the readout beside the pointer. Returns true
/// while a resize is in progress (the canvas then hides its cursor).
pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) -> bool {
let Some(r) = app.brush_resize else { return false };
let b = &app.session.tools.brush;
let c = xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32);
let radius = (b.size / 2.0 * xf.zoom).max(1.0);
tip_preview(painter, c, radius, b.hardness, Color32::from_rgba_unmultiplied(255, 0, 0, 110));
painter.circle_stroke(c, radius + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
painter.circle_stroke(c, radius, Stroke::new(1.0, Color32::from_white_alpha(220)));
// Photoshop hides the pointer while the circle stays put.
painter.ctx().set_cursor_icon(egui::CursorIcon::None);
let at = painter.ctx().pointer_latest_pos().unwrap_or(c);
crate::canvas::draw_readout(
painter.ctx(),
"brush-resize-readout",
at,
["Diameter:", "Hardness:"],
[format!("{} px", b.size.round() as i64), format!("{}%", (b.hardness * 100.0).round() as i64)],
);
true
}
/// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear
/// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush.
fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
let clip = painter.clip_rect();
if !clip.expand(radius).contains(c) {
return;
}
// Enough segments for a smooth edge at any size, capped so huge brushes stay cheap.
let n = ((radius * 0.5) as usize).clamp(24, 160);
let inner = radius * hardness.clamp(0.0, 1.0);
let clear = Color32::TRANSPARENT;
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(c, color);
for k in 0..n {
let a = k as f32 / n as f32 * std::f32::consts::TAU;
let d = vec2(a.cos(), a.sin());
mesh.colored_vertex(c + d * inner, color);
mesh.colored_vertex(c + d * radius, clear);
}
let n = n as u32;
for k in 0..n {
let (i0, o0) = (1 + 2 * k, 2 + 2 * k);
let (i1, o1) = (1 + 2 * ((k + 1) % n), 2 + 2 * ((k + 1) % n));
mesh.add_triangle(0, i0, i1);
mesh.add_triangle(i0, o0, o1);
mesh.add_triangle(i0, o1, i1);
}
painter.add(mesh);
}
#[cfg(test)]
mod tests {
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
use super::*;
use crate::canvas::tool_event;
const CTRL_ALT: Modifiers = Modifiers { alt: true, ctrl: true, shift: false, mac_cmd: false, command: false };
fn app(tool: Tool) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200, "background": "transparent"})).unwrap();
app.run("tools.setBrush", json!({"brush": {"size": 40, "hardness": 0.2}})).unwrap();
app.ui.tool = tool;
app
}
fn history(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().history.past_len()
}
#[test]
fn mapping_is_bounded() {
assert_eq!(resized((40.0, 0.5), 10.0, 0.0, 1.0), (60.0, 0.5), "the edge follows the pointer");
assert_eq!(resized((40.0, 0.5), 0.0, 50.0, 2.0), (40.0, 1.0), "down is harder, in screen points");
assert_eq!(resized((40.0, 0.5), -1e9, -1e9, 1.0), (1.0, 0.0));
assert_eq!(resized((40.0, 0.5), 1e9, 1e9, 1.0), (5000.0, 1.0));
assert_eq!(resized((40.0, 0.5), f64::NAN, f64::INFINITY, f32::NAN), (40.0, 0.5));
assert!(is_gesture(CTRL_ALT) && !is_gesture(Modifiers::ALT) && !is_gesture(Modifiers::CTRL) && !is_gesture(Modifiers::SHIFT));
}
#[test]
fn ctrl_alt_drag_resizes_every_brush_tool_without_painting_or_history() {
let tools: Vec<Tool> = Tool::ALL.into_iter().filter(|t| applies(*t)).collect();
assert!(tools.len() > 10 && tools.contains(&Tool::Brush) && tools.contains(&Tool::Eraser) && tools.contains(&Tool::CloneStamp));
for tool in tools {
let mut app = app(tool);
let (h0, j0) = (history(&app), app.session.journal.len());
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, CTRL_ALT);
assert!(app.brush_resize.is_some() && app.drag.is_none(), "{tool:?}");
// Right grows the size.
tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 100.0, pressure: 1.0 }, CTRL_ALT);
assert_eq!(app.session.tools.brush.size, 100.0, "{tool:?}");
// Down raises the hardness (at 100 %, 200 points take it from 0 to 100 %).
tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 160.0, pressure: 1.0 }, CTRL_ALT);
assert_eq!(app.session.tools.brush.hardness, 0.5, "{tool:?}");
// Letting go of the keys mid-drag keeps resizing until the button is released.
tool_event(&mut app, ToolEvent::Up { x: 120.0, y: 180.0 }, Modifiers::NONE);
let b = &app.session.tools.brush;
assert_eq!((b.size, b.hardness), (80.0, 0.6), "{tool:?}");
assert!(app.brush_resize.is_none() && app.drag.is_none());
// No history step, no paint: one coalesced tools.setBrush in the journal.
assert_eq!(history(&app), h0, "{tool:?}");
let new: Vec<&str> = app.session.journal[j0..].iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(new, ["tools.setBrush"], "{tool:?}");
assert_eq!(app.session.journal.last().unwrap().1["brush"], json!({"size": 80.0f32, "hardness": 0.6f32}));
}
}
#[test]
fn other_tools_and_strokes_in_progress_are_untouched() {
let mut a = app(Tool::Move);
tool_event(&mut a, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, CTRL_ALT);
assert!(a.brush_resize.is_none());
tool_event(&mut a, ToolEvent::Up { x: 10.0, y: 10.0 }, CTRL_ALT);
let mut a = app(Tool::Brush);
tool_event(&mut a, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut a, ToolEvent::Move { x: 50.0, y: 10.0, pressure: 1.0 }, CTRL_ALT);
assert!(a.brush_resize.is_none(), "a stroke in progress isn't taken over");
tool_event(&mut a, ToolEvent::Up { x: 50.0, y: 10.0 }, CTRL_ALT);
assert!(a.session.journal.iter().any(|(id, _)| id == "paint.stroke"));
assert_eq!(a.session.tools.brush.size, 40.0);
}
#[test]
fn canvas_drag_shows_the_circle_and_readout() {
let mut a = app(Tool::Brush);
a.sync_views();
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
a,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());
h.run_steps(4);
let c = h.state().last_canvas_rect.center();
let press = |h: &mut Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool| {
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: CTRL_ALT });
h.run();
};
h.event(egui::Event::ModifiersChanged(CTRL_ALT));
h.event(egui::Event::PointerMoved(c));
h.run();
press(&mut h, c, true);
let size0 = h.state().session.tools.brush.size;
let undo0 = history(h.state());
for k in 1..=6 {
h.event(egui::Event::PointerMoved(c + vec2(10.0 * k as f32, 8.0 * k as f32)));
h.run();
}
assert!(h.state().brush_resize.is_some());
assert!(h.state().session.tools.brush.size > size0, "dragging right grew the brush");
assert!(h.state().session.tools.brush.hardness > 0.2, "dragging down hardened it");
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("brush-resize-readout"))).is_some(), "the readout shows mid-drag");
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the pointer hides");
press(&mut h, c + vec2(60.0, 48.0), false);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
assert!(h.state().brush_resize.is_none());
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"), "nothing painted");
assert_eq!(history(h.state()), undo0, "no undo step");
}
}
+28 -24
View File
@@ -96,35 +96,32 @@ pub fn marquee_readout(r: [f64; 4]) -> [String; 2] {
/// Draw the marquee size readout below-right of the cursor (kept on screen), like Photoshop's:
/// two rows, `W:` / `H:` labels on the left and the values right-aligned.
fn draw_marquee_readout(ctx: &egui::Context, cursor: Pos2, values: [String; 2]) {
draw_readout(ctx, "marquee-readout", cursor, ["W:", "H:"], values);
}
/// A two-row readout beside the pointer (labels left, values right-aligned), above everything.
pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor: Pos2, labels: [&str; 2], values: [String; 2]) {
let t = crate::theme::Tokens::get(ctx);
let font = egui::FontId::proportional(11.5);
let labels = ["W:", "H:"];
let width = |text: &str| ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), t.text).size().x);
let (lw, vw) = (labels.map(width), [width(&values[0]), width(&values[1])]);
let (label_col, value_col) = (lw[0].max(lw[1]), vw[0].max(vw[1]));
egui::Area::new(egui::Id::new("marquee-readout"))
.order(egui::Order::Tooltip)
.fixed_pos(cursor + vec2(16.0, 18.0))
.interactable(false)
.constrain(true)
.show(ctx, |ui| {
egui::Frame::new()
.fill(t.card)
.stroke(Stroke::new(1.0, t.card_border))
.corner_radius(t.radius_sm)
.inner_margin(egui::Margin::symmetric(7, 4))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
for (i, value) in values.into_iter().enumerate() {
// Labels left, values right-aligned on one edge, sharing a baseline.
ui.horizontal(|ui| {
ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend());
ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]);
ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend());
});
}
});
});
egui::Area::new(egui::Id::new(id)).order(egui::Order::Tooltip).fixed_pos(cursor + vec2(16.0, 18.0)).interactable(false).constrain(true).show(ctx, |ui| {
egui::Frame::new().fill(t.card).stroke(Stroke::new(1.0, t.card_border)).corner_radius(t.radius_sm).inner_margin(egui::Margin::symmetric(7, 4)).show(
ui,
|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
for (i, value) in values.into_iter().enumerate() {
// Labels left, values right-aligned on one edge, sharing a baseline.
ui.horizontal(|ui| {
ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend());
ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]);
ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend());
});
}
},
);
});
}
/// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of
@@ -1402,6 +1399,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
draw_drag_preview(app, &painter, &xf);
crate::zoom_tool::draw(&ctx, &painter);
let resizing = crate::brush_resize::draw(app, &painter, &xf);
draw_transform_controls(app, &painter, &xf);
crate::paint_mouse::show_picker(app, &ctx);
crate::snap_ui::draw(app, &painter, &xf);
@@ -1430,6 +1428,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
} else if let Some(p) = response.hover_pos() {
let alt = ui.input(|i| i.modifiers.alt);
let icon = match tool {
// Resizing the brush: the circle stays where the drag began (`brush_resize`).
t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None,
t if t.is_brushlike() || t == Tool::QuickSelection => {
// Preferences › Cursors: brush tip outline (normal = the 50% contour, or
// full size), precise crosshair, or the standard pointer.
@@ -1768,6 +1768,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
let mods = crate::workspace_ui::sticky_mods(app, mods);
// Control+Alt-drag with a painting tool resizes the brush instead of painting (#231).
if crate::brush_resize::pointer(app, ev, mods) {
return;
}
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
let ev = crate::move_mods::filter_event(app, ev, mods);
// Ruler, Count and Note tools.
+4
View File
@@ -17,6 +17,7 @@ pub mod artboard_ui;
pub mod brush_panel;
pub mod brush_picker;
pub mod brush_preview;
pub mod brush_resize;
pub mod brush_sections;
pub mod brushes_tab;
pub mod camera_raw_ui;
@@ -223,6 +224,8 @@ pub struct PhotocraftApp {
secondary_erase: bool,
/// End of the last painting stroke: ⇧-click draws a straight line from it (#178).
last_stroke_end: Option<(DocId, [f64; 2])>,
/// Control+Alt-drag brush resize in progress (`brush_resize`, #231).
pub(crate) brush_resize: Option<brush_resize::Resize>,
control_rx: Option<Receiver<ControlRequest>>,
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
/// Screenshots not yet requested from the viewport: (token, earliest time in ms, frames seen).
@@ -339,6 +342,7 @@ impl PhotocraftApp {
move_preview: None,
secondary_erase: false,
last_stroke_end: None,
brush_resize: None,
control_rx: None,
pending_screenshots: Vec::new(),
queued_screenshots: Vec::new(),