diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 4f160d75c6..634b645ead 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -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, diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 880a21a453..1c9b9346a2 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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, + /// 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, } diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 1def4b85d0..c0e877ef6d 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -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, + /// 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). diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index dcd8053a9a..c61b48c1be 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -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 }))); diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 56b7395b2f..773783aa0b 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -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 = 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 { +/// 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 { 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 { + 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) { 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(); diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 43e0a4c3b7..5fcff67cb1 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -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, +) { + 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, st: &mut BrushesPanelState, layout: ListLayout) -> Vec { 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); } diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index edbfacef10..3a225aa6c6 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -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 { + 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 = 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] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 68cdd907e9..a60f8c1b6d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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!({})); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5392e8406f..29cbf93cd9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 65891326bb..d3feb39fce 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7e462ccc2f..b9338f2261 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Προβολή πινέλου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index de002e2a8e..9ef60393d9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 72d5efe500..24dd260e32 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 07a73a8b44..7d2e0718b3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dabbd7591e..488c65e9ef 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b1235113c5..8bc2866d0f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 ブラシの投影 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a656f02bc4..1091e690fe 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 브러시 투영 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4e53198c42..4f860c2a53 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4c7a26483f..a3cf5f1fd8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c7af44bbbc..189dd9b448 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1d01f7c2c7..acbf220406 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Новая кисть diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d03c615ee5..d2430b7730 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Новий пензель diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 70beaddaef..ddea085321 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 画笔投影 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 835b2a2c3f..675d1b60b2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 筆刷投影 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7fae6886a9..28dbe026b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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) { diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 431ffd61f3..9fefa2a1ea 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -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 = diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 5c669f1ab8..ee0bb22f83 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -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(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 63cd0e1da2..4d4f653c4e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9c5fc107a9..04dded7e72 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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. diff --git a/docs/scorecard.md b/docs/scorecard.md index 186e9ef675..f9b7015120 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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`.