Merge pull request #11919 from davidmann4/fix/11819-brush-picker-gear-menu

Brush picker: gear menu entries match the reference application (#11819)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-09 15:46:37 +01:00
commit c1be93ab16
7 files changed
+407 -60

No files matched your search

@@ -0,0 +1,208 @@
//! The Brush Preset picker's gear menu: Photoshop's entries in its order with its separators, the
//! picker's defaults (Brush Tip off, Show Search Bar on) and which entries are greyed (#11819).
use egui::accesskit::Role;
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use super::*;
use crate::state::Tool;
/// Photoshop's gear menu, measured top to bottom; "―" is a separator.
const MEASURED: &[&str] = &[
"New Brush Preset…",
"New Brush Group…",
"―",
"Rename Brush…",
"Delete Brush…",
"―",
"Brush Name",
"Brush Stroke",
"Brush Tip",
"―",
"Show Additional Preset Info",
"―",
"Show Search Bar",
"Show Recent Brushes",
"―",
"Append Default Brushes…",
"Import Brushes…",
"Export Selected Brushes…",
"―",
"Get More Brushes…",
"―",
"Converted Legacy Tool Presets",
"Legacy Brushes",
];
/// The entries a click works on with a preset selected; every other one is greyed.
const LIVE: &[&str] = &["New Brush Preset…", "Rename Brush…", "Delete Brush…", "Brush Name", "Brush Stroke", "Brush Tip", "Show Search Bar", "Import Brushes…"];
/// The entries that act on the selected preset.
const NEEDS_PRESET: &[&str] = &["Rename Brush…", "Delete Brush…", "Export Selected Brushes…"];
fn harness() -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.ui.tool = Tool::Brush;
app.sync_views();
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.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));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
h
}
/// [`harness`] with the first preset picked, as after a click on it.
fn harness_with_preset() -> Harness<'static, PhotocraftApp> {
let mut h = harness();
let first = h.state().session.tools.presets.first().map(|p| p.name.clone()).expect("the default presets");
h.state_mut().run("tools.setBrush", json!({ "preset": first })).unwrap();
h.run_steps(2);
h
}
/// Open the picker near the canvas's top left (room below for the whole menu), then its gear.
fn open_gear(h: &mut Harness<'static, PhotocraftApp>) {
let r = h.state().last_canvas_rect;
h.state_mut().ui.brush_picker = Some([r.left() + 20.0, r.top() + 10.0]);
h.run_steps(2);
h.get_by_label("Brush Preset Options").click();
h.run_steps(2);
}
fn entries() -> Vec<&'static str> {
GEAR_MENU.iter().filter_map(|e| e.map(Gear::label)).collect()
}
#[test]
fn the_gear_menu_lists_the_reference_entries_in_order_with_its_separators() {
let menu: Vec<&str> = GEAR_MENU.iter().map(|e| e.map_or("―", Gear::label)).collect();
assert_eq!(menu, MEASURED);
// The menu drawn: every entry once, top to bottom in that order.
let mut h = harness();
open_gear(&mut h);
let tops: Vec<f32> = entries().iter().map(|l| h.get_by_label(l).rect().top()).collect();
assert!(tops.windows(2).all(|w| w[0] < w[1]), "drawn top to bottom in the measured order: {tops:?}");
assert!(h.query_all_by_label("Delete Brush").next().is_none(), "Delete Brush… has its ellipsis");
}
#[test]
fn the_picker_starts_without_the_brush_tip_and_with_the_search_bar() {
let h = harness();
let st = &h.state().ui.brush_picker_list;
assert!(st.show_name && st.show_stroke && !st.show_tip, "Brush Name and Brush Stroke on, Brush Tip off");
assert!(st.show_search, "Show Search Bar is on");
// The Brushes panel keeps its own default.
assert!(h.state().ui.brushes_panel.show_tip);
// The menu shows the same, and the placeholders keep Photoshop's checks.
let mut h = harness();
open_gear(&mut h);
for (label, on) in [
("Brush Name", true),
("Brush Stroke", true),
("Brush Tip", false),
("Show Additional Preset Info", true),
("Show Search Bar", true),
("Show Recent Brushes", true),
] {
let checked = h.get_by_label(label).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True);
assert_eq!(checked, on, "{label}");
}
}
#[test]
fn a_saved_brush_tip_still_wins_and_an_empty_card_gets_the_defaults_back() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.prefs.edit(|p| p.brush_picker = json!({"showTip": true, "showSearch": false}));
restore(&mut app);
assert!(app.ui.brush_picker_list.show_tip && !app.ui.brush_picker_list.show_search);
app.session.prefs.edit(|p| p.brush_picker = json!({"showName": false, "showStroke": false, "showTip": false}));
app.ui.brush_picker_list = list_state();
restore(&mut app);
let st = &app.ui.brush_picker_list;
assert_eq!((st.show_name, st.show_stroke, st.show_tip), (true, true, false), "the at-least-one-part rule restores the defaults");
}
#[test]
fn show_search_bar_hides_the_search_field_and_is_remembered() {
let mut h = harness();
open_gear(&mut h);
let fields = |h: &Harness<'static, PhotocraftApp>| h.query_all_by_role(Role::TextInput).count();
let shown = fields(&h);
h.state_mut().ui.brush_picker_list.filter = "zzz".into();
h.get_by_label("Show Search Bar").click();
h.run_steps(3);
assert!(!h.state().ui.brush_picker_list.show_search);
assert_eq!(fields(&h), shown - 1, "the search field goes");
assert!(h.state().ui.brush_picker_list.filter.is_empty(), "a hidden search filters nothing");
let ctx = h.ctx.clone();
persist(h.state_mut(), &ctx);
assert_eq!(h.state().session.prefs().brush_picker["showSearch"], json!(false));
h.get_by_label("Show Search Bar").click();
h.run_steps(3);
assert!(h.state().ui.brush_picker_list.show_search);
assert_eq!(fields(&h), shown, "and comes back");
}
#[test]
fn entries_without_a_function_and_preset_entries_without_a_preset_are_greyed() {
let mut h = harness_with_preset();
open_gear(&mut h);
for label in entries() {
assert_eq!(!h.get_by_label(label).accesskit_node().is_disabled(), LIVE.contains(&label), "{label} with a preset selected");
}
// A placeholder does nothing when clicked.
let before = (h.state().session.tools.presets.len(), h.state().ui.dialogs.len());
h.get_by_label("New Brush Group…").click();
h.run_steps(2);
assert_eq!((h.state().session.tools.presets.len(), h.state().ui.dialogs.len()), before);
// With no preset selected the preset's entries grey too.
let mut h = harness();
assert!(h.state().session.tools.current_preset.is_none(), "a fresh session has no preset picked");
open_gear(&mut h);
for label in entries() {
let live = LIVE.contains(&label) && !NEEDS_PRESET.contains(&label);
assert_eq!(!h.get_by_label(label).accesskit_node().is_disabled(), live, "{label} with no preset selected");
}
}
#[test]
fn delete_brush_still_deletes_the_selected_preset() {
let mut h = harness_with_preset();
let name = h.state().session.tools.current_preset.clone().expect("a preset is selected");
open_gear(&mut h);
h.get_by_label("Delete Brush…").click();
h.run_steps(3);
assert!(!h.state().session.tools.presets.iter().any(|p| p.name == name), "{name} is deleted");
}
#[test]
fn two_clicks_in_one_frame_still_leave_one_card_part_on() {
let mut h = harness();
open_gear(&mut h);
// Brush Tip is off; both remaining parts get an assistive-technology click in the same frame
// (kittest's own clicks each run a frame of their own, so the events go in by hand).
let clicks: Vec<_> = ["Brush Name", "Brush Stroke"]
.into_iter()
.map(|label| {
let (target_node, target_tree) = h.get_by_label(label).accesskit_node().locate();
egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest { target_node, target_tree, action: egui::accesskit::Action::Click, data: None })
})
.collect();
h.input_mut().events.extend(clicks);
h.run_steps(3);
let st = &h.state().ui.brush_picker_list;
assert_eq!((st.show_name, st.show_stroke, st.show_tip), (false, true, false), "the last part on stays on");
}
@@ -423,11 +423,15 @@ fn picker_gear_menu_saves_and_names_a_new_preset() {
assert_eq!(h.state().session.tools.current_preset.as_deref(), Some("Inky"));
h.state_mut().ui.brush_picker = Some([c.x, c.y]);
h.run_steps(2);
// The gear's boxes pick the card parts; the last one on can't be turned off.
// The gear's boxes pick the card parts; the last one on can't be turned off. Brush Tip
// starts off (#11819).
h.get_by_label("Brush Preset Options").click();
h.run_steps(2);
h.get_by_label("Brush Tip").click();
h.run_steps(2);
assert!(h.state().ui.brush_picker_list.show_tip);
h.get_by_label("Brush Tip").click();
h.run_steps(2);
assert!(!h.state().ui.brush_picker_list.show_tip);
h.get_by_label("Brush Stroke").click();
h.run_steps(2);
+4 -4
View File
@@ -1116,7 +1116,7 @@ fn ui_set_opens_the_brush_preset_picker_and_sets_its_cards() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let list = &app.ui.brush_picker_list;
assert!(list.show_name && list.show_stroke && list.show_tip, "every card part is on by default");
assert!(list.show_name && list.show_stroke && !list.show_tip, "the cards show the name and the stroke by default");
let r = call(&mut app, &ctx, "ui.set", json!({"tool": "brush", "brushPicker": [120, 80], "brushPickerStroke": false}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(app.ui.brush_picker, Some([120.0, 80.0]));
@@ -1160,11 +1160,11 @@ fn ui_inspect_tracks_brush_picker_open_and_closed() {
assert!(open["result"]["brushPicker"]["pos"].is_array(), "{open}");
assert_eq!(open["result"]["brushPicker"]["list"]["showName"], true);
assert_eq!(open["result"]["brushPicker"]["list"]["showStroke"], true);
assert_eq!(open["result"]["brushPicker"]["list"]["showTip"], true);
assert_eq!(open["result"]["brushPicker"]["list"]["showTip"], false);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPickerTip": false}))["ok"], true);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPickerTip": true}))["ok"], true);
let changed = call(&mut app, &ctx, "ui.inspect", json!({}));
assert_eq!(changed["result"]["brushPicker"]["list"]["showTip"], false);
assert_eq!(changed["result"]["brushPicker"]["list"]["showTip"], true);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPicker": null}))["ok"], true);
let closed_again = call(&mut app, &ctx, "ui.inspect", json!({}));
+3
View File
@@ -98,6 +98,8 @@ pub struct BrushesPanelState {
pub show_stroke: bool,
/// The Brush Preset picker's cards: show the tip thumbnail.
pub show_tip: bool,
/// The Brush Preset picker's search field (its gear menu's Show Search Bar).
pub show_search: bool,
/// The Brush Preset picker's cards and the Brushes tab's grid cells and list rows: their size
/// scale, from the picker's footer slider (`crate::brush_picker::body`) or the tab's preview-size
/// slider. 1 is the standard size; the cards' width, height and
@@ -117,6 +119,7 @@ impl Default for BrushesPanelState {
show_name: true,
show_stroke: true,
show_tip: true,
show_search: true,
scale: 1.0,
}
}
@@ -188,6 +188,8 @@ fn brush_picker_cards_follow_the_part_boxes() {
h.state_mut().run("file.new", serde_json::json!({"width": 400, "height": 300})).unwrap();
h.state_mut().ui.tool = crate::state::Tool::Brush;
h.state_mut().ui.brush_picker = Some([300.0, 120.0]);
// Every part on (Brush Tip starts off).
h.state_mut().ui.brush_picker_list.show_tip = true;
h.run_steps(8);
let presets = h.state().session.tools.presets.clone();
let cards = |h: &Harness<'_, PhotocraftApp>| -> Vec<Rect> {
+4 -4
View File
@@ -1019,15 +1019,15 @@ fn brush_picker_view_survives_a_restart() {
let (mut app2, _) = app_with_saved(Some(saved));
tick(&mut app2, &ctx);
assert!(!app2.ui.brush_picker_list.show_stroke);
assert!(app2.ui.brush_picker_list.show_name && app2.ui.brush_picker_list.show_tip);
assert!(app2.ui.brush_picker_list.show_name && !app2.ui.brush_picker_list.show_tip);
assert!((app2.ui.brush_picker_list.scale - 0.4).abs() < 1e-6);
// A hand-edited file: the scale is clamped to the slider's range and the last part comes
// back on — an empty card would show no brush at all.
// A hand-edited file: the scale is clamped to the slider's range and the
// parts come back as they start — an empty card would show no brush at all.
let bad = json!({"brushPicker": {"showName": false, "showStroke": false, "showTip": false, "scale": 9.0}}).to_string();
let (mut app3, _) = app_with_saved(Some(bad));
tick(&mut app3, &ctx);
assert_eq!(app3.ui.brush_picker_list.scale, *crate::brush_picker::SCALE_RANGE.end());
assert!(app3.ui.brush_picker_list.show_name && app3.ui.brush_picker_list.show_stroke && app3.ui.brush_picker_list.show_tip);
assert!(app3.ui.brush_picker_list.show_name && app3.ui.brush_picker_list.show_stroke && !app3.ui.brush_picker_list.show_tip);
}
#[test]
+181 -51
View File
@@ -7,13 +7,13 @@
//! click picks a preset and keeps the picker open, a double-click picks it and closes the picker
//! (#1031); drag to reorder, right-click to rename or delete. Each card is a 2 × 2 grid — tip at
//! the top left, stroke preview at the top right and the name in the merged bottom row (or in the
//! stroke's cell when only the tip and the name show) — and the gear's three boxes ([`body`]'s
//! Brush Name/Brush Stroke/Brush Tip) hide its parts, at least one always on; the footer slider
//! scales the cards. Its view state (search, collapsed groups, the card's parts, the footer
//! scale, a rename in progress) is `UiState::brush_picker_list`, so the control channel can read
//! and set it. The gear's parts and the footer scale are remembered across restarts
//! ([`persist`]/[`restore`], `Preferences::brush_picker`); the search, collapsed groups and a
//! rename in progress are not.
//! stroke's cell when only the tip and the name show) — and the gear's three boxes ([`GEAR_MENU`]'s
//! Brush Name/Brush Stroke/Brush Tip; the tip off at first, as in Photoshop) hide its parts, at
//! least one always on; the footer slider scales the cards. Its view state (search, collapsed
//! groups, the card's parts, the search bar, the footer scale, a rename in progress) is
//! `UiState::brush_picker_list`, so the control channel can read and set it. The gear's parts,
//! Show Search Bar and the footer scale are remembered across restarts ([`persist`]/[`restore`],
//! `Preferences::brush_picker`); the search, collapsed groups and a rename in progress are not.
//!
//! The picker edits a copy of the brush; the caller sends the size and hardness edits through
//! `tools.setBrush` ([`crate::brush_panel::commit_gesture`]) and applies the returned [`Pick`]s
@@ -151,16 +151,18 @@ pub fn close(ui: &mut crate::state::UiState) {
ui.brush_picker_list.renaming = None;
}
/// The state the picker's preset list starts from: every card part on (tip, stroke and name).
/// The state the picker's preset list starts from: the cards show the name and the stroke, not the
/// tip, and the search bar shows (Photoshop's defaults). The Brushes panel keeps
/// [`BrushesPanelState::default`].
pub fn list_state() -> BrushesPanelState {
BrushesPanelState::default()
BrushesPanelState { show_tip: false, ..BrushesPanelState::default() }
}
/// The picker's view remembered across restarts: the gear's three card parts and the footer
/// scale (`Preferences::brush_picker`).
/// The picker's view remembered across restarts: the gear's three card parts, Show Search Bar and
/// the footer scale (`Preferences::brush_picker`).
fn view(app: &PhotocraftApp) -> Value {
let st = &app.ui.brush_picker_list;
json!({"showName": st.show_name, "showStroke": st.show_stroke, "showTip": st.show_tip, "scale": st.scale})
json!({"showName": st.show_name, "showStroke": st.show_stroke, "showTip": st.show_tip, "showSearch": st.show_search, "scale": st.scale})
}
/// Remember the picker's view in the preferences once the user lets go of the pointer: the
@@ -179,7 +181,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) {
/// Restore the picker's remembered view at launch. A missing or malformed part keeps its
/// default, the scale is clamped to the slider's range, and the three parts can't all come
/// back off (an empty card would show no brush at all).
/// back off (an empty card would show no brush at all): then they get [`list_state`]'s.
pub fn restore(app: &mut PhotocraftApp) {
let saved = app.session.prefs().brush_picker.clone();
let st = &mut app.ui.brush_picker_list;
@@ -192,13 +194,17 @@ pub fn restore(app: &mut PhotocraftApp) {
if let Some(v) = saved.get("showTip").and_then(Value::as_bool) {
st.show_tip = v;
}
if let Some(v) = saved.get("showSearch").and_then(Value::as_bool) {
st.show_search = v;
}
if let Some(v) = saved.get("scale").and_then(Value::as_f64)
&& v.is_finite()
{
st.scale = (v as f32).clamp(*SCALE_RANGE.start(), *SCALE_RANGE.end());
}
if ![st.show_name, st.show_stroke, st.show_tip].into_iter().any(|b| b) {
(st.show_name, st.show_stroke, st.show_tip) = (true, true, true);
let d = list_state();
(st.show_name, st.show_stroke, st.show_tip) = (d.show_name, d.show_stroke, d.show_tip);
}
}
@@ -251,7 +257,8 @@ pub(crate) fn named(resp: egui::Response, label: &str) -> egui::Response {
}
/// The picker's contents: Size, Hardness (round tips only: a sampled tip has none), a search
/// field with the Brush Settings, New Preset and gear buttons, the preset list, and the footer
/// field (unless the gear's Show Search Bar is off) with the Brush Settings, New Preset and gear
/// buttons, the preset list, and the footer
/// slider that scales the cards ([`scale_slider`]). Size and Hardness sit in a block flush with
/// the left edge, at most [`HEAD_MAX_W`] wide, so a picker dragged wider doesn't stretch them.
/// `current` is the selected preset's name (by identity, so edits don't deselect). `size` is the
@@ -316,10 +323,21 @@ pub fn body(
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
// A hidden search bar filters nothing: a search typed before hiding it would otherwise keep
// presets out of the list with no field to show why.
if !st.show_search {
st.filter.clear();
}
ui.horizontal(|ui| {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
widgets::text_field(ui, ui.scope_id().with("brush-picker-search"), &mut st.filter, tl!("Search Brushes"), (size.x - 118.0).max(60.0), false);
// In a scope of its own whether shown or not, so the buttons after it keep their ids and
// the gear's menu stays open when Show Search Bar takes the field away.
ui.scope(|ui| {
if st.show_search {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
widgets::text_field(ui, ui.scope_id().with("brush-picker-search"), &mut st.filter, tl!("Search Brushes"), (size.x - 118.0).max(60.0), false);
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
gear_menu(ui, current, presets, st, &mut picks);
let new_tip = "Create new brush preset from the current settings";
@@ -412,8 +430,103 @@ pub fn resize_grip(ui: &mut egui::Ui, frame: egui::Rect, content: egui::Vec2) ->
})
}
/// The gear: Photoshop's picker menu. A new preset, rename or delete the current one, the list's
/// view, and Import Brushes.
/// An entry of the gear menu ([`GEAR_MENU`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Gear {
NewPreset,
NewGroup,
Rename,
Delete,
Name,
Stroke,
Tip,
AdditionalInfo,
SearchBar,
RecentBrushes,
AppendDefaults,
Import,
ExportSelected,
GetMore,
LegacyToolPresets,
LegacyBrushes,
}
impl Gear {
pub(crate) fn label(self) -> &'static str {
match self {
Gear::NewPreset => "New Brush Preset…",
Gear::NewGroup => "New Brush Group…",
Gear::Rename => "Rename Brush…",
Gear::Delete => "Delete Brush…",
Gear::Name => "Brush Name",
Gear::Stroke => "Brush Stroke",
Gear::Tip => "Brush Tip",
Gear::AdditionalInfo => "Show Additional Preset Info",
Gear::SearchBar => "Show Search Bar",
Gear::RecentBrushes => "Show Recent Brushes",
Gear::AppendDefaults => "Append Default Brushes…",
Gear::Import => "Import Brushes…",
Gear::ExportSelected => "Export Selected Brushes…",
Gear::GetMore => "Get More Brushes…",
Gear::LegacyToolPresets => "Converted Legacy Tool Presets",
Gear::LegacyBrushes => "Legacy Brushes",
}
}
/// Does this entry act on the selected preset (greyed with none selected)?
pub(crate) fn needs_preset(self) -> bool {
matches!(self, Gear::Rename | Gear::Delete | Gear::ExportSelected)
}
/// Is this entry only listed, its function still to come? It is greyed and does nothing.
/// New Brush Group…, Append Default Brushes…, Export Selected Brushes… and Legacy Brushes come
/// with the Brushes panel's library actions (#7450), Show Recent Brushes with the picker's
/// recent-brush bar (#7452). What Show Additional Preset Info, Get More Brushes… and Converted
/// Legacy Tool Presets do is not measured yet (#11819).
pub(crate) fn placeholder(self) -> bool {
matches!(
self,
Gear::NewGroup
| Gear::AdditionalInfo
| Gear::RecentBrushes
| Gear::AppendDefaults
| Gear::ExportSelected
| Gear::GetMore
| Gear::LegacyToolPresets
| Gear::LegacyBrushes
)
}
}
/// Photoshop's picker gear menu, top to bottom; `None` is a separator.
pub(crate) const GEAR_MENU: &[Option<Gear>] = &[
Some(Gear::NewPreset),
Some(Gear::NewGroup),
None,
Some(Gear::Rename),
Some(Gear::Delete),
None,
Some(Gear::Name),
Some(Gear::Stroke),
Some(Gear::Tip),
None,
Some(Gear::AdditionalInfo),
None,
Some(Gear::SearchBar),
Some(Gear::RecentBrushes),
None,
Some(Gear::AppendDefaults),
Some(Gear::Import),
Some(Gear::ExportSelected),
None,
Some(Gear::GetMore),
None,
Some(Gear::LegacyToolPresets),
Some(Gear::LegacyBrushes),
];
/// The gear: Photoshop's picker menu ([`GEAR_MENU`]). A new preset, rename or delete the current
/// one, the cards' parts, the search bar and Import Brushes; the entries still to come are greyed.
fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset], st: &mut BrushesPanelState, picks: &mut Vec<Pick>) {
let gear = named(icons::button(ui, "settings", 24.0, false, tl!("Brush Preset Options")), "Brush Preset Options");
// The menu hangs 30 pt right of the gear, off the button's own edge; the align fallbacks still
@@ -423,44 +536,61 @@ fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset],
other => other,
};
egui::Popup::menu(&gear).anchor(anchor).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if ui.button(tl!("New Brush Preset…")).clicked() {
picks.push(Pick::NewPreset);
ui.close();
}
ui.separator();
let current = current.filter(|n| presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(str::to_string);
ui.add_enabled_ui(current.is_some(), |ui| {
if ui.button(tl!("Rename Brush…")).clicked()
&& let Some(name) = current.clone()
{
st.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() });
ui.close();
}
if ui.button(tl!("Delete Brush")).clicked()
&& let Some(name) = current.clone()
{
picks.push(Pick::List(Action::Delete(name)));
ui.close();
}
});
ui.separator();
// What a preset's card shows: a checkbox per part. At least one stays on — clicking the
// last checked box does nothing, since an empty card would show no brush at all.
let on = [st.show_name, st.show_stroke, st.show_tip].into_iter().filter(|b| *b).count();
for (flag, label) in [(&mut st.show_name, tl!("Brush Name")), (&mut st.show_stroke, tl!("Brush Stroke")), (&mut st.show_tip, tl!("Brush Tip"))] {
let was = *flag;
if ui.checkbox(flag, label).changed() && was && on == 1 {
*flag = true;
// last checked box does nothing, since an empty card would show no brush at all. Counted
// before each box, so two clicks in one frame cannot turn off the last two parts.
for item in GEAR_MENU {
let Some(item) = *item else {
ui.separator();
continue;
};
let label = tl!(item.label());
match item {
Gear::Name | Gear::Stroke | Gear::Tip => {
let on = [st.show_name, st.show_stroke, st.show_tip].into_iter().filter(|b| *b).count();
let flag = match item {
Gear::Name => &mut st.show_name,
Gear::Stroke => &mut st.show_stroke,
_ => &mut st.show_tip,
};
let was = *flag;
if ui.checkbox(flag, label).changed() && was && on == 1 {
*flag = true;
}
}
Gear::SearchBar => {
ui.checkbox(&mut st.show_search, label);
}
// Checked by default in Photoshop; greyed until their function lands.
Gear::AdditionalInfo | Gear::RecentBrushes => {
ui.add_enabled(false, egui::Checkbox::new(&mut true, label));
}
_ => {
let enabled = !item.placeholder() && (!item.needs_preset() || current.is_some());
if !ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
continue;
}
match (item, current.clone()) {
(Gear::NewPreset, _) => picks.push(Pick::NewPreset),
(Gear::Import, _) => picks.push(Pick::Import),
(Gear::Rename, Some(name)) => {
st.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() });
}
(Gear::Delete, Some(name)) => picks.push(Pick::List(Action::Delete(name))),
_ => {}
}
ui.close();
}
}
}
ui.separator();
if ui.button(tl!("Import Brushes…")).clicked() {
picks.push(Pick::Import);
ui.close();
}
});
}
#[cfg(test)]
#[path = "../brush_picker_gear_menu_tests.rs"]
mod gear_menu_tests;
#[cfg(test)]
#[path = "../brush_picker_angle_widget_tests.rs"]
mod brush_picker_angle_widget_tests;