Compare commits

...
6 changed files with 354 additions and 74 deletions

No files matched your search

+4
View File
@@ -230,6 +230,8 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
for (i, (name, has_box)) in SECTIONS.iter().enumerate() {
let sel = app.ui.brush_section == i;
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click());
// Named for accessibility and for tests and agents that look sections up by name.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, *name));
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
@@ -239,6 +241,8 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
if *has_box && let Some(flag) = section_flag(b, i) {
let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(13.0, 13.0));
let box_resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click());
let checked = *flag;
box_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, format!("Enable {name}")));
if *flag {
ui.painter().rect_filled(br, 2.0, t.accent);
let (a, m, c) = (br.left_center() + vec2(3.0, 0.5), br.center_bottom() + vec2(-1.0, -3.5), br.right_top() + vec2(-3.0, 3.5));
+91
View File
@@ -418,3 +418,94 @@ fn brushes_panel_groups_collapse_and_filter() {
h.run_steps(2);
assert!(brush_preview::render_count(&ctx) >= n);
}
/// A fresh session with the Brush tool: shortcuts, the options bar and the Brush Settings window,
/// in the default theme (#258).
fn app_harness() -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.tool = crate::state::Tool::Brush;
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
crate::panels::options_bar(app, ui);
window(app, &ctx);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());
h.run_steps(4);
h
}
#[test]
fn f5_edits_the_default_brush_with_every_section() {
use egui_kittest::kittest::Queryable;
let mut h = app_harness();
assert!(!h.state().ui.panels.brush_settings);
let fresh = h.state().session.tools.brush.clone();
assert!(!fresh.shape_dynamics.enabled && !fresh.transfer.enabled, "the default round brush has no dynamics yet");
h.key_press(egui::Key::F5);
h.run_steps(3);
assert!(h.state().ui.panels.brush_settings && h.state().ui.brush_tab == 0, "F5 shows Brush Settings");
// Every section is there for the built-in round brush, not only for sampled or imported ones.
for (name, _) in SECTIONS {
assert!(h.query_all_by_label(name).next().is_some(), "{name} listed");
}
// Clicking a section's name shows it and turns it on: one tools.setBrush.
let n = h.state().session.journal.len();
h.get_by_label("Shape Dynamics").click();
h.run_steps(3);
assert_eq!(h.state().ui.brush_section, 1);
assert!(h.state().session.tools.brush.shape_dynamics.enabled);
assert_eq!(h.state().session.journal.len(), n + 1);
let (id, p) = last_journal(h.state()).unwrap();
assert_eq!(id, "tools.setBrush");
assert_eq!(p["brush"]["shapeDynamics"]["enabled"], json!(true));
// A section's box toggles it without showing it.
h.get_by_label("Enable Transfer").click();
h.run_steps(3);
assert!(h.state().session.tools.brush.transfer.enabled);
assert_eq!(h.state().ui.brush_section, 1);
assert_eq!(h.state().session.journal.len(), n + 2);
// The rest of the brush is untouched.
let b = &h.state().session.tools.brush;
assert_eq!((b.size, b.hardness, &b.tip), (fresh.size, fresh.hardness, &fresh.tip));
// F5 again hides the panel.
h.key_press(egui::Key::F5);
h.run_steps(2);
assert!(!h.state().ui.panels.brush_settings);
}
#[test]
fn options_bar_reaches_brush_settings_and_the_preset_library() {
use egui_kittest::kittest::Queryable;
let mut h = app_harness();
// Photoshop's "Toggle the Brush Settings panel" button beside the brush chip.
h.get_by_label("Toggle the Brush Settings panel").click();
h.run_steps(3);
assert!(h.state().ui.panels.brush_settings);
h.get_by_label("Toggle the Brush Settings panel").click();
h.run_steps(3);
assert!(!h.state().ui.panels.brush_settings);
// The chip's picker lists the session's presets in their groups, not a fixed set of tips.
h.get_by_label("Brush Preset picker").click();
h.run_steps(3);
let presets = h.state().session.tools.presets.clone();
for (group, _) in grouped_presets(&presets) {
assert!(h.query_by_label(&group).is_some(), "group {group}");
}
let target = presets.iter().find(|p| p.group == "Dry Media").or(presets.last()).unwrap().name.clone();
assert!(!is_current(&paint::presets::find(&presets, &target).unwrap().brush, &h.state().session.tools.brush));
h.get_by_label(&target).click();
h.run_steps(3);
assert_eq!(last_journal(h.state()).unwrap(), ("tools.setBrush".to_string(), json!({ "preset": target })));
assert!(is_current(&paint::presets::find(&presets, &target).unwrap().brush, &h.state().session.tools.brush));
// Its Brush Settings button opens the full editor.
h.get_by_label("Brush Settings…").click();
h.run_steps(3);
assert!(h.state().ui.panels.brush_settings && h.state().ui.brush_tab == 0);
}
+214
View File
@@ -0,0 +1,214 @@
//! Photoshop's Brush Preset picker: the options-bar brush chip's popup and the picker a
//! right-click on the canvas opens (`paint_mouse`). It is the quick picker (Size, Hardness 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 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`]
//! with [`apply`], so every change is a journaled command (Rule 1).
use std::collections::BTreeSet;
use egui::{Color32, RichText, Sense, Stroke, pos2, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::BrushPreset;
use serde_json::json;
use crate::brush_panel::{full_uv, grouped_presets, is_current, new_preset_name, run_or_status};
use crate::theme::{self, Tokens};
use crate::{PhotocraftApp, brush_preview, icons, widgets};
/// Width of the picker's contents.
pub const WIDTH: f32 = 300.0;
/// A preset cell in the picker's grid.
const CELL: egui::Vec2 = vec2(44.0, 50.0);
/// What a click in the picker asks for beyond the size and hardness edits.
#[derive(Clone, Debug, PartialEq)]
pub enum Pick {
/// Pick a preset (`tools.setBrush {"preset": name}`).
Preset(String),
/// Open the Brush Settings panel (Window › Brush Settings, F5).
OpenSettings,
/// Save the current brush as a new preset.
NewPreset,
}
/// Picker view state kept in egui memory: the name filter and the collapsed groups.
#[derive(Clone, Debug, Default)]
struct View {
filter: String,
collapsed: BTreeSet<String>,
}
fn view_id() -> egui::Id {
egui::Id::new("brush-picker-view")
}
/// Run what the picker asked for.
pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, pick: Option<Pick>) {
match pick {
None => {}
Some(Pick::Preset(name)) => run_or_status(app, "tools.setBrush", json!({ "preset": name })),
Some(Pick::OpenSettings) => open_settings(app, ctx),
Some(Pick::NewPreset) => {
let name = new_preset_name(&app.session.tools.presets);
run_or_status(app, "brush.presets.save", json!({ "name": name }));
}
}
}
/// Show the Brush Settings panel on its settings tab (what F5 does when it is hidden).
pub fn open_settings(app: &mut PhotocraftApp, ctx: &egui::Context) {
if app.ui.panels.brush_settings {
app.ui.brush_tab = 0;
return;
}
if let Err(e) = crate::menus::invoke(app, ctx, "window.panel.brushSettings", json!({})) {
app.ui.status = e;
}
}
/// The options-bar button beside the brush chip that shows and hides the Brush Settings panel
/// (Photoshop's "Toggle the Brush Settings panel"). It runs Window › Brush Settings (F5).
pub fn settings_toggle(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let on = app.ui.panels.brush_settings;
if named(icons::button(ui, "sliders-horizontal", 24.0, on, "Toggle the Brush Settings panel (F5)"), "Toggle the Brush Settings panel").clicked()
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), "window.panel.brushSettings", json!({}))
{
app.ui.status = e;
}
}
/// An icon button's accessible name (icon buttons have only a tooltip).
fn named(resp: egui::Response, label: &str) -> egui::Response {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp
}
/// The picker's contents: Size, Hardness, a search field with the Brush Settings and New Preset
/// buttons, and the presets in their groups as tip thumbnails.
pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) -> Option<Pick> {
let t = Tokens::get(ui.ctx());
let mut pick = None;
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("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..=5000.0, "px", 72.0).changed() {
b.size = size.round().clamp(1.0, 5000.0);
}
});
});
let mut lv = b.size.max(1.0).ln();
if widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None).changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
}
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);
}
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
let mut view = ui.data(|d| d.get_temp::<View>(view_id())).unwrap_or_default();
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 view.filter).hint_text("Search Brushes").desired_width(WIDTH - 90.0).id_salt("brush-picker-search"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if named(icons::button(ui, "square-plus", 24.0, false, "Create new brush preset from the current settings"), "New Brush Preset").clicked() {
pick = Some(Pick::NewPreset);
}
if named(icons::button(ui, "sliders-horizontal", 24.0, false, "Brush Settings… (F5): every option of this brush"), "Brush Settings…").clicked()
{
pick = Some(Pick::OpenSettings);
}
});
});
ui.add_space(4.0);
let filter = view.filter.trim().to_lowercase();
egui::ScrollArea::vertical().id_salt("brush-picker-presets").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for (label, items) in grouped_presets(presets) {
let items: Vec<&BrushPreset> =
items.into_iter().filter_map(|i| presets.get(i)).filter(|p| filter.is_empty() || p.name.to_lowercase().contains(&filter)).collect();
if items.is_empty() {
continue;
}
let open = !filter.is_empty() || !view.collapsed.contains(&label);
if group_header(ui, &label, open, items.len()) && !view.collapsed.remove(&label) {
view.collapsed.insert(label.clone());
}
if !open {
continue;
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
ui.add_space(4.0);
for p in items {
if cell(ui, p, is_current(&p.brush, b)) {
pick = Some(Pick::Preset(p.name.clone()));
}
}
});
ui.add_space(3.0);
}
if presets.is_empty() {
ui.label(RichText::new("No brush presets").color(t.text_faint));
}
});
ui.data_mut(|d| d.insert_temp(view_id(), view));
pick
}
/// A group's header row: chevron, folder, name, count. Returns true when clicked (toggle).
fn group_header(ui: &mut egui::Ui, label: &str, open: bool, count: usize) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let x = r.left() + 2.0;
let chevron = if open { "chevron-down" } else { "chevron-right" };
icons::paint(ui, egui::Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)), chevron, 12.0, t.text_dim);
let folder = if open { "folder-open" } else { "folder" };
icons::paint(ui, egui::Rect::from_min_size(pos2(x + 16.0, r.center().y - 8.0), vec2(16.0, 16.0)), folder, 14.0, t.icon);
ui.painter().text(pos2(x + 37.0, r.center().y), egui::Align2::LEFT_CENTER, label, theme::semibold(12.0), t.text);
ui.painter().text(pos2(r.right() - 6.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::CollapsingHeader, true, label));
resp.clicked()
}
/// One preset: its tip thumbnail (scaled by size) and its size. Returns true when clicked.
fn cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(CELL, Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name));
if !ui.is_rect_visible(r) {
return resp.clicked();
}
if current {
ui.painter().rect_filled(r, 3.0, t.accent_soft);
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
} else {
ui.painter().rect_filled(r, 3.0, t.field);
}
let pb = &p.brush;
// The Brushes tab's thumbnail slot, so both share one cached texture per preset.
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 ir = egui::Rect::from_center_size(pos2(r.center().x, r.top() + 20.0), vec2(32.0, 32.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), ir, full_uv(), Color32::WHITE);
ui.painter().text(
pos2(r.center().x, r.bottom() - 7.0),
egui::Align2::CENTER_CENTER,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.5),
t.text_dim,
);
resp.on_hover_text(&p.name).clicked()
}
+1
View File
@@ -15,6 +15,7 @@ pub mod adjust_ui;
pub mod analysis_ui;
pub mod artboard_ui;
pub mod brush_panel;
pub mod brush_picker;
pub mod brush_preview;
pub mod brush_sections;
pub mod brushes_tab;
+22 -6
View File
@@ -107,13 +107,19 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) {
return;
}
let screen = ctx.content_rect();
// Keep the whole picker on screen (it is about 280 × 300 points).
let pos = egui::pos2(x.min(screen.right() - 284.0).max(screen.left()), y.min(screen.bottom() - 310.0).max(screen.top()));
let area = egui::Area::new(egui::Id::new("canvas-brush-picker")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
// Keep the whole picker on screen: its last size, or about 320 × 480 points before it shows.
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 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();
egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut b));
let pick = egui::Frame::popup(ui.style()).show(ui, |ui| crate::brush_picker::body(ui, &mut b, &app.session.tools.presets)).inner;
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
if pick == Some(crate::brush_picker::Pick::OpenSettings) {
app.ui.brush_picker = None;
}
crate::brush_picker::apply(app, ui.ctx(), pick);
});
let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p)));
if outside {
@@ -209,10 +215,20 @@ mod tests {
press(&mut h, c, PointerButton::Secondary, false);
assert_eq!(h.state().ui.brush_picker, Some([c.x, c.y]), "opened at the pointer");
assert!(strokes(&h).is_empty());
// The picker shows over the canvas, at the pointer.
// The picker shows over the canvas at the pointer, moved up just enough to stay on screen.
h.run_steps(2);
let shown = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("picker shown");
assert!(shown.min.distance(c) < 1.0 && shown.width() > 250.0 && shown.height() > 150.0, "{shown:?}");
let screen = h.ctx.content_rect();
assert!((shown.min.x - c.x).abs() < 1.0 && shown.min.y <= c.y + 1.0, "{shown:?}");
assert!(screen.contains_rect(shown.shrink(0.5)) && shown.width() > 250.0 && shown.height() > 150.0, "{shown:?}");
// It is the real preset library, not a fixed set of round tips (#258).
let names: Vec<String> = h.state().session.tools.presets.iter().take(3).map(|p| p.name.clone()).collect();
{
use egui_kittest::kittest::Queryable;
for n in &names {
assert!(h.query_by_label(n).is_some(), "{n} listed");
}
}
// A right drag doesn't paint either.
drag(&mut h, PointerButton::Secondary);
assert!(strokes(&h).is_empty(), "right-drag never paints with the brush picker preference");
+22 -68
View File
@@ -418,8 +418,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Eraser)) || tool == Tool::QuickSelection {
let before = app.session.tools.brush.clone();
let mut b = before.clone();
brush_preset_chip(ui, &mut b);
let pick = brush_preset_chip(ui, &mut b, &app.session.tools.presets);
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
crate::brush_picker::apply(app, ui.ctx(), pick);
crate::brush_picker::settings_toggle(app, ui);
widgets::vline(ui, 22.0);
}
if crate::eraser_ui::options_bar(app, ui, tool)
@@ -434,9 +436,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let brush_before = app.session.tools.brush.clone();
let mut brush = brush_before.clone();
let b = &mut brush;
let mut picked = None;
match app.ui.tool {
Tool::Brush | Tool::Eraser if t.pro => {
brush_preset_chip(ui, b);
picked = brush_preset_chip(ui, b, &app.session.tools.presets);
crate::brush_picker::settings_toggle(app, ui);
widgets::vline(ui, 22.0);
opt_label(ui, "Mode");
let mut mode = b.mode;
@@ -468,6 +472,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let _ = icons::button(ui, "arrow-left-right", 24.0, false, "Set painting symmetry options");
}
Tool::Brush | Tool::Eraser => {
picked = brush_preset_chip(ui, b, &app.session.tools.presets);
crate::brush_picker::settings_toggle(app, ui);
widgets::vline(ui, 22.0);
opt_label(ui, "Size");
widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0);
widgets::vline(ui, 22.0);
@@ -736,6 +743,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
_ => {}
}
crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush);
crate::brush_picker::apply(app, ui.ctx(), picked);
});
});
}
@@ -2101,8 +2109,13 @@ fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings,
}
}
/// Options-bar brush chip; opens Photoshop's Brush Preset Picker (size, hardness, preset tips).
fn brush_preset_chip(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) {
/// Options-bar brush chip; opens Photoshop's Brush Preset picker (size, hardness, the preset
/// library). Returns what the picker asked for beyond the size and hardness edits in `b`.
fn brush_preset_chip(
ui: &mut egui::Ui,
b: &mut photocraft_engine::BrushSettings,
presets: &[photocraft_engine::paint::BrushPreset],
) -> Option<crate::brush_picker::Pick> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(44.0, 30.0), Sense::click());
if resp.hovered() {
@@ -2112,71 +2125,12 @@ fn brush_preset_chip(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings
brush_tip(ui.painter(), c, 7.0, b.hardness, Color32::WHITE);
ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim);
icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Brush Preset picker"));
let resp = resp.on_hover_text("Brush Preset picker");
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| brush_picker_body(ui, b));
}
/// The Brush Preset picker's contents (size, hardness, preset tips): the options-bar chip's
/// popup, and the picker a right-click on the canvas opens (`paint_mouse`).
pub(crate) fn brush_picker_body(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) {
let t = Tokens::get(ui.ctx());
ui.set_width(260.0);
// Size: value field plus a logarithmic slider (small sizes get most of the travel).
ui.horizontal(|ui| {
ui.label(RichText::new("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..=5000.0, "px", 72.0).changed() {
b.size = size.round().clamp(1.0, 5000.0);
}
});
});
let mut lv = b.size.max(1.0).ln();
if widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None).changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
}
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);
}
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
ui.label(RichText::new("General Brushes").color(t.text_dim).size(11.5));
// Photoshop's default round presets: soft and hard at common sizes.
let presets: [(f32, f32); 12] =
[(1.0, 1.0), (3.0, 1.0), (5.0, 1.0), (9.0, 1.0), (13.0, 1.0), (19.0, 1.0), (5.0, 0.0), (9.0, 0.0), (13.0, 0.0), (17.0, 0.0), (45.0, 0.0), (65.0, 0.0)];
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for (sz, hd) in presets {
let (cell, cr) = ui.allocate_exact_size(vec2(38.0, 44.0), Sense::click());
let on = (b.size - sz).abs() < 0.5 && (b.hardness - hd).abs() < 0.01;
ui.painter().rect_filled(
cell,
3.0,
if on {
t.accent_soft
} else if cr.hovered() {
t.hover
} else {
t.field
},
);
let rad = (sz / 2.0).clamp(1.0, 13.0);
brush_tip(ui.painter(), pos2(cell.center().x, cell.top() + 17.0), rad, hd, t.text);
ui.painter().text(
pos2(cell.center().x, cell.bottom() - 7.0),
Align2::CENTER_CENTER,
format!("{}", sz as i64),
egui::FontId::proportional(9.5),
t.text_dim,
);
if cr.on_hover_text(format!("{} Round {sz:.0} px", if hd >= 1.0 { "Hard" } else { "Soft" })).clicked() {
b.size = sz;
b.hardness = hd;
}
}
});
egui::Popup::from_toggle_button_response(&resp)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| crate::brush_picker::body(ui, b, presets))
.and_then(|r| r.inner)
}
/// Drag a layer row to reorder: drop on the upper/lower half to place above/below, or on the middle