Expose the Mixer Brush as a UI tool (#213) (#433)

* feat(ui): expose Mixer Brush tool (#213)

Wire the existing Mixer Brush command into the selectable UI tool model, options bar, and control-driven painting path. Cover shortcut cycling, options persistence, and selection clipping.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* docs(scorecard): mark Mixer Brush slice complete (#213)

Reflect the new Mixer Brush toolbar, options, control dispatch, and selection tests in TOOL-213-2 and regenerate the scorecard.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* fix(i18n): translate Mixer Brush labels in zh-Hans

Add Simplified Chinese entries for the Mixer Brush tool, its options, and the brush-settings heading. Test all Mixer Brush UI strings against every registered language catalog.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Polish Mixer Brush UI controls

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
This commit is contained in:
authored and GitHub committed 2026-10-07 07:04:31 -04:00
1 parent bd7d47bf0f
commit d91ab25772
16 files changed
+163 -46

No files matched your search

+24
View File
@@ -286,6 +286,30 @@ fn options_bar_edits_are_one_set_brush_per_gesture() {
assert_eq!(s.tools.brush, h.state().session.tools.brush);
}
#[test]
fn mixer_brush_options_update_the_persisted_mixer_settings() {
use egui_kittest::kittest::Queryable;
let mut h = options_bar_harness(crate::state::Tool::MixerBrush);
for label in ["Wet", "Load", "Mix", "Flow", "Sample All Layers"] {
assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}");
}
let initial = h.state().session.journal.len();
let at = h.get_by_label("Sample All Layers").rect().center();
h.hover_at(at);
h.run();
h.drag_at(at);
h.run();
h.drop_at(at);
h.run_steps(2);
assert!(h.state().session.tools.brush.mixer.sample_all_layers);
assert_eq!(h.state().session.journal.len(), initial + 1);
let (id, params) = h.state().session.journal.last().unwrap();
assert_eq!(id, "tools.setBrush");
assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true);
}
#[test]
fn drop_targets_and_actions_reorder_presets() {
use crate::brushes_tab::{Action, apply, drop_target, group_key};
+13 -3
View File
@@ -268,6 +268,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool {
tool,
Tool::Brush
| Tool::Pencil
| Tool::MixerBrush
| Tool::Eraser
| Tool::BackgroundEraser
| Tool::HistoryBrush
@@ -2516,9 +2517,18 @@ mod tests {
#[test]
fn freehand_tools_are_the_ones_that_follow_a_path() {
for t in
[Tool::Brush, Tool::Pencil, Tool::Eraser, Tool::BackgroundEraser, Tool::CloneStamp, Tool::Smudge, Tool::Dodge, Tool::Lasso, Tool::QuickSelection]
{
for t in [
Tool::Brush,
Tool::Pencil,
Tool::MixerBrush,
Tool::Eraser,
Tool::BackgroundEraser,
Tool::CloneStamp,
Tool::Smudge,
Tool::Dodge,
Tool::Lasso,
Tool::QuickSelection,
] {
assert!(freehand_tool(t), "{t:?} paints or retouches along a path");
}
for t in [Tool::Move, Tool::Eyedropper, Tool::Gradient, Tool::Crop, Tool::RectMarquee, Tool::Type, Tool::Hand] {
+1
View File
@@ -1951,6 +1951,7 @@
Brush Projection Projekce štětce
Apply the pen's tilt and rotation to the tip shape Použít náklon a natočení pera na tvar hrotu
Mixer Brush Míchací štětec
Mixer Brush Tool Nástroj Míchací štětec
Wet Vlhkost
Mix Míchání
Wetness Jitter Kolísání vlhkosti
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1937,6 +1937,7 @@
Brush Projection Proyección del pincel
Apply the pen's tilt and rotation to the tip shape Aplicar la inclinación y la rotación del lápiz a la forma de la punta
Mixer Brush Pincel mezclador
Mixer Brush Tool Herramienta Pincel mezclador
Wet Húmedo
Mix Mezcla
Wetness Jitter Variación de humedad
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -1942,6 +1942,7 @@
Brush Projection ブラシの投影
Apply the pen's tilt and rotation to the tip shape ペンの傾きと回転を先端のシェイプに適用します
Mixer Brush 混合ブラシ
Mixer Brush Tool 混合ブラシツール
Wet ウェット
Mix 混合
Wetness Jitter ウェットのジッター
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -538,4 +538,19 @@ mod tests {
}
}
}
#[test]
fn mixer_brush_ui_strings_have_translations_in_every_registered_language() {
const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"];
for lang in Lang::all() {
for source in STRINGS {
let translated = tr(lang, source);
if lang == Lang::EN {
assert_eq!(translated, *source, "English source string {source}");
} else {
assert_ne!(translated, *source, "{} is missing {source:?}", lang.code());
}
}
}
}
}
+1
View File
@@ -468,6 +468,7 @@
Quick Selection Быстрое выделение
Magnetic Lasso Магнитное лассо
Mixer Brush Смешивающая кисть
Mixer Brush Tool Инструмент «Смешивающая кисть»
History Brush Кисть истории
Art History Brush Кисть истории искусств
Pattern Stamp Узорный штамп
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -1135,6 +1135,9 @@
Midtones 中间调
Midtones from: 中间调下限:
Midtones to: 中间调上限:
Mix 混合
Mixer Brush 混合笔刷
Mixer Brush Tool 混合画笔工具
Minimum Depth 最小深度
Minimum Diameter 最小直径
Minimum Roundness 最小圆度
@@ -1665,6 +1668,7 @@
Web Minimum Web 最小尺寸
Web Most Common Web 常用尺寸
WebP (lossless) WebP(无损)
Wet 湿度
Wet Edges 湿边
Yellows 黄色
Zoom Tool 缩放工具
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
1135
1136
1137
1138
1139
1140
1141
1142
1143
1668
1669
1670
1671
1672
1673
1674
+1
View File
@@ -1933,6 +1933,7 @@
Brush Projection 筆刷投影
Apply the pen's tilt and rotation to the tip shape 將筆的傾斜和旋轉套用至筆尖形狀
Mixer Brush 混合器筆刷
Mixer Brush Tool 混合器筆刷工具
Wet 濕度
Mix 混合
Wetness Jitter 濕度快速變換
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -64,6 +64,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::EllipseMarquee => "circle-dashed",
Tool::Brush => "brush",
Tool::Pencil => "pencil",
Tool::MixerBrush => "palette",
Tool::Eraser => "eraser",
Tool::BackgroundEraser => "eraser-background",
Tool::MagicEraser => "eraser-magic",
+31 -32
View File
@@ -25,7 +25,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
],
&[
&[Tool::SpotHealing, Tool::Healing],
&[Tool::Brush, Tool::Pencil],
&[Tool::Brush, Tool::Pencil, Tool::MixerBrush],
&[Tool::CloneStamp],
&[Tool::HistoryBrush],
&[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser],
@@ -423,7 +423,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let tool = app.ui.tool;
// Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1).
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser)) || tool == Tool::QuickSelection {
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser)) || tool == Tool::QuickSelection {
let before = app.session.tools.brush.clone();
let mut b = before.clone();
let pick = brush_preset_chip(ui, &mut b, &app.session.tools.presets);
@@ -456,19 +456,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) {
b.mode = mode;
}
opt_label(ui, tl!("Opacity"));
let mut o = b.opacity * 100.0;
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 62.0).changed() {
b.opacity = o / 100.0;
}
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;
}
opt_label(ui, tl!("Flow"));
let mut f = b.flow * 100.0;
if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 62.0).changed() {
b.flow = f / 100.0;
}
percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0);
let _ = icons::button(ui, "sparkles", 24.0, false, tl!("Enable airbrush-style build-up effects"));
opt_label(ui, tl!("Smoothing"));
smoothing_field(ui, b, 58.0);
@@ -495,11 +487,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if widgets::dropdown(ui, "pencil-mode", &mut mode, &opts, 96.0) {
b.mode = mode;
}
opt_label(ui, tl!("Opacity"));
let mut o = b.opacity * 100.0;
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", if t.pro { 62.0 } else { 66.0 }).changed() {
b.opacity = o / 100.0;
}
percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, if t.pro { 62.0 } else { 66.0 });
opt_label(ui, tl!("Smoothing"));
smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 });
widgets::vline(ui, 22.0);
@@ -512,27 +500,30 @@ 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);
opt_label(ui, tl!("Hardness"));
let mut h = b.hardness * 100.0;
if widgets::value_field(ui, &mut h, 0.0..=100.0, "%", 66.0).changed() {
b.hardness = h / 100.0;
}
opt_label(ui, tl!("Opacity"));
let mut o = b.opacity * 100.0;
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() {
b.opacity = o / 100.0;
}
opt_label(ui, tl!("Flow"));
let mut f = b.flow * 100.0;
if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 66.0).changed() {
b.flow = f / 100.0;
}
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);
opt_label(ui, tl!("Smoothing"));
smoothing_field(ui, b, 66.0);
widgets::vline(ui, 22.0);
widgets::toggle(ui, &mut b.pressure_size, tl!("Pressure for Size"));
widgets::toggle(ui, &mut b.pressure_opacity, tl!("Pressure for Opacity"));
}
Tool::MixerBrush => {
picked = brush_preset_chip(ui, b, &app.session.tools.presets);
crate::brush_picker::settings_toggle(app, ui);
widgets::vline(ui, 22.0);
for (label, value) in [
(tl!("Wet"), &mut b.mixer.wet),
(tl!("Load"), &mut b.mixer.load),
(tl!("Mix"), &mut b.mixer.mix),
(tl!("Flow"), &mut b.mixer.flow),
] {
percent_field(ui, label, value, 0.0..=100.0, 62.0);
}
widgets::vline(ui, 22.0);
widgets::checkbox(ui, &mut b.mixer.sample_all_layers, tl!("Sample All Layers"));
}
Tool::RectMarquee | Tool::EllipseMarquee if t.pro => {
ui.spacing_mut().item_spacing.x = 2.0;
for (i, (icon, tip)) in [
@@ -836,6 +827,14 @@ fn opt_label(ui: &mut egui::Ui, s: &str) {
ui.label(RichText::new(tl!(&text)).color(t.text_dim));
}
fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, width: f32) {
opt_label(ui, label);
let mut percent = *value * 100.0;
if widgets::value_field(ui, &mut percent, range, "%", width).changed() {
*value = percent / 100.0;
}
}
fn hint(ui: &mut egui::Ui, s: &str) {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(s).color(t.text_faint));
+7 -2
View File
@@ -134,7 +134,7 @@ fn square_cursor_sits_on_the_pixel_grid() {
}
#[test]
fn b_cycles_brush_and_pencil() {
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| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
@@ -152,13 +152,18 @@ fn b_cycles_brush_and_pencil() {
assert_eq!(h.state().ui.tool, Tool::Brush);
press(&mut h, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::Pencil);
press(&mut h, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::MixerBrush);
// Use Shift Key for Tool Switch: ⇧B cycles, B keeps the group's tool.
h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true);
press(&mut h, Modifiers::SHIFT);
assert_eq!(h.state().ui.tool, Tool::Brush);
press(&mut h, Modifiers::SHIFT);
assert_eq!(h.state().ui.tool, Tool::Pencil);
press(&mut h, Modifiers::SHIFT);
assert_eq!(h.state().ui.tool, Tool::MixerBrush);
press(&mut h, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::Pencil);
assert_eq!(h.state().ui.tool, Tool::MixerBrush);
assert_eq!(Tool::from_name("pencil"), Some(Tool::Pencil));
assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush));
}
+50 -3
View File
@@ -1,6 +1,6 @@
//! UI for the retouching tools (healing, clone, history brush, blur/sharpen/smudge, dodge/burn/
//! sponge) and the smart selection tools (Quick Selection, Object Selection): gesture → engine
//! command, options bars, and the clone-source marker.
//! sponge, Mixer Brush) and the smart selection tools (Quick Selection, Object Selection): gesture
//! → engine command, options bars, and the clone-source marker.
use egui::{Color32, Stroke, vec2};
use serde_json::{Value, json};
@@ -16,6 +16,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
let pts = json!(points);
let (cmd, mut p): (&str, Value) = match tool {
Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type})),
Tool::MixerBrush => ("paint.mixerBrush", json!({})),
Tool::Healing | Tool::CloneStamp => {
let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample});
// The Clone Source panel's active slot (set by ⌥-click) drives the stroke: the engine
@@ -137,7 +138,9 @@ fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32) {
/// Options bar for the retouching and smart-selection tools. Returns false for other tools.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool {
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection) || matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) {
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection)
|| matches!(tool, Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser)
{
return false;
}
let o = &mut app.ui.tool_options;
@@ -293,4 +296,48 @@ mod tests {
}
}
}
#[test]
fn mixer_brush_is_selectable_and_paints_only_inside_the_selection_via_control() {
use crate::control::{ControlRequest, Outcome, handle};
let mut app = app();
app.run("paint.pencil", json!({"points": [[50, 30]], "size": 200, "color": "#204080"})).unwrap();
app.run("tools.setColors", json!({"foreground": "#f02010"})).unwrap();
app.run(
"tools.setBrush",
json!({
"pressureSize": false,
"size": 12,
"mixer": {"wet": 0.0, "load": 1.0, "mix": 0.0, "flow": 1.0}
}),
)
.unwrap();
app.run("select.rect", json!({"x": 35, "y": 20, "width": 30, "height": 20})).unwrap();
let before = {
let surface = active(&app).surface().unwrap();
[surface.rgba(20, 30), surface.rgba(50, 30), surface.rgba(80, 30)]
};
let ctx = egui::Context::default();
let (req, _rx) = ControlRequest::new(
"ui.pointer",
json!({
"tool": "mixerBrush",
"events": [
{"kind": "down", "x": 8, "y": 30},
{"kind": "move", "x": 92, "y": 30},
{"kind": "up", "x": 92, "y": 30}
]
}),
);
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
assert_eq!(app.ui.tool, Tool::MixerBrush);
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("paint.mixerBrush"));
let surface = active(&app).surface().unwrap();
assert_ne!(surface.rgba(50, 30), before[1], "the selected pixels are mixed");
assert_eq!(surface.rgba(20, 30), before[0], "outside the selection is unchanged");
assert_eq!(surface.rgba(80, 30), before[2], "the far side outside the selection is unchanged");
}
}
+9 -2
View File
@@ -18,6 +18,7 @@ pub enum Tool {
Count,
Brush,
Pencil,
MixerBrush,
Eraser,
BackgroundEraser,
MagicEraser,
@@ -51,7 +52,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 43] = [
pub const ALL: [Tool; 44] = [
Tool::Move,
Tool::RectMarquee,
Tool::EllipseMarquee,
@@ -65,6 +66,7 @@ impl Tool {
Tool::Count,
Tool::Brush,
Tool::Pencil,
Tool::MixerBrush,
Tool::Eraser,
Tool::BackgroundEraser,
Tool::MagicEraser,
@@ -104,6 +106,7 @@ impl Tool {
Tool::EllipseMarquee => "Elliptical Marquee Tool",
Tool::Brush => "Brush Tool",
Tool::Pencil => "Pencil Tool",
Tool::MixerBrush => "Mixer Brush Tool",
Tool::Eraser => "Eraser Tool",
Tool::BackgroundEraser => "Background Eraser Tool",
Tool::MagicEraser => "Magic Eraser Tool",
@@ -150,6 +153,7 @@ impl Tool {
self,
Tool::Brush
| Tool::Pencil
| Tool::MixerBrush
| Tool::Eraser
| Tool::BackgroundEraser
| Tool::SpotHealing
@@ -169,7 +173,7 @@ impl Tool {
match self {
Tool::Move => 'V',
Tool::RectMarquee | Tool::EllipseMarquee => 'M',
Tool::Brush | Tool::Pencil => 'B',
Tool::Brush | Tool::Pencil | Tool::MixerBrush => 'B',
Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E',
Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I',
Tool::Lasso | Tool::PolygonLasso => 'L',
@@ -197,6 +201,7 @@ impl Tool {
Tool::RectMarquee => "⬚",
Tool::EllipseMarquee => "◌",
Tool::Brush => "🖌",
Tool::MixerBrush => "🖌",
Tool::Eraser => "⌫",
Tool::Eyedropper => "💧",
Tool::Lasso | Tool::PolygonLasso => "L",
@@ -765,6 +770,8 @@ mod tests {
assert_eq!(Tool::from_name("Rect"), Some(Tool::RectMarquee));
assert_eq!(Tool::from_name("RectMarquee"), Some(Tool::RectMarquee));
assert_eq!(Tool::from_name("Eraser Tool"), Some(Tool::Eraser));
assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush));
assert_eq!(Tool::from_name("Mixer Brush Tool"), Some(Tool::MixerBrush));
assert_eq!(Tool::from_name("nope"), None);
}
+2 -2
View File
@@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 12 | 46 scenarios: 3 within budget, 22 over budget or failing, 13 tracked only, 4 not run, 8 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
| [Tools](#tools) | 4 | 14 | 12 | |
| [Tools](#tools) | 5 | 13 | 12 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 4 | 19 | settings that do nothing: 60 of 134 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
@@ -121,7 +121,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs |
| TOOL-213-2 | Mixer Brush tool on the toolbar | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.mixerBrush (brush_cmds.rs:337) + panel options; no Tool variant |
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests |
| TOOL-213-3 | Patch tool with a live preview of the patched area | missing | [#213](https://github.com/storytold/photocraft/issues/213) | No patch command or tool |
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | missing | [#213](https://github.com/storytold/photocraft/issues/213) | inpaint::complete only used by spot healing (retouch_cmds.rs:386) |
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |
+2 -2
View File
@@ -15,9 +15,9 @@ note = "Tool::Pencil (B group): live aliased strokes on the pixel grid, square c
[[item]]
id = "TOOL-213-2"
target = "Mixer Brush tool on the toolbar"
status = "partial"
status = "done"
issue = 213
note = "paint.mixerBrush (brush_cmds.rs:337) + panel options; no Tool variant"
note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests"
[[item]]
id = "TOOL-213-3"