Addition : Brush popup better UI (#2217)

* Brush name/tip/stroke cards

+ locale

* brush popup scale ui

scale brushes

* align fix + tests

* test fix

* ci fix

* ci fix 2

* don't scale text

* smaller width for tip+name

* scale + spacing + better UX

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: JJ <jj@dot.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
siraxe
2026-10-10 07:04:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 JJ Brandon Thomas
parent d99562f038
commit 4e8aaec643
30 changed files with 859 additions and 128 deletions
+5 -2
View File
@@ -229,8 +229,11 @@ pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark), brushSection, brushTab, brushesView, brushPicker ([x, y] opens the
/// Brush Preset picker there, null closes it), brushPickerView, brushSize, gradientBlendMode
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark), brushSection, brushTab,
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),
/// brushSize, gradientBlendMode
/// (a blend mode name, for the Gradient tool), gradientClassic (bool), eyedropperSampleSize
/// ("point" or 1, 3, 5, 11, 31, 51, 101), eyedropperSample (current, currentAndBelow, all,
/// allNoAdjustments, currentAndBelowNoAdjustments), eyedropperRing (bool), cropOverlay (thirds,
+4
View File
@@ -914,6 +914,10 @@ pub struct Preferences {
/// The last choices of dialogs that remember them across restarts, by command id (Edit ›
/// Fill…: `"edit.fill"` → its params). JSON owned by the shell.
pub dialogs: BTreeMap<String, Value>,
/// The Brush Preset picker's remembered view: which card parts show (name, stroke, tip) and
/// the footer slider's card scale, saved as the user changes them and restored at launch.
/// JSON owned by the shell.
pub brush_picker: Value,
/// File › Scripts › Script Events Manager: event → script bindings.
pub script_events: crate::automate_cmds::ScriptEvents,
}
+29 -1
View File
@@ -74,7 +74,7 @@ pub struct Renaming {
}
/// Brushes panel view state (serde, so the control channel can read and drive it).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct BrushesPanelState {
/// Collapsed groups and folders: a group's label, or for a nested folder the group's label and
@@ -84,6 +84,34 @@ pub struct BrushesPanelState {
pub filter: String,
pub view: BrushesView,
pub renaming: Option<Renaming>,
/// The Brush Preset picker's cards: show the preset's name. At least one of the three parts
/// is always on, and the picker's cards never go empty (see [`crate::brush_picker`]).
pub show_name: bool,
/// The Brush Preset picker's cards: show the stroke preview.
pub show_stroke: bool,
/// The Brush Preset picker's cards: show the tip thumbnail.
pub show_tip: bool,
/// The Brush Preset picker's cards: their size scale, from the picker's footer slider
/// (`crate::brush_picker::body`). 1 is the standard size; the cards' width, height and
/// insides multiply by it (`crate::brushes_tab`) — the height down to a 0.50 floor, the
/// width all the way — the text holds its 1.0 size, and at or below the compact threshold
/// the tips drop the size number under them and take their cell whole.
pub scale: f32,
}
impl Default for BrushesPanelState {
fn default() -> Self {
Self {
collapsed: Vec::new(),
filter: String::new(),
view: BrushesView::default(),
renaming: None,
show_name: true,
show_stroke: true,
show_tip: true,
scale: 1.0,
}
}
}
/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing).
+3
View File
@@ -801,6 +801,9 @@ fn options_bar_reaches_brush_settings_and_the_preset_library() {
}
let target = presets.iter().find(|p| p.group == "Dry Media").or(presets.last()).unwrap().name.clone();
assert_ne!(h.state().session.tools.current_preset.as_deref(), Some(target.as_str()));
// The picker's cards are tall: scroll the target into the list before clicking it.
h.get_by_label(&target).scroll_to_me();
h.run_steps(3);
h.get_by_label(&target).click();
h.run_steps(3);
assert_eq!(last_journal(h.state()).unwrap(), ("tools.setBrush".to_string(), json!({ "preset": target })));
+228 -41
View File
@@ -3,30 +3,56 @@
//! field, the gear menu and the preset library in its groups); the full editor is the Brush
//! Settings panel (F5), which the picker and the options bar open with one click (#258).
//!
//! The preset list is the Brushes panel's ([`brushes_tab::preset_list`]): a 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. Its view state (search, collapsed groups, list or
//! grid, a rename in progress) is `UiState::brush_picker_list`, so the control channel can read
//! and set it.
//! The preset list is the Brushes panel's ([`brushes_tab::preset_list`]), always as cards: a
//! 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.
//!
//! 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
//! with [`apply`], so every change is a journaled command (Rule 1).
use egui::{RichText, vec2};
use egui::{CursorIcon, RichText, Stroke, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{BrushPreset, MAX_BRUSH_SIZE, TipShape};
use serde_json::json;
use serde_json::{Value, json};
use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, new_preset_name, run_or_status};
use crate::brush_panel::{BrushesPanelState, Renaming, new_preset_name, run_or_status};
use crate::brushes_tab::{self, Action, ListLayout};
use crate::theme::Tokens;
use crate::{PhotocraftApp, icons, widgets};
/// Width of the picker's contents.
pub const WIDTH: f32 = 300.0;
/// The picker's preset list: denser than the Brushes tab's.
pub(crate) const LIST: ListLayout = ListLayout { id: "brush-picker-presets", max_height: 300.0, cell: 44.0, indent: 4.0 };
/// The picker's content size before its grip is dragged, and the smallest it can be dragged to.
pub const DEFAULT_SIZE: [f32; 2] = [WIDTH, 420.0];
pub const MIN_SIZE: [f32; 2] = [230.0, 170.0];
/// The picker's preset list: denser than the Brushes tab's, and always the cards (its tip, stroke
/// and name cells, set by [`BrushesPanelState::show_name`] and friends).
pub(crate) const LIST: ListLayout = ListLayout { id: "brush-picker-presets", max_height: 300.0, cell: 44.0, indent: 4.0, cards: true };
/// The scale slider's height (the Size and Hardness sliders').
const SLIDER_H: f32 = 18.0;
/// The least gap between the preset list and the slider under it.
const FOOTER_GAP: f32 = 6.0;
/// The room the footer takes from the picker's bottom.
const FOOTER_H: f32 = SLIDER_H + FOOTER_GAP;
/// Room the footer leaves on its right for the resize grip ([`resize_grip`] is 16 wide): the
/// scale slider runs up to the grip's left edge, the two corner controls side by side.
const GRIP_ROOM: f32 = 20.0;
/// The block the Size and Hardness controls share, flush with the picker's left edge and at most
/// this wide: a picker dragged wider keeps the controls their size, the space past them empty.
pub(crate) const HEAD_MAX_W: f32 = 350.0;
/// The footer scale slider's range: what the cards' size scale runs between, and what a
/// remembered scale is clamped to (under the bottom the cards' padding and rows stop making
/// sense).
pub(crate) const SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.15..=2.0;
/// What the picker asks for beyond the size and hardness edits.
#[derive(Clone, Debug, PartialEq)]
@@ -66,9 +92,55 @@ pub fn close(ui: &mut crate::state::UiState) {
ui.brush_picker_list.renaming = None;
}
/// The picker's list view before it is changed: tip thumbnails, like Photoshop's picker.
/// The state the picker's preset list starts from: every card part on (tip, stroke and name).
pub fn list_state() -> BrushesPanelState {
BrushesPanelState { view: BrushesView::Grid, ..Default::default() }
BrushesPanelState::default()
}
/// The picker's view remembered across restarts: the gear's three card parts 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})
}
/// Remember the picker's view in the preferences once the user lets go of the pointer: the
/// gear's card parts and the footer scale. Cheap: a small JSON compare per frame. Run every
/// frame (not from the picker's own code) so a view set while the picker is closed — by the
/// control channel — is remembered too.
pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) {
if ctx.input(|i| i.pointer.any_down()) {
return;
}
let now = view(app);
if app.session.prefs().brush_picker != now {
app.session.prefs.edit(|p| p.brush_picker = now);
}
}
/// 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).
pub fn restore(app: &mut PhotocraftApp) {
let saved = app.session.prefs().brush_picker.clone();
let st = &mut app.ui.brush_picker_list;
if let Some(v) = saved.get("showName").and_then(Value::as_bool) {
st.show_name = v;
}
if let Some(v) = saved.get("showStroke").and_then(Value::as_bool) {
st.show_stroke = v;
}
if let Some(v) = saved.get("showTip").and_then(Value::as_bool) {
st.show_tip = 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);
}
}
/// Run what the picker asked for.
@@ -121,39 +193,70 @@ 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, and the preset list. `current` is
/// the selected preset's name (by identity, so edits don't deselect).
pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], current: Option<&str>, st: &mut BrushesPanelState) -> Vec<Pick> {
/// field 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
/// content size the user dragged the picker to ([`DEFAULT_SIZE`] before that): the list fills
/// it, so closing every group doesn't shrink the picker and reopening one has the room to show
/// it.
pub fn body(
ui: &mut egui::Ui,
b: &mut BrushSettings,
presets: &[BrushPreset],
current: Option<&str>,
st: &mut BrushesPanelState,
size: egui::Vec2,
) -> Vec<Pick> {
let t = Tokens::get(ui.ctx());
let mut picks = Vec::new();
ui.set_width(WIDTH);
// Size: value field plus a logarithmic slider (small sizes get most of the travel).
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Size")).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut size = b.size;
if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).changed() {
b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE);
}
// The picker is its dragged (or remembered) size: at least that tall even when every group
// is closed, so reopening one has the room to show it.
ui.set_width(size.x);
ui.set_min_height(size.y);
let top = ui.cursor().min.y;
// The Size and Hardness controls live in one block flush with the left edge, at most
// [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space past the block empty instead of
// stretching the rows across it.
ui.scope(|ui| {
ui.set_max_width(ui.available_width().min(HEAD_MAX_W));
// Size: value field plus a logarithmic slider (small sizes get most of the travel).
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Size")).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut size = b.size;
if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).changed() {
b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE);
}
});
});
});
let mut lv = b.size.max(1.0).ln();
if widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None).changed() {
b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE);
}
if b.tip == TipShape::Round {
let mut hard = b.hardness * 100.0;
if widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None).changed() {
b.hardness = (hard / 100.0).clamp(0.0, 1.0);
let mut lv = b.size.max(1.0).ln();
let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None);
if size_slider.changed() {
b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE);
}
}
dismiss_popup(&size_slider);
if b.tip == TipShape::Round {
let mut hard = b.hardness * 100.0;
let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None);
if hardness.changed() {
b.hardness = (hard / 100.0).clamp(0.0, 1.0);
}
dismiss_popup(&hardness);
}
});
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
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);
ui.add(egui::TextEdit::singleline(&mut st.filter).hint_text(tl!("Search Brushes")).desired_width(WIDTH - 118.0).id_salt("brush-picker-search"));
ui.add(
egui::TextEdit::singleline(&mut st.filter)
.hint_text(tl!("Search Brushes"))
.desired_width((size.x - 118.0).max(60.0))
.id_salt("brush-picker-search"),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
gear_menu(ui, current, presets, st, &mut picks);
if named(icons::button(ui, "square-plus", 24.0, false, "Create new brush preset from the current settings"), "New Brush Preset").clicked() {
@@ -166,15 +269,96 @@ pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], c
});
});
ui.add_space(4.0);
picks.extend(brushes_tab::preset_list(ui, presets, current, st, LIST).into_iter().map(Pick::List));
// The list takes the picker's height past the controls above it and the footer below.
let mut layout = LIST;
layout.max_height = (size.y - (ui.cursor().min.y - top) - FOOTER_H).max(80.0);
picks.extend(brushes_tab::preset_list(ui, presets, current, st, layout).into_iter().map(Pick::List));
// The footer lives in the picker's bottom band whatever the list left over, flush with the
// bottom and the slider clear of the resize grip's corner.
ui.add_space((size.y - (ui.cursor().min.y - top) - SLIDER_H).max(FOOTER_GAP));
scale_slider(ui, st);
picks
}
/// The footer's card-scale slider: how big the preset cards are drawn (1 the standard, 2 the
/// largest), bare like the Size and Hardness sliders — a readout would be noise for a control
/// that's set by feel. The value snaps to hundredths and runs over [`SCALE_RANGE`], from 0.15,
/// under which the cards' padding and rows stop making sense; 0.40 and below is where
/// [`brushes_tab`]'s cards go compact, the tips dropping their size numbers (the text itself
/// holds its size at every scale).
fn scale_slider(ui: &mut egui::Ui, st: &mut BrushesPanelState) {
let w = (ui.available_width() - GRIP_ROOM).max(60.0);
ui.allocate_ui_with_layout(vec2(w, SLIDER_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
let resp = widgets::slider(ui, &mut st.scale, SCALE_RANGE, None);
if resp.changed() {
st.scale = (st.scale * 100.0).round() / 100.0;
}
dismiss_popup(&resp);
});
}
/// Closes any open popup (the gear menu) as soon as `resp` is grabbed: a slider or grip drag never
/// counts as a click, so the menu's [`egui::PopupCloseBehavior::CloseOnClickOutside`] alone leaves
/// it up — and the picker's own interaction pulls its layer in front, sinking the menu behind it.
fn dismiss_popup(resp: &egui::Response) {
if resp.is_pointer_button_down_on() {
egui::Popup::close_all(&resp.ctx);
}
}
/// A remembered picker size, clamped for `screen` (a corrupt or wildly stale value falls back to
/// [`DEFAULT_SIZE`]).
pub fn clamp_size(size: [f32; 2], screen: egui::Vec2) -> egui::Vec2 {
if !size[0].is_finite() || !size[1].is_finite() {
return egui::vec2(DEFAULT_SIZE[0], DEFAULT_SIZE[1]);
}
let min = egui::vec2(MIN_SIZE[0], MIN_SIZE[1]);
let max = (screen - vec2(40.0, 40.0)).max(min);
egui::vec2(size[0], size[1]).clamp(min, max)
}
/// The picker's resize grip, in its frame's bottom-right corner: three ticks, dragging gives the
/// new content size (clamped to [`MIN_SIZE`] and the screen). Returns the size while dragged. The
/// corner tracks the pointer's travel since the drag began, not each frame's movement: held past
/// a limit (the minimum, the screen), the overshoot isn't banked, so coming back the picker grows
/// only once the pointer reaches the corner again — it waits for the pointer.
pub fn resize_grip(ui: &mut egui::Ui, frame: egui::Rect, content: egui::Vec2) -> Option<egui::Vec2> {
let t = Tokens::get(ui.ctx());
let grip = egui::Rect::from_min_size(frame.right_bottom() - vec2(16.0, 16.0), vec2(16.0, 16.0));
let resp = ui.interact(grip, ui.id().with("brush-picker-resize"), egui::Sense::drag());
dismiss_popup(&resp);
let corner = grip.right_bottom() - vec2(3.0, 3.0);
for i in 0..3 {
let o = i as f32 * 4.0;
ui.painter().line_segment([corner - vec2(o, 0.0), corner - vec2(0.0, o)], Stroke::new(1.0, t.text_faint));
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(CursorIcon::ResizeNwSe);
}
// The size this drag started from, kept while the button is held.
let start_id = resp.id.with("start");
if resp.drag_started() {
ui.ctx().data_mut(|d| d.insert_temp(start_id, content));
}
resp.dragged().then(|| {
let min = egui::vec2(MIN_SIZE[0], MIN_SIZE[1]);
let max = (ui.ctx().content_rect().size() - vec2(40.0, 40.0)).max(min);
let start: egui::Vec2 = ui.ctx().data_mut(|d| d.get_temp(start_id)).unwrap_or(content);
(start + resp.total_drag_delta().unwrap_or_default()).clamp(min, max)
})
}
/// The gear: Photoshop's picker menu. A new preset, rename or delete the current one, the list's
/// view, and Import Brushes.
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");
egui::Popup::menu(&gear).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
// The menu hangs 30 pt right of the gear, off the button's own edge; the align fallbacks still
// keep it on screen near the window's edge.
let anchor = match egui::PopupAnchor::from(&gear) {
egui::PopupAnchor::ParentRect(rect) => egui::PopupAnchor::ParentRect(rect.translate(vec2(30.0, 0.0))),
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();
@@ -196,10 +380,13 @@ fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset],
}
});
ui.separator();
for (view, label) in [(BrushesView::List, tl!("List view")), (BrushesView::Grid, tl!("Grid view"))] {
if ui.selectable_label(st.view == view, label).clicked() {
st.view = view;
ui.close();
// 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;
}
}
ui.separator();
+261 -13
View File
@@ -81,10 +81,14 @@ pub struct ListLayout {
/// Width of a grid cell (its height is 6 points more) and the grid's left indent.
pub cell: f32,
pub indent: f32,
/// The Brush Preset picker's cards: a tip cell and a stroke cell on top and the name across
/// the bottom (or, with only the tip and the name on, in the stroke's cell). Replaces the
/// list/grid choice in the picker.
pub cards: bool,
}
/// The Brushes tab's list.
pub const PANEL_LIST: ListLayout = ListLayout { id: "brush-presets", max_height: 400.0, cell: 52.0, indent: 20.0 };
pub const PANEL_LIST: ListLayout = ListLayout { id: "brush-presets", max_height: 400.0, cell: 52.0, indent: 20.0, cards: false };
/// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as
/// [`UNGROUPED`].
@@ -340,15 +344,19 @@ fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushP
/// The gap between grid cells, across and down.
const GRID_GAP: f32 = 3.0;
/// The picker's cards pack tighter than the grid: their horizontal gap (the vertical one stays
/// [`GRID_GAP`]).
const CARD_GAP: f32 = 1.0;
/// The grid's columns in a list `width` points wide: how many `cell`-wide cells fit past the
/// `indent`, and the left margin that centres them in the room past it. At least one column.
pub(crate) fn grid_columns(width: f32, cell: f32, indent: f32) -> (f32, usize) {
/// The grid's columns in a list `width` points wide: how many `cell`-wide cells separated by
/// `gap` fit past the `indent`, and the left margin that centres them in the room past it. At
/// least one column.
pub(crate) fn grid_columns(width: f32, cell: f32, indent: f32, gap: f32) -> (f32, usize) {
let room = if width.is_finite() { (width - indent).max(0.0) } else { 0.0 };
let cols = if cell.is_finite() && cell > 0.0 { ((room + GRID_GAP) / (cell + GRID_GAP)).floor() } else { 1.0 };
let cols = if cell.is_finite() && cell > 0.0 { ((room + gap) / (cell + gap)).floor() } else { 1.0 };
// `as` saturates (and NaN becomes 0), so this is a whole number from 1 to a sane bound.
let cols = (cols as usize).clamp(1, 256);
let used = cols as f32 * cell + (cols - 1) as f32 * GRID_GAP;
let used = cols as f32 * cell + (cols - 1) as f32 * gap;
// Whole points keep the tiles' edges crisp.
let left = indent + ((room - used) / 2.0).max(0.0).floor();
(if left.is_finite() { left } else { 0.0 }, cols)
@@ -383,6 +391,194 @@ fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[Brush
preset_interactions(ui, &resp, r, p, presets, true, acts);
}
/// Padding, tip cell width and name row height, at scale 1; the footer slider multiplies them
/// (heights down to [`CARD_MIN_H_SCALE`]).
const CARD_PAD: f32 = 4.0;
const CARD_TIP_W: f32 = 46.0;
/// The tip thumbnail's base size at scale 1, before the footer slider and the per-preset
/// [`crate::brush_sections::thumb_scale`]: the tips read small at 28 px, so the base carries a
/// 60 % bump.
const CARD_TIP_BASE: f32 = 28.0 * 1.6;
const CARD_NAME_H: f32 = 20.0;
/// A full card's height at scale 1: the tip/stroke row takes what the name row leaves. The
/// footer slider multiplies it down to [`CARD_MIN_H_SCALE`].
const CARD_MAX_H: f32 = 70.0;
/// The widest a lone card grows at scale 1; past it the room is shared between columns.
const CARD_MAX_W: f32 = 300.0;
/// Tip alone: a grid of small [`CARD_TIP_ONLY_W`] × [`CARD_TIP_ONLY_H`] cells (scale 1).
const CARD_TIP_ONLY_W: f32 = 60.0;
const CARD_TIP_ONLY_H: f32 = 70.0;
/// Tip + name, no stroke: a grid of [`CARD_TIP_NAME_W`] × [`CARD_TIP_NAME_H`] cells (scale 1).
const CARD_TIP_NAME_W: f32 = 150.0;
const CARD_TIP_NAME_H: f32 = 70.0;
/// The footer scale's floor for heights and padding (the widths keep shrinking).
const CARD_MIN_H_SCALE: f32 = 0.60;
/// At or below this scale the tips drop their size numbers and fill their cell whole.
const CARD_COMPACT_SCALE: f32 = 0.70;
/// stay readable from the smallest card to the largest.
const CARD_NAME_FONT: f32 = 12.0;
const CARD_NUM_FONT: f32 = 11.0;
/// The cards' own fill: [`Tokens::card`] (what the picker's popup is filled with) this much
/// darker, so the cards read as tiles against it.
const CARD_BG_SHADE: f32 = 0.90;
/// The picker's cards in a list `width` points wide, `indent` past its left edge: how wide each
/// card is and how many sit in a row. A lone card fills the room up to [`CARD_MAX_W`] × `scale`
/// (wider lists leave the rest empty); from two columns on, the cards share the room, filling it
/// fully. `scale` is the picker's footer slider ([`crate::brush_picker::body`]), already floored
/// so the widths never reach zero.
fn card_columns(width: f32, indent: f32, scale: f32) -> (f32, usize) {
let max_w = CARD_MAX_W * scale;
let room = if width.is_finite() { (width - indent).max(0.0) } else { CARD_MAX_W };
let cols = ((room / max_w).floor() as usize).clamp(1, 256);
let share = (room - CARD_GAP * (cols - 1) as f32) / cols as f32;
let card_w = if cols == 1 { room.min(max_w) } else { share };
(if card_w.is_finite() { card_w.max(1.0) } else { CARD_MAX_W }, cols)
}
/// Is the card showing the tip alone (no name, no stroke)? Then it's a small tip cell in a grid.
fn tip_only(show: (bool, bool, bool)) -> bool {
show == (false, false, true)
}
/// Is the card showing the tip and the name with the stroke off? Then it's a tip-and-name cell.
fn tip_and_name(show: (bool, bool, bool)) -> bool {
show == (true, false, true)
}
/// `c` darker by `f` in gamma space, kept opaque — [`Color32::gamma_multiply`] fades the alpha
/// along with the channels, so it can't darken a fill over its own color.
fn darker(c: Color32, f: f32) -> Color32 {
let [r, g, b, a] = c.to_srgba_unmultiplied();
Color32::from_rgba_unmultiplied((r as f32 * f).round() as u8, (g as f32 * f).round() as u8, (b as f32 * f).round() as u8, a)
}
/// One preset in the Brush Preset picker: a 2 × 2 grid whose bottom row (the name) spans both
/// columns — tip at the top left, stroke preview at the top right. A part that's off loses its
/// cell (the other takes the room): with only the tip and the name on, the name moves into the
/// stroke's cell and the bottom row is gone. At least one part is always on. `show` is the three
/// gear boxes (name, stroke, tip); `width` is the row slot the card fills (a [`card_columns`]
/// column or a fixed cell, [`CARD_TIP_ONLY_W`]/[`CARD_TIP_NAME_W`]), and `scale` the footer
/// slider: heights and padding floor at [`CARD_MIN_H_SCALE`], the text holds its 1.0 size
/// ([`CARD_NAME_FONT`], [`CARD_NUM_FONT`]), and at or below [`CARD_COMPACT_SCALE`] the tips
/// drop their size numbers and take their cell whole.
#[allow(clippy::too_many_arguments)]
fn preset_card(
ui: &mut egui::Ui,
p: &BrushPreset,
current: bool,
presets: &[BrushPreset],
show: (bool, bool, bool),
width: f32,
scale: f32,
acts: &mut Vec<Action>,
) {
let (show_name, show_stroke, show_tip) = show;
let tip_only = tip_only(show);
let compact = scale <= CARD_COMPACT_SCALE;
let t = Tokens::get(ui.ctx());
let name_in_top = tip_and_name(show);
// Widths (which arrive scaled) keep narrowing with the slider and heights stop at the floor;
// the text holds its 1.0 size — the fonts don't take the scale.
let scale_h = scale.max(CARD_MIN_H_SCALE);
let pad = CARD_PAD * scale;
let pad_y = CARD_PAD * scale_h;
let name_h = if show_name && !name_in_top { CARD_NAME_H * scale_h } else { 0.0 };
// The tip/stroke row: the tip-only or tip-and-name card's own height less the padding, else
// what [`CARD_MAX_H`] leaves past the padding and the name row. A card with no top part at
// all (the name alone) has no such row.
let top_h = if tip_only {
(CARD_TIP_ONLY_H - CARD_PAD * 2.0) * scale_h
} else if name_in_top {
(CARD_TIP_NAME_H - CARD_PAD * 2.0) * scale_h
} else if show_tip || show_stroke {
(CARD_MAX_H - CARD_PAD * 2.0 - CARD_NAME_H) * scale_h
} else {
0.0
};
let (full, resp) = ui.allocate_exact_size(vec2(width, pad_y * 2.0 + top_h + name_h), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name));
if !ui.is_rect_visible(full) {
return;
}
// The card is inset a little in its column; the whole column stays the drop target.
let card = full.shrink2(vec2((4.0 * scale).min(full.width() * 0.5), 0.0));
// Every card carries its own background, a touch darker than the popup behind it, so the
// cards read as tiles; the current and hover fills paint over it.
ui.painter().rect_filled(card, 3.0, darker(t.card, CARD_BG_SHADE));
if current {
ui.painter().rect_filled(card, 3.0, t.accent_soft);
ui.painter().rect_stroke(card, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(card, 3.0, t.hover);
}
let pb = &p.brush;
let inner = card.shrink2(vec2(pad, pad_y));
let top = egui::Rect::from_min_size(inner.left_top(), vec2(inner.width(), top_h));
let mut x = top.left();
if show_tip {
// The tip thumbnail with the size under it (like the list row). With the tip alone the
// cell takes the whole card, so the thumbnail is centred in it (the tip-only grid).
// Compact cards drop the number — it wouldn't fit — and the tip takes the cell whole.
let tip_w = if tip_only { top.width() } else { (CARD_TIP_W * scale).min(top.width() * 0.5) };
let cell = egui::Rect::from_min_size(top.left_top(), vec2(tip_w, top_h));
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let side = if compact { (cell.width().min(cell.height()) - 2.0).max(1.0) } else { CARD_TIP_BASE * scale * crate::brush_sections::thumb_scale(pb.size) };
let center = if compact { cell.center() } else { pos2(cell.center().x, cell.center().y - 2.0 * scale_h) };
let tr = egui::Rect::from_center_size(center, vec2(side, side));
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
if !compact {
ui.painter().text(
pos2(cell.center().x, cell.bottom() - 1.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(CARD_NUM_FONT),
t.text_faint,
);
}
x = cell.right();
}
if show_stroke || name_in_top {
// The stroke preview, or the name where it goes when the stroke is hidden.
let cell = egui::Rect::from_min_max(pos2(x, top.top()), top.right_bottom());
if show_stroke {
let cw = if cell.width().is_finite() { cell.width().max(1.0) } else { 1.0 };
let w = ((cw - 16.0 * scale).clamp(24.0 * scale, 260.0 * scale)).min(cw).max(1.0).round();
let h = (36.0 * scale_h).round().max(1.0) as u32;
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke-{w}:{}", p.name), pb, w as u32, h, t.text);
let sr = egui::Rect::from_min_size(pos2(cell.left() + 8.0 * scale, cell.center().y - 18.0 * scale_h), vec2(w, 36.0 * scale_h));
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
} else {
crate::layer_row_ui::label(
ui.painter(),
cell.left() + 8.0 * scale,
cell.center().y,
cell.right() - 6.0 * scale,
&p.name,
egui::FontId::proportional(CARD_NAME_FONT),
t.text_dim,
);
}
}
if name_h > 0.0 {
// The merged bottom row: the name across the whole card.
let cell = egui::Rect::from_min_size(pos2(inner.left(), top.bottom()), vec2(inner.width(), name_h));
crate::layer_row_ui::label(
ui.painter(),
cell.left() + 6.0 * scale,
cell.center().y,
cell.right() - 6.0 * scale,
&p.name,
egui::FontId::proportional(CARD_NAME_FONT),
t.text_dim,
);
}
let resp = resp.on_hover_text(&p.name);
// A tip-only card's grid drops between columns, like the Brushes tab's: the rest drop between
// rows (there is a row under the tip there).
preset_interactions(ui, &resp, card, p, presets, tip_only, acts);
}
/// Indent of one nested folder level.
const FOLDER_INDENT: f32 = 16.0;
@@ -473,6 +669,14 @@ struct Draw<'a> {
filtering: bool,
grid: bool,
layout: ListLayout,
/// The picker cards' parts — name, stroke, tip — from the gear's boxes.
show: (bool, bool, bool),
/// The picker cards' size scale (its footer slider, 1 = standard): multiplies [`CARD_MAX_W`]
/// and [`CARD_MAX_H`], or [`CARD_TIP_ONLY_W`]/[`CARD_TIP_ONLY_H`] for tip-only cards and
/// [`CARD_TIP_NAME_W`]/[`CARD_TIP_NAME_H`] for tip-and-name ones; the text holds its 1.0
/// size ([`CARD_NAME_FONT`], [`CARD_NUM_FONT`]); at or below [`CARD_COMPACT_SCALE`] the tips
/// drop their size numbers.
scale: f32,
}
/// Draw `nodes` (a group's or folder's content) at nesting `depth`: runs of presets as rows or a
@@ -485,10 +689,52 @@ fn draw_nodes(ui: &mut egui::Ui, d: &Draw, nodes: &[Node], depth: usize, acts: &
if run.is_empty() {
return;
}
if d.grid {
if d.layout.cards {
// The picker's cards: [`card_columns`] cards sharing each row, a centred grid of
// small [`CARD_TIP_ONLY_W`] cells, or card-wide [`CARD_TIP_NAME_W`] cells snapped
// to the indent like the full-card rows. The scale floors at the slider's own
// bottom end.
let scale = d.scale.max(*crate::brush_picker::SCALE_RANGE.start());
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(CARD_GAP, GRID_GAP);
let fixed = if tip_only(d.show) {
Some(CARD_TIP_ONLY_W * scale)
} else if tip_and_name(d.show) {
Some(CARD_TIP_NAME_W * scale)
} else {
None
};
if let Some(cell) = fixed {
// Tip-only cells centre their whole columns like a grid; the card-sized
// tip-and-name cells start at the indent.
let (mut left, cols) = grid_columns(ui.available_width(), cell, indent, CARD_GAP);
if tip_and_name(d.show) {
left = indent;
}
for row in run.chunks(cols) {
ui.horizontal(|ui| {
ui.add_space(left);
for p in row {
preset_card(ui, p, d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), d.presets, d.show, cell, scale, acts);
}
});
}
} else {
let (card_w, cols) = card_columns(ui.available_width(), indent, scale);
for row in run.chunks(cols) {
ui.horizontal(|ui| {
ui.add_space(indent);
for p in row {
preset_card(ui, p, d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), d.presets, d.show, card_w, scale, acts);
}
});
}
}
});
} else if d.grid {
// Rows of whole columns, each with the same margin: a wrapped row indented only
// its first line and left the spare width on the right.
let (left, cols) = grid_columns(ui.available_width(), d.layout.cell, d.layout.indent + indent);
let (left, cols) = grid_columns(ui.available_width(), d.layout.cell, d.layout.indent + indent, GRID_GAP);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(GRID_GAP, GRID_GAP);
for row in run.chunks(cols) {
@@ -560,15 +806,17 @@ fn rename_bar(ui: &mut egui::Ui, st: &mut BrushesPanelState, acts: &mut Vec<Acti
ui.add_space(4.0);
}
/// The presets in their groups, filtered by `st.filter`, as rows or tip cells (`st.view`), with
/// the rename bar above them while a rename is open. `current` is the selected preset's name:
/// selection is by identity, so it survives edits and tells look-alike duplicates apart. Group
/// toggles and renames update `st`; the returned actions are commands for [`apply`].
/// The presets in their groups, filtered by `st.filter`, as rows or tip cells (`st.view`) or as
/// the picker's cards (`layout.cards`, drawn from the `st.show_*` parts), with the rename bar
/// above them while a rename is open. `current` is the selected preset's name: selection is by
/// identity, so it survives edits and tells look-alike duplicates apart. Group toggles and renames
/// update `st`; the returned actions are commands for [`apply`].
pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<&str>, st: &mut BrushesPanelState, layout: ListLayout) -> Vec<Action> {
let mut acts = Vec::new();
rename_bar(ui, st, &mut acts);
let filter = st.filter.trim().to_lowercase();
let grid = st.view == BrushesView::Grid;
let show = (st.show_name, st.show_stroke, st.show_tip);
let collapsed = &st.collapsed;
egui::ScrollArea::vertical().id_salt(layout.id).max_height(layout.max_height).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
@@ -584,7 +832,7 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<&
if !open {
continue;
}
let d = Draw { label: &label, key: &key, presets, current, collapsed, filtering: !filter.is_empty(), grid, layout };
let d = Draw { label: &label, key: &key, presets, current, collapsed, filtering: !filter.is_empty(), grid, layout, show, scale: st.scale };
draw_nodes(ui, &d, &folder_tree(&items), 0, &mut acts);
ui.add_space(2.0);
}
+152 -9
View File
@@ -10,13 +10,15 @@ use super::*;
#[test]
fn grid_columns_centre_whole_columns_past_the_indent() {
// 6 × 44 + 5 × 3 = 279 of the 290 past a 4 pt indent: 5 spare on each side (rounded down).
assert_eq!(grid_columns(294.0, 44.0, 4.0), (9.0, 6));
assert_eq!(grid_columns(294.0, 44.0, 4.0, GRID_GAP), (9.0, 6));
// An exact fit leaves only the indent.
assert_eq!(grid_columns(4.0 + 279.0, 44.0, 4.0), (4.0, 6));
assert_eq!(grid_columns(4.0 + 279.0, 44.0, 4.0, GRID_GAP), (4.0, 6));
// One point short of a column: one fewer, centred.
assert_eq!(grid_columns(4.0 + 278.0, 44.0, 4.0), (4.0 + 23.0, 5));
assert_eq!(grid_columns(4.0 + 278.0, 44.0, 4.0, GRID_GAP), (4.0 + 23.0, 5));
// The picker's cards centre with their own tighter gap: 6 × 44 + 5 × 1 = 269 of the 290.
assert_eq!(grid_columns(294.0, 44.0, 4.0, CARD_GAP), (4.0 + 10.0, 6));
for (w, cell, indent) in [(294.0, 44.0, 4.0), (360.0, 52.0, 20.0), (500.0, 52.0, 20.0)] {
let (left, cols) = grid_columns(w, cell, indent);
let (left, cols) = grid_columns(w, cell, indent, GRID_GAP);
let right = w - left - (cols as f32 * cell + (cols - 1) as f32 * GRID_GAP);
assert!(((left - indent) - right).abs() <= 1.0, "{w}: left {left}, right {right}");
}
@@ -26,13 +28,13 @@ fn grid_columns_centre_whole_columns_past_the_indent() {
fn grid_columns_never_panic_or_return_no_columns() {
for w in [0.0, -50.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1e30] {
for cell in [0.0, -1.0, f32::NAN, f32::INFINITY, 44.0, 1e-30] {
let (left, cols) = grid_columns(w, cell, 4.0);
let (left, cols) = grid_columns(w, cell, 4.0, GRID_GAP);
assert!((1..=256).contains(&cols), "{w} {cell}: {cols}");
assert!(left.is_finite() && left >= 0.0, "{w} {cell}: {left}");
}
}
// Narrower than one cell: one column at the indent.
assert_eq!(grid_columns(30.0, 44.0, 4.0), (4.0, 1));
assert_eq!(grid_columns(30.0, 44.0, 4.0, GRID_GAP), (4.0, 1));
}
/// Checks each of the first `groups` groups' rows against its header (which spans the list's
@@ -88,8 +90,13 @@ fn rows_are_centred(h: &Harness<'_, PhotocraftApp>, indent: f32, groups: usize)
assert!(wrapped, "no group wrapped onto a second row, so the rows weren't compared");
}
/// The picker's cards: one full-width 2 × 2 card per preset (tip and stroke on top, the name
/// across the bottom), and a hidden part shrinks the card — with only the tip and the name on,
/// the name takes the stroke's cell and the card takes its own height ([`CARD_TIP_NAME_H`]) and
/// width ([`CARD_TIP_NAME_W`]) snapped to the list's left edge; with only the tip on, the card
/// takes the tip-only cell's own height, with the grid's whole columns centred.
#[test]
fn the_brush_preset_picker_grid_is_centred() {
fn brush_picker_cards_follow_the_part_boxes() {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())
@@ -98,8 +105,144 @@ fn the_brush_preset_picker_grid_is_centred() {
h.state_mut().ui.tool = crate::state::Tool::Brush;
h.state_mut().ui.brush_picker = Some([300.0, 120.0]);
h.run_steps(8);
assert_eq!(h.state().ui.brush_picker_list.view, BrushesView::Grid);
rows_are_centred(&h, crate::brush_picker::LIST.indent, 2);
let presets = h.state().session.tools.presets.clone();
let cards = |h: &Harness<'_, PhotocraftApp>| -> Vec<Rect> {
h.query_all_by_role(Role::Button)
.filter(|n| n.accesskit_node().label().is_some_and(|l| presets.iter().any(|p| p.name == l)))
.map(|n| n.rect())
.collect()
};
let full = CARD_MAX_H;
let one_row = CARD_MAX_H - CARD_NAME_H;
let all_on = cards(&h);
assert!(all_on.len() >= 2, "{} cards", all_on.len());
assert!(all_on.iter().all(|r| (r.height() - full).abs() < 0.5 && r.width() > 250.0), "{all_on:?}");
h.state_mut().ui.brush_picker_list.show_stroke = false;
h.run_steps(2);
let tip_name = cards(&h);
assert!(
tip_name.iter().all(|r| (r.height() - CARD_TIP_NAME_H).abs() < 0.5 && (r.width() - CARD_TIP_NAME_W).abs() < 0.5),
"tip + name: the name in the stroke's cell, its own cell size"
);
assert!(tip_name.iter().all(|r| (r.left() - all_on[0].left()).abs() < 0.5), "snapped to the left edge: {tip_name:?}");
h.state_mut().ui.brush_picker_list.show_name = false;
h.run_steps(2);
let tip_only = cards(&h);
assert!(tip_only.iter().all(|r| (r.height() - CARD_TIP_ONLY_H).abs() < 0.5), "tip only: its own cell");
assert!(tip_only[0].left() > all_on[0].left() + 5.0, "the tip-only grid's columns stay centred: {tip_only:?}");
h.state_mut().ui.brush_picker_list.show_stroke = true;
h.run_steps(2);
assert!(cards(&h).iter().all(|r| (r.height() - one_row).abs() < 0.5), "tip + stroke: one row");
}
/// The footer slider's widths take the raw scale; heights, padding and text stop at
/// [`CARD_MIN_H_SCALE`], so at the slider's 0.15 the cards stand at 42 pt ([`CARD_MAX_H`] at that
/// floor) with the widths a fraction of that.
#[test]
fn brush_picker_cards_floor_their_height_but_not_their_width() {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())
});
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]);
h.state_mut().ui.brush_picker_list.scale = 0.15;
h.run_steps(8);
let presets = h.state().session.tools.presets.clone();
let cards: Vec<Rect> = h
.query_all_by_role(Role::Button)
.filter(|n| n.accesskit_node().label().is_some_and(|l| presets.iter().any(|p| p.name == l)))
.map(|n| n.rect())
.collect();
assert!(cards.len() >= 2, "{} cards", cards.len());
for r in &cards {
assert!((r.height() - CARD_MAX_H * CARD_MIN_H_SCALE).abs() < 0.5, "the height floors at the min scale: {r:?}");
// A column's share can run up to 1/col past the raw max width ([`card_columns`]); the
// floored scale's widths would be four times this (0.60 / 0.15).
assert!(r.width() <= CARD_MAX_W * 0.15 * 1.5, "the width keeps the raw scale, not the floored one: {r:?}");
}
}
/// The gear's menu goes the moment a slider or the resize grip is grabbed. A menu closes on a
/// click, and a drag never becomes one, so the menu stayed up — and the picker's own interaction
/// pulled the picker over it. Each press is held down (no release yet, nothing classified) while
/// the menu is checked, so only the grab itself can have closed it.
#[test]
fn brush_picker_menu_closes_when_a_slider_or_the_grip_is_grabbed() {
fn click_gear(h: &Harness<'_, PhotocraftApp>) {
h.query_all_by_role(Role::Button).find(|n| n.accesskit_node().label().as_deref() == Some("Brush Preset Options")).expect("the gear button").click();
}
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())
});
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]);
h.run_steps(8);
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("the picker is shown");
assert!(!egui::Popup::is_any_open(&h.ctx), "nothing open to start with");
click_gear(&h);
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the gear opens its menu");
// The Size slider, under the row that holds the "Size" label: the row stands as tall as its
// 24 pt value field with the label centred in it, so the slider's middle sits 12 pt below the
// label's, plus the style's spacing and half the slider's 18 pt — 27 pt at the 6 pt spacing.
let size_label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect();
let slider = egui::pos2(size_label.left() + 100.0, size_label.center().y + 27.0);
h.drag_at(slider);
h.run_steps(2);
assert!(!egui::Popup::is_any_open(&h.ctx), "grabbing the Size slider dismisses the menu");
h.drop_at(slider);
h.run_steps(2);
click_gear(&h);
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the gear opens its menu again");
// The grip is the frame's bottom-right 16 × 16, so its middle is 8 pt off the picker's corner.
let grip = picker.right_bottom() - vec2(8.0, 8.0);
h.drag_at(grip);
h.run_steps(2);
assert!(!egui::Popup::is_any_open(&h.ctx), "grabbing the resize grip dismisses the menu");
h.drop_at(grip);
h.run_steps(2);
}
/// The upper controls (Size, Hardness) share a block at most [`crate::brush_picker::HEAD_MAX_W`]
/// wide, flush with the picker's left edge: on a picker dragged wider, a click on the Size slider
/// just inside the block jumps the size, and the same click just past the block lands on nothing.
/// A click on a slider sets it to the spot clicked, so a size that doesn't move is a click that
/// missed.
#[test]
fn brush_picker_upper_controls_stop_at_their_max_width() {
fn click(h: &mut Harness<'_, PhotocraftApp>, pos: egui::Pos2) {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
}
}
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())
});
h.state_mut().run("file.new", serde_json::json!({"width": 400, "height": 300})).unwrap();
h.state_mut().ui.tool = crate::state::Tool::Brush;
// Dragged wide: 500 pt of content, well past the block.
h.state_mut().ui.brush_picker = Some([300.0, 120.0]);
h.state_mut().ui.brush_picker_size = Some([500.0, 420.0]);
h.run_steps(8);
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("the picker is shown");
let size_label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect();
// The Size slider's middle: 12 pt below the label's (the row stands as tall as its 24 pt value
// field, the label centred in it), the style's spacing, then half the slider's 18 pt — 27 pt
// at the 6 pt spacing.
let y = size_label.center().y + 27.0;
let before = h.state().session.tools.brush.size;
click(&mut h, egui::pos2(size_label.left() + crate::brush_picker::HEAD_MAX_W - 10.0, y));
let inside = h.state().session.tools.brush.size;
assert_ne!(inside, before, "the slider takes a click just inside the block");
click(&mut h, egui::pos2(size_label.left() + crate::brush_picker::HEAD_MAX_W + 10.0, y));
assert_eq!(h.state().session.tools.brush.size, inside, "just past the block the click lands on nothing");
}
#[test]
+67 -16
View File
@@ -8,7 +8,7 @@
//! - `engine.commands`: list commands with enablement
//! - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size); the menu
//! tree is `ui.menu.list`
//! - `ui.set {tool?, panels?, dock?, dockTabs?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, brushSize?}`:
//! - `ui.set {tool?, panels?, dock?, dockTabs?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, brushSize?}`:
//! change UI state; any other field is an error ([`UI_SET_FIELDS`])
//! - `ui.dialog.open {kind, fields?}` (kinds: newDocument, about, layerStyle {effect?}, colorPicker {target: foreground|background}, command {command}) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`
//! - `ui.dialog.apply {dialog}`: commit Preferences changes without closing the dialog
@@ -74,7 +74,7 @@ pub enum Outcome {
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
/// changed (#412).
pub const UI_SET_FIELDS: [&str; 30] = [
pub const UI_SET_FIELDS: [&str; 33] = [
"tool",
"panels",
"dock",
@@ -93,7 +93,10 @@ pub const UI_SET_FIELDS: [&str; 30] = [
"brushTab",
"brushesView",
"brushPicker",
"brushPickerView",
"brushPickerName",
"brushPickerStroke",
"brushPickerTip",
"brushPickerScale",
"brushSize",
"gradientBlendMode",
"gradientClassic",
@@ -415,8 +418,26 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| format!("brushesView: {e} (list, grid)"))?,
None => None,
};
let brush_picker_view = match p.get("brushPickerView") {
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| format!("brushPickerView: {e} (list, grid)"))?,
// The three parts of the picker's cards. At least one stays on (a rejected call
// leaves them all alone).
let brush_picker_name = bool_field(p, "brushPickerName")?;
let brush_picker_stroke = bool_field(p, "brushPickerStroke")?;
let brush_picker_tip = bool_field(p, "brushPickerTip")?;
if [brush_picker_name, brush_picker_stroke, brush_picker_tip].iter().any(Option::is_some) {
let on = [
brush_picker_name.unwrap_or(app.ui.brush_picker_list.show_name),
brush_picker_stroke.unwrap_or(app.ui.brush_picker_list.show_stroke),
brush_picker_tip.unwrap_or(app.ui.brush_picker_list.show_tip),
];
if !on.iter().any(|b| *b) {
return Err("brushPickerName, brushPickerStroke and brushPickerTip: at least one must stay on".into());
}
}
// The picker footer slider: the preset cards' size scale (1 standard; 0.30 and
// below the tips drop their size numbers).
let brush_picker_scale = match num_field(p, "brushPickerScale")? {
Some(s) if !(0.15..=2.0).contains(&s) => return Err("brushPickerScale must be between 0.15 and 2".into()),
Some(s) => Some(s as f32),
None => None,
};
// `Some(None)` is an explicit null, which closes the picker (a missing field
@@ -552,8 +573,17 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(v) = brushes_view {
app.ui.brushes_panel.view = v;
}
if let Some(v) = brush_picker_view {
app.ui.brush_picker_list.view = v;
if let Some(v) = brush_picker_name {
app.ui.brush_picker_list.show_name = v;
}
if let Some(v) = brush_picker_stroke {
app.ui.brush_picker_list.show_stroke = v;
}
if let Some(v) = brush_picker_tip {
app.ui.brush_picker_list.show_tip = v;
}
if let Some(v) = brush_picker_scale {
app.ui.brush_picker_list.scale = v;
}
if let Some(at) = brush_picker {
app.ui.brush_picker = at;
@@ -933,7 +963,19 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
"layerMenu": app.ui.layer_menu,
"brushPicker": app.ui.brush_picker.map(|pos| json!({
"pos": pos,
"list": app.ui.brush_picker_list,
// The picker's content size once its corner grip was dragged (null: the default).
"size": app.ui.brush_picker_size,
"list": {
"collapsed": app.ui.brush_picker_list.collapsed,
"filter": app.ui.brush_picker_list.filter,
"renaming": app.ui.brush_picker_list.renaming,
// The card's parts (`view` is the Brushes panel's listing and doesn't apply).
"showName": app.ui.brush_picker_list.show_name,
"showStroke": app.ui.brush_picker_list.show_stroke,
"showTip": app.ui.brush_picker_list.show_tip,
// The footer slider: the cards' width scale.
"scale": app.ui.brush_picker_list.scale,
},
})),
"canvasToolMenu": app.ui.canvas_tool_menu.as_ref().map(|menu| {
json!({
@@ -1448,18 +1490,25 @@ mod tests {
}
#[test]
fn ui_set_opens_the_brush_preset_picker_and_sets_its_view() {
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();
assert_eq!(app.ui.brush_picker_list.view, crate::brush_panel::BrushesView::Grid, "tip thumbnails by default");
let r = call(&mut app, &ctx, "ui.set", json!({"tool": "brush", "brushPicker": [120, 80], "brushPickerView": "list"}));
let list = &app.ui.brush_picker_list;
assert!(list.show_name && list.show_stroke && list.show_tip, "every card part is on 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]));
assert_eq!(app.ui.brush_picker_list.view, crate::brush_panel::BrushesView::List);
for bad in [json!({"brushPicker": [1]}), json!({"brushPicker": "here"}), json!({"brushPickerView": "tiles"})] {
assert!(!app.ui.brush_picker_list.show_stroke);
for bad in [
json!({"brushPicker": [1]}),
json!({"brushPicker": "here"}),
json!({"brushPickerStroke": "nope"}),
json!({"brushPickerName": false, "brushPickerStroke": false, "brushPickerTip": false}),
] {
assert_eq!(call(&mut app, &ctx, "ui.set", bad.clone())["ok"], false, "{bad}");
}
assert_eq!(app.ui.brush_picker, Some([120.0, 80.0]), "a bad value leaves the picker alone");
assert!(app.ui.brush_picker_list.show_name, "a rejected call leaves the card parts alone");
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPicker": null}))["ok"], true);
assert_eq!(app.ui.brush_picker, None);
}
@@ -1487,11 +1536,13 @@ mod tests {
assert_eq!(click["ok"], true, "{click}");
let open = call(&mut app, &ctx, "ui.inspect", json!({}));
assert!(open["result"]["brushPicker"]["pos"].is_array(), "{open}");
assert_eq!(open["result"]["brushPicker"]["list"]["view"], "grid");
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!(call(&mut app, &ctx, "ui.set", json!({"brushPickerView": "list"}))["ok"], true);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPickerTip": false}))["ok"], true);
let changed = call(&mut app, &ctx, "ui.inspect", json!({}));
assert_eq!(changed["result"]["brushPicker"]["list"]["view"], "list");
assert_eq!(changed["result"]["brushPicker"]["list"]["showTip"], false);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPicker": null}))["ok"], true);
let closed_again = call(&mut app, &ctx, "ui.inspect", json!({}));
+3 -2
View File
@@ -2021,8 +2021,9 @@
Brush Preset Options Možnosti přednastavení štětců
Group name Název skupiny
Brush name Název štětce
Grid view Zobrazení mřížky
List view Zobrazení seznamu
Brush Name Název štětce
Brush Stroke Tah štětce
Brush Tip Hrot štětce
Off: the speed of the pointer sets the spacing Vypnuto: rozestup určuje rychlost ukazatele
Tilt Scale Měřítko náklonu
Brush Projection Projekce štětce
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -2
View File
@@ -1993,8 +1993,9 @@
Brush Preset Options Pinselvorgaben-Optionen
Group name Gruppenname
Brush name Pinselname
Grid view Rasteransicht
List view Listenansicht
Brush Name Pinselname
Brush Stroke Pinselstrich
Brush Tip Pinselspitze
Off: the speed of the pointer sets the spacing Aus: die Zeigergeschwindigkeit setzt den Abstand
Tilt Scale Neigungsskala
Brush Projection Pinselprojektion
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -1491,8 +1491,8 @@
Delete Brush Διαγραφή πινέλου
Group name Όνομα ομάδας
Brush name Όνομα πινέλου
Grid view Προβολή πλέγματος
List view Προβολή λίστας
Brush Name Όνομα πινέλου
Brush Stroke Πινελιά
Off: the speed of the pointer sets the spacing Ανενεργό: η ταχύτητα του δείκτη καθορίζει την απόσταση
Tilt Scale Κλίμακα κλίσης
Brush Projection Προβολή πινέλου
Can't render this file because it contains an unexpected character in line 966 and column 18.
+3 -2
View File
@@ -2005,8 +2005,9 @@
Brush Preset Options Opciones de preajustes de pincel
Group name Nombre del grupo
Brush name Nombre del pincel
Grid view Vista de cuadrícula
List view Vista de lista
Brush Name Nombre del pincel
Brush Stroke Trazo de pincel
Brush Tip Punta del pincel
Off: the speed of the pointer sets the spacing Desactivado: la velocidad del puntero establece el espaciado
Tilt Scale Escala de inclinación
Brush Projection Proyección del pincel
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2 -2
View File
@@ -1972,8 +1972,8 @@
Brush Preset Options Options des préréglages de pinceau
Group name Nom du groupe
Brush name Nom du pinceau
Grid view Vue en grille
List view Vue en liste
Brush Name Nom du pinceau
Brush Stroke Trace de pinceau
Off: the speed of the pointer sets the spacing Désactivé : la vitesse du pointeur détermine l'espacement
Tilt Scale Échelle d'inclinaison
Brush Projection Projection du pinceau
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -1966,8 +1966,8 @@
Brush Preset Options Opsi Preset Kuas
Group name Nama grup
Brush name Nama kuas
Grid view Tampilan grid
List view Tampilan daftar
Brush Name Nama kuas
Brush Stroke Sapuan kuas
Off: the speed of the pointer sets the spacing Mati: kecepatan penunjuk menentukan jarak
Tilt Scale Skala Kemiringan
Brush Projection Proyeksi Kuas
Can't render this file because it contains an unexpected character in line 4 and column 47.
+3 -2
View File
@@ -2003,8 +2003,9 @@
Brush Preset Options Opzioni predefiniti pennello
Group name Nome gruppo
Brush name Nome pennello
Grid view Vista griglia
List view Vista elenco
Brush Name Nome pennello
Brush Stroke Tratto pennello
Brush Tip Punta pennello
Off: the speed of the pointer sets the spacing Off: la velocità del puntatore imposta la spaziatura
Tilt Scale Scala inclinazione
Brush Projection Proiezione pennello
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2003
2004
2005
2006
2007
2008
2009
2010
2011
+1 -2
View File
@@ -2009,8 +2009,7 @@
Brush Preset Options ブラシプリセットのオプション
Group name グループ名
Brush name ブラシ名
Grid view グリッド表示
List view リスト表示
Brush Name ブラシ名
Off: the speed of the pointer sets the spacing オフ: ポインターの速度で間隔が決まります
Tilt Scale 傾きの比率
Brush Projection ブラシの投影
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -2
View File
@@ -1967,8 +1967,9 @@
Brush Preset Options 브러시 사전 설정 옵션
Group name 그룹 이름
Brush name 브러시 이름
Grid view 격자 보기
List view 목록 보기
Brush Name 브러시 이름
Brush Stroke 브러시 획
Brush Tip 브러시 끝
Off: the speed of the pointer sets the spacing 끔: 포인터 속도에 따라 간격 설정
Tilt Scale 기울기 비율
Brush Projection 브러시 투영
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
1967
1968
1969
1970
1971
1972
1973
1974
1975
+1 -2
View File
@@ -1982,8 +1982,7 @@
Brush Preset Options Opties penseelvoorinstelling
Group name Groepsnaam
Brush name Penseelnaam
Grid view Rasterweergave
List view Lijstweergave
Brush Name Penseelnaam
Off: the speed of the pointer sets the spacing Uit: de snelheid van de aanwijzer bepaalt de tussenruimte
Tilt Scale Kantelschaal
Brush Projection Penseelprojectie
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2 -2
View File
@@ -1997,8 +1997,8 @@
Delete Brush Usuń pędzel
Group name Nazwa grupy
Brush name Nazwa pędzla
Grid view Widok siatki
List view Widok listy
Brush Name Nazwa pędzla
Brush Stroke Pociągnięcie pędzla
Off: the speed of the pointer sets the spacing Wyłączone: prędkość wskaźnika określa odstępy
Tilt Scale Skala nachylenia
Brush Projection Rzutowanie pędzla
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -1971,8 +1971,8 @@
Brush Preset Options Opções de predefinições de pincel
Group name Nome do grupo
Brush name Nome do pincel
Grid view Visualização em grade
List view Visualização em lista
Brush Name Nome do pincel
Brush Stroke Traçado do pincel
Off: the speed of the pointer sets the spacing Desativado: a velocidade do ponteiro define o espaçamento
Tilt Scale Escala de inclinação
Brush Projection Projeção do pincel
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -969,8 +969,8 @@
Rename Brush… Переименовать кисть…
Delete Brush Удалить кисть
Import Brushes… Импортировать кисти…
List view Вид списком
Grid view Вид сеткой
Brush Name Имя кисти
Brush Stroke Мазок кисти
New Brush Preset… Новый набор параметров кисти…
Brush Preset Options Параметры наборов кистей
New Brush Новая кисть
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -949,8 +949,8 @@
Rename Brush… Перейменувати пензель…
Delete Brush Видалити пензель
Import Brushes… Імпортувати пензлі…
List view Список
Grid view Сітка
Brush Name Назва пензля
Brush Stroke Мазок пензля
New Brush Preset… Новий набір пензля…
Brush Preset Options Параметри набору пензля
New Brush Новий пензель
1 # Ukrainian (uk) catalog. UTF-8: context<TAB>English source<TAB>translation.
949
950
951
952
953
954
955 # Paragraph and character panels
956
+3 -2
View File
@@ -2064,8 +2064,9 @@ cameraRaw Blending 混合
Brush Preset Options 画笔预设选项
Group name 组名称
Brush name 画笔名称
Grid view 网格视图
List view 列表视图
Brush Name 画笔名称
Brush Stroke 画笔描边
Brush Tip 画笔笔尖
Off: the speed of the pointer sets the spacing 关闭:由指针移动速度决定间距
Tilt Scale 倾斜缩放
Brush Projection 画笔投影
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2064
2065
2066
2067
2068
2069
2070
2071
2072
+3 -2
View File
@@ -2003,8 +2003,9 @@
Brush Preset Options 筆刷預設集選項
Group name 群組名稱
Brush name 筆刷名稱
Grid view 格狀檢視
List view 清單檢視
Brush Name 筆刷名稱
Brush Stroke 筆刷筆畫
Brush Tip 筆刷筆尖
Off: the speed of the pointer sets the spacing 關:由指標速度決定間距
Tilt Scale 傾斜縮放
Brush Projection 筆刷投影
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -1387,6 +1387,10 @@ impl eframe::App for PhotocraftApp {
}
// A device lost while drawing this frame: switch to the CPU canvas before the next one.
gpu_status::check(self, &ctx);
// The Brush Preset picker's view (its gear's card parts and the footer scale) is
// remembered once the pointer is up. Here, after every panel, rather than in the
// picker's own code: the control channel can set it while the picker is closed.
brush_picker::persist(self, &ctx);
self.automation_input = false;
native_menu::sync(self, &ctx);
if screen_picker::busy(&ctx) {
+33 -10
View File
@@ -150,26 +150,43 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) {
// end a rename in its rename bar.
let key_close = app.ui.brush_picker_list.renaming.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter));
let screen = ctx.content_rect();
// Keep the whole picker on screen: its last size, or about 320 × 480 points before it shows.
// The picker's content size: the one its grip was dragged to, or the one it last closed with
// (kept in egui's own memory, which outlives the app's), or the default. The frame's chrome
// sits around it, and together they keep the whole picker on screen.
let id = egui::Id::new("canvas-brush-picker");
let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(324.0, 480.0), |r| r.size());
let size_id = id.with("content-size");
let content = app.ui.brush_picker_size.or_else(|| ctx.data_mut(|d| d.get_persisted::<[f32; 2]>(size_id))).map_or_else(
|| egui::vec2(crate::brush_picker::DEFAULT_SIZE[0], crate::brush_picker::DEFAULT_SIZE[1]),
|s| crate::brush_picker::clamp_size(s, screen.size()),
);
// Remember it for the picker's next open, and — egui's memory being saved — the next run.
ctx.data_mut(|d| d.insert_persisted(size_id, [content.x, content.y]));
let style = ctx.global_style();
let size = content + style.spacing.menu_margin.sum() + egui::Vec2::splat(2.0 * style.visuals.window_stroke().width);
let pos = egui::pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top()));
let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
let before = app.session.tools.brush.clone();
let mut b = before.clone();
let list = &mut app.ui.brush_picker_list;
let picks = egui::Frame::popup(ui.style())
.show(ui, |ui| crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), list))
.inner;
let frame = egui::Frame::popup(ui.style())
.show(ui, |ui| crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), list, content));
let picks = frame.inner;
// The corner grip resizes the picker, and the size is remembered across opens.
if let Some(new) = crate::brush_picker::resize_grip(ui, frame.response.rect, content) {
app.ui.brush_picker_size = Some([new.x, new.y]);
}
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
let closes = picks.iter().any(crate::brush_picker::Pick::closes);
crate::brush_picker::apply(app, ui.ctx(), picks);
closes
});
// Not a press on a menu the picker opened (the gear, a preset's context menu), nor on the
// options-bar chip, whose click toggles the picker.
// options-bar chip, whose click toggles the picker. A menu floats over the picker in the
// foreground order, so the press is told apart by the layer it lands on, not by any popup
// just being open: a press elsewhere closes the picker in the same click that closes the menu.
let press = ctx.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten());
let outside = press.is_some_and(|p| !area.response.rect.contains(p) && !crate::brush_picker::on_chip(ctx, p)) && !egui::Popup::is_any_open(ctx);
let on_menu = press.is_some_and(|p| ctx.layer_id_at(p).is_some_and(|l| l.order == egui::Order::Foreground));
let outside = press.is_some_and(|p| !area.response.rect.contains(p) && !crate::brush_picker::on_chip(ctx, p)) && !on_menu;
if outside || key_close || area.inner {
crate::brush_picker::close(&mut app.ui);
}
@@ -423,12 +440,18 @@ mod tests {
assert!(h.state().session.tools.presets.iter().any(|p| p.name == "Inky"), "renamed");
assert!(h.state().ui.brush_picker_list.renaming.is_none());
assert!(h.state().ui.brush_picker.is_some(), "Enter ended the rename, not the picker");
// The gear's view items switch the list between tips and names.
// The gear's boxes pick the card parts; the last one on can't be turned off.
h.get_by_label("Brush Preset Options").click();
h.run_steps(2);
h.get_by_label("List view").click();
h.get_by_label("Brush Tip").click();
h.run_steps(2);
assert_eq!(h.state().ui.brush_picker_list.view, crate::brush_panel::BrushesView::List);
assert!(!h.state().ui.brush_picker_list.show_tip);
h.get_by_label("Brush Stroke").click();
h.run_steps(2);
h.get_by_label("Brush Name").click();
h.run_steps(2);
assert!(h.state().ui.brush_picker_list.show_name, "the last part on stays on");
assert!(!h.state().ui.brush_picker_list.show_stroke && !h.state().ui.brush_picker_list.show_tip);
assert!(h.state().ui.brush_picker.is_some());
// A rename left open goes with the picker.
h.state_mut().ui.brush_picker_list.renaming =
+27
View File
@@ -136,6 +136,7 @@ pub fn load(app: &mut PhotocraftApp) {
app.ui.status = format!("Preferences were reset: {e}");
}
crate::dock::restore(app);
crate::brush_picker::restore(app);
app.sync_recent();
app.prefs_rt.saved_rev = app.session.prefs.rev();
app.prefs_rt.saved_value = Some(app.session.prefs_value());
@@ -2327,6 +2328,32 @@ mod tests {
assert_eq!(app2.recent_cap(), 100);
}
#[test]
fn brush_picker_view_survives_a_restart() {
let (mut app, store) = app_with_store();
let ctx = egui::Context::default();
tick(&mut app, &ctx);
app.ui.brush_picker_list.show_stroke = false;
app.ui.brush_picker_list.scale = 0.4;
crate::brush_picker::persist(&mut app, &ctx);
tick(&mut app, &ctx);
assert_eq!(stored(&store)["brushPicker"]["showStroke"], json!(false));
// A restart gets the view back at startup, before the picker is ever opened.
let saved = store.lock().unwrap().clone().unwrap();
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.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.
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);
}
#[test]
fn show_tooltips_preferences_turn_tooltips_off() {
let (mut app, _) = app_with_store();
+5
View File
@@ -883,6 +883,10 @@ pub struct UiState {
/// The Brush Preset picker's preset list: search, collapsed groups, view, a rename in progress.
#[serde(default = "crate::brush_picker::list_state")]
pub brush_picker_list: crate::brush_panel::BrushesPanelState,
/// The Brush Preset picker's content size, once its corner grip was dragged
/// ([`crate::brush_picker::DEFAULT_SIZE`] before that).
#[serde(default)]
pub brush_picker_size: Option<[f32; 2]>,
/// Layers under the pointer, listed by a right-click on the canvas with the Move tool or
/// ⌘/Ctrl+right-click with any tool (`layer_pick_ui`, #307).
#[serde(default)]
@@ -1022,6 +1026,7 @@ impl Default for UiState {
vector_mask_target: false,
brush_picker: None,
brush_picker_list: crate::brush_picker::list_state(),
brush_picker_size: None,
layer_menu: None,
canvas_tool_menu: None,
brush_tool: Tool::Brush,
+2 -2
View File
@@ -42,8 +42,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI)
- `engine.commands`: list commands with enablement
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], list: {collapsed, filter, view, renaming}}` while the Brush Preset picker is open (coordinates in screen points). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
+2 -2
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 20 | 15 | 6 | |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 148 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 149 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 0 | 2 | never-crash lints on 28 of 28 crates; panic_hunt present |
@@ -174,7 +174,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 46 of 148** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 46 of 149** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.