mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
Claude Opus 5.5
JJ
Brandon Thomas
parent
d99562f038
commit
4e8aaec643
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 })));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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 브러시 투영
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 Новий пензель
|
||||
|
||||
|
@@ -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 画笔投影
|
||||
|
||||
|
@@ -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.
|
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user