mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
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:
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);
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user