Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662) (#2694)

* Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662)

The Pencil cursor always outlined the square grid_square footprint, but the
aliased dab is a disc from 4 px on. It now draws a circle on the same
pixel-grid square when the tip is round, and the square for 1-3 px tips
whose dab really fills it.

The Eraser options bar gains Photoshop's Mode: Brush / Pencil (replacing
the blend-mode dropdown the Eraser never used). Pencil mode commits
paint.pencil with erase: true (hard, aliased, full flow), live and on
release, and shows the Pencil's cursor. Block mode is left for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Eraser: translate the Pencil mode label in every catalog

The Eraser's Mode dropdown added tl!("Pencil"), which had no entry in the
cs, el, ko, zh-hans and zh-hant catalogs, so every_tl_literal_is_translated
failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-10 17:14:17 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 96ec16ed7d
commit 5a1f6eef2e
10 changed files with 121 additions and 22 deletions
+29 -9
View File
@@ -362,9 +362,10 @@ fn live_retouch_command(tool: Tool) -> Option<&'static str> {
}
}
/// The command a live-stroking tool commits: the Pencil's `paint.pencil`, else `paint.stroke`.
pub(crate) fn stroke_command(tool: Tool) -> &'static str {
if tool == Tool::Pencil { "paint.pencil" } else { "paint.stroke" }
/// The command a live-stroking tool commits: the Pencil's (and the Pencil-mode Eraser's)
/// `paint.pencil`, else `paint.stroke`.
pub(crate) fn stroke_command(app: &PhotocraftApp, tool: Tool) -> &'static str {
if tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool) { "paint.pencil" } else { "paint.stroke" }
}
/// Windows' crosshair cursor inverts the pixels under it, so over mid-grey (the pasteboard, many
@@ -388,6 +389,16 @@ pub(crate) fn pencil_cursor_rect(xf: &ViewXform, doc: [f64; 2], size: f32, ppp:
Rect::from_min_max(pos2(snap(r.min.x), snap(r.min.y)), pos2(snap(r.max.x), snap(r.max.y)))
}
/// Whether an aliased (Pencil) dab of `size` pixels is round rather than its whole
/// [`pencil_cursor_rect`] square. The dab fills the pixels whose centres lie within its radius of
/// the grid centre (`paint::grid_center`), so up to 3 px the corner pixels are in and the dab is
/// a square; from 4 px on they fall outside and it is a disc (#2662).
pub(crate) fn pencil_tip_is_round(size: f32) -> bool {
let n = if size.is_finite() { size.round().clamp(1.0, 100_000.0) } else { 1.0 };
let h = n / 2.0;
(h - 0.5) * std::f32::consts::SQRT_2 > h
}
/// Whether a brush-tip circle draws its centre mark.
/// Clone Stamp and Healing Brush stay an empty circle until Option is held, the source-point
/// cursor. Other brushes follow the cursor preference, the large-tip mark, or the Background Eraser.
@@ -466,7 +477,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)
} else {
let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(d.tool), &p).ok()?))
EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?))
};
let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff;
let damage = vec![stroke.bounds()];
@@ -3024,13 +3035,22 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
PaintingCursor::Standard => egui::CursorIcon::Default,
PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0),
_ if painting && cur.show_only_crosshair_while_painting => crate::tool_cursor::crosshair(&painter, p, 5.0, 0.0),
// The Pencil: the square of whole pixels its dab fills, on the pixel grid.
_ if tool == Tool::Pencil => {
// The Pencil (and the Eraser in Pencil mode): the outline of the
// whole pixels its dab fills, on the pixel grid. A round tip is a
// disc, a tiny one the whole square (#2662).
_ if tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool) => {
let ppp = painter.ctx().pixels_per_point();
let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp);
let px = 1.0 / ppp;
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside);
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside);
let (dark, light) = (Stroke::new(px, Color32::from_black_alpha(160)), Stroke::new(px, Color32::from_white_alpha(230)));
if pencil_tip_is_round(brush.size) {
let r = sq.width() / 2.0;
painter.circle_stroke(sq.center(), r + px / 2.0, dark);
painter.circle_stroke(sq.center(), (r - px / 2.0).max(0.0), light);
} else {
painter.rect_stroke(sq, 0.0, dark, egui::StrokeKind::Outside);
painter.rect_stroke(sq, 0.0, light, egui::StrokeKind::Inside);
}
// Too small to see where it is: the hotspot as well.
if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 {
crate::tool_cursor::crosshair(&painter, p, 4.0, 0.0);
@@ -4326,7 +4346,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
}
// The canvas already shows the stroke: let the commit's damage rect refresh it rather
// than recompositing the whole document.
if app.run(stroke_command(d.tool), p).is_ok()
if app.run(stroke_command(app, d.tool), p).is_ok()
&& let Some(l) = live
{
// Raw preview key 0 = the document itself (its colour display folded in).
+13
View File
@@ -53,6 +53,19 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) -
true
}
/// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through
/// `paint.pencil` (`"erase": true`) and shows the Pencil's pixel-grid cursor (#2662).
pub fn pencil_mode(app: &PhotocraftApp, tool: Tool) -> bool {
tool == Tool::Eraser && app.ui.tool_options.eraser_mode == "pencil"
}
/// The Eraser's Mode dropdown (Brush / Pencil). Returns whether it changed.
pub fn mode_dropdown(ui: &mut egui::Ui, mode: &mut String) -> bool {
opt(ui, tl!("Mode:"));
let modes = [("brush".to_string(), tl!("Brush")), ("pencil".to_string(), tl!("Pencil"))];
widgets::dropdown(ui, "eraser-mode", mode, &modes, 96.0)
}
fn opt(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(&text)).color(t.text_dim).size(12.0));
+1
View File
@@ -1549,6 +1549,7 @@
Blur Tool Nástroj rozostření
Bold Tučné
Brush Štětec
Pencil Tužka
Brush Pose Poloha štětce
Brush Tip Shape Tvar hrotu štětce
Brush Tool Nástroj štětec
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3360,6 +3360,7 @@ cameraRaw Yellow Κίτρινο
Apple Watch 45mm Apple Watch 45mm
Black Matte Μαύρο ματ
Brush Πινέλο
Pencil Μολύβι
Color… Χρώμα…
Cone (Inverted) Κώνος (αντεστραμμένος)
Could not open Δεν ήταν δυνατό το άνοιγμα
Can't render this file because it contains an unexpected character in line 973 and column 18.
+1
View File
@@ -1503,6 +1503,7 @@
Blur Tool 흐림 효과 도구
Bold 굵게
Brush 브러시
Pencil 연필
Brush Pose 브러시 자세
Brush Tip Shape 브러시 끝 모양
Brush Tool 브러시 도구
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
1503
1504
1505
1506
1507
1508
1509
+1
View File
@@ -1544,6 +1544,7 @@
Blur Tool 模糊工具
Bold 粗体
Brush 画笔
Pencil 铅笔
Brush Pose 画笔姿态
Brush Tip Shape 画笔笔尖形状
Brush Tool 画笔工具
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
1544
1545
1546
1547
1548
1549
1550
+1
View File
@@ -1538,6 +1538,7 @@
Blur Tool 模糊工具
Bold 粗體
Brush 筆刷
Pencil 鉛筆
Brush Pose 筆刷姿勢
Brush Tip Shape 筆尖形狀
Brush Tool 筆刷工具
Can't render this file because it contains an unexpected character in line 6 and column 47.
+25 -12
View File
@@ -837,21 +837,29 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
brush_preset_chip(ui, b, &mut app.ui);
crate::brush_picker::settings_toggle(app, ui);
widgets::vline(ui, 22.0);
opt_label(ui, tl!("Mode"));
let mut mode = b.mode;
let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect();
if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) {
b.mode = mode;
// The Eraser has no blend mode: its Mode is Brush or Pencil (#2662).
if app.ui.tool == Tool::Eraser {
crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode);
} else {
opt_label(ui, tl!("Mode"));
let mut mode = b.mode;
let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect();
if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) {
b.mode = mode;
}
}
percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0);
if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() {
b.pressure_opacity = !b.pressure_opacity;
}
percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0);
let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects"));
if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() {
b.build_up = !b.build_up;
}
// A Pencil-mode Eraser is always full flow, without build-up (Photoshop).
ui.add_enabled_ui(!crate::eraser_ui::pencil_mode(app, app.ui.tool), |ui| {
percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0);
let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects"));
if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() {
b.build_up = !b.build_up;
}
});
opt_label(ui, tl!("Smoothing"));
smoothing_field(ui, b, 58.0);
smoothing_options(ui, b);
@@ -889,9 +897,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
opt_label(ui, tl!("Size"));
widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0);
widgets::vline(ui, 22.0);
percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0);
if app.ui.tool == Tool::Eraser {
crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode);
}
// A Pencil-mode Eraser is always hard and at full flow (Photoshop).
let pencil = crate::eraser_ui::pencil_mode(app, app.ui.tool);
ui.add_enabled_ui(!pencil, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0));
percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0);
percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0);
ui.add_enabled_ui(!pencil, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0));
opt_label(ui, tl!("Smoothing"));
smoothing_field(ui, b, 66.0);
widgets::vline(ui, 22.0);
+40 -1
View File
@@ -6,7 +6,7 @@ use egui_kittest::Harness;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, tool_event};
use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, pencil_tip_is_round, tool_event};
use crate::state::Tool;
fn app() -> PhotocraftApp {
@@ -137,6 +137,45 @@ fn square_cursor_sits_on_the_pixel_grid() {
let _ = pencil_cursor_rect(&xf, [f64::NAN, f64::INFINITY], f32::NAN, f32::NAN);
}
#[test]
fn the_cursor_is_round_exactly_when_the_pencil_dab_is() {
// #2662: one dab per size; the cursor is the square only while the dab fills its corners.
let mut app = app();
for n in 1..=12u32 {
let (x, y) = (8.0 + f64::from((n - 1) % 6) * 18.0 + 0.3, 15.0 + f64::from((n - 1) / 6) * 30.0 + 0.6);
app.run("paint.pencil", json!({"points": [[x, y]], "size": n})).unwrap();
let [x0, y0, ..] = photocraft_engine::paint::grid_square(x, y, n as f32);
let corner = rgba(&app, x0 as i32, y0 as i32)[3] == 1.0;
assert_eq!(pencil_tip_is_round(n as f32), !corner, "{n} px");
}
assert!(pencil_tip_is_round(9.0) && !pencil_tip_is_round(3.0));
assert!(!pencil_tip_is_round(f32::NAN));
}
#[test]
fn the_eraser_in_pencil_mode_erases_aliased_pixels() {
// #2662: an opaque layer, then a soft Eraser in Pencil mode erases with hard edges.
let mut app = app();
app.run("paint.pencil", json!({"points": [[60.0, 40.0]], "size": 400})).unwrap();
app.ui.tool = Tool::Eraser;
crate::paint_mouse::sync_tool_brush(&mut app);
app.run("tools.setBrush", json!({"brush": {"size": 15, "hardness": 0.0, "smoothing": {"amount": 0.0}}})).unwrap();
app.ui.tool_options.eraser_mode = "pencil".into();
drag(&mut app, &[(10.0, 40.0), (60.3, 25.7), (110.0, 40.0)], Modifiers::NONE);
let (id, p) = last(&app);
assert_eq!((id.as_str(), &p["erase"]), ("paint.pencil", &json!(true)));
let s = layer(&app).surface().unwrap().clone();
let mut erased = 0;
for y in 0..80 {
for x in 0..120 {
let a = s.rgba(x, y)[3];
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
erased += usize::from(a == 0.0);
}
}
assert!(erased > 500, "{erased}");
}
#[test]
fn b_cycles_brush_pencil_and_mixer_brush() {
let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
+9
View File
@@ -569,6 +569,10 @@ pub struct ToolOptions {
/// Pencil › Auto Erase: a stroke that starts on the foreground colour paints the background colour.
#[serde(default)]
pub pencil_auto_erase: bool,
/// Eraser › Mode: `brush` (soft, anti-aliased) or `pencil` (hard, aliased pixels, through
/// `paint.pencil`), as in Photoshop's options bar (#2662).
#[serde(default = "default_eraser_mode")]
pub eraser_mode: String,
/// Magnetic Lasso: detection width (px, 1..256), edge contrast (%, 1..100), how often it
/// fastens points by itself (0..100), and whether pen pressure narrows the width.
pub magnetic_width: f32,
@@ -606,6 +610,10 @@ fn default_move_target() -> String {
"layer".into()
}
fn default_eraser_mode() -> String {
"brush".into()
}
fn default_marquee_style() -> String {
"normal".into()
}
@@ -704,6 +712,7 @@ impl Default for ToolOptions {
bg_protect_fg: false,
zoom_scrubby: true,
pencil_auto_erase: false,
eraser_mode: default_eraser_mode(),
magnetic_width: 10.0,
magnetic_contrast: 10.0,
magnetic_frequency: 57.0,