feat(iced): parity improvements for canvas, tools, settings and sidebar

This commit is contained in:
2026-07-15 19:16:26 +03:00
parent defcabd069
commit 70084faf0e
11 changed files with 1665 additions and 241 deletions
Generated
+1
View File
@@ -2730,6 +2730,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"zip",
]
[[package]]
File diff suppressed because it is too large Load Diff
@@ -63,6 +63,12 @@ struct OverlayProgram {
crop_state: Option<CropState>,
/// Marching ants animation offset (0.0..1.0).
marching_ants_offset: f32,
/// Current pen pressure for HUD display (0.0..1.0, 1.0 = no tablet).
pressure: f32,
/// Active tool (drives vector shape preview geometry).
active_tool: hcie_engine_api::Tool,
/// Gradient drag preview endpoints in canvas-space.
gradient_drag: Option<((f32, f32), (f32, f32))>,
}
/// Overlay state — tracks hover and cursor position for crosshair.
@@ -369,6 +375,15 @@ impl canvas::Program<Message> for OverlayProgram {
let sel_w = sw as f32 * self.zoom;
let sel_h = sh as f32 * self.zoom;
// Semi-transparent fill over the selection bounds so the
// selected region is visually distinguished (egui draws a
// purple fill over selected pixels at mod.rs:1655).
let fill_path = Path::rectangle(
Point::new(sel_x, sel_y),
Size::new(sel_w, sel_h),
);
frame.fill(&fill_path, iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12));
// Calculate dash offset for marching ants animation
// The offset moves the dash pattern to create the marching effect
let dash_length = 8.0;
@@ -405,17 +420,11 @@ impl canvas::Program<Message> for OverlayProgram {
}
}
// Draw vector shape preview
// Draw vector shape preview — shape geometry matches the active
// tool so the user sees what will be drawn (egui draws the actual
// shape outline, not just a bounding box).
if let Some(((x0, y0), (x1, y1))) = self.vector_draw {
let v_x = origin_x + x0.min(x1) * self.zoom;
let v_y = origin_y + y0.min(y1) * self.zoom;
let v_w = (x1 - x0).abs() * self.zoom;
let v_h = (y1 - y0).abs() * self.zoom;
let v_path = Path::rectangle(
Point::new(v_x, v_y),
Size::new(v_w, v_h),
);
frame.stroke(&v_path, Stroke {
let stroke_style = Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 0.8, 0.0)),
width: 2.0 / self.zoom.max(1.0),
line_dash: canvas::LineDash {
@@ -423,7 +432,110 @@ impl canvas::Program<Message> for OverlayProgram {
offset: 0,
},
..Default::default()
};
match self.active_tool {
hcie_engine_api::Tool::VectorCircle => {
// Ellipse preview using the bounding box center + radii.
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom;
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom;
let rx = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom;
let ry = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom;
let ellipse_path = Path::new(|b| {
// Approximate ellipse with 48 segments.
let segments = 48;
for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
let px = cx + rx * t.cos();
let py = cy + ry * t.sin();
if i == 0 {
b.move_to(Point::new(px, py));
} else {
b.line_to(Point::new(px, py));
}
}
b.close();
});
frame.stroke(&ellipse_path, stroke_style);
}
hcie_engine_api::Tool::VectorLine => {
// Straight line from start to end.
let p0 = Point::new(origin_x + x0 * self.zoom, origin_y + y0 * self.zoom);
let p1 = Point::new(origin_x + x1 * self.zoom, origin_y + y1 * self.zoom);
let line_path = Path::line(p0, p1);
frame.stroke(&line_path, stroke_style);
}
hcie_engine_api::Tool::VectorStar => {
// 5-point star preview (ignores settings points for the preview).
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom;
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom;
let r_out = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom;
let r_in = r_out * 0.5;
let star_path = Path::new(|b| {
for i in 0..10 {
let t = (i as f32) * std::f32::consts::PI / 5.0 - std::f32::consts::FRAC_PI_2;
let r = if i % 2 == 0 { r_out } else { r_in };
let px = cx + r * t.cos();
let py = cy + r * t.sin();
if i == 0 {
b.move_to(Point::new(px, py));
} else {
b.line_to(Point::new(px, py));
}
}
b.close();
});
frame.stroke(&star_path, stroke_style);
}
hcie_engine_api::Tool::VectorPolygon => {
// 6-sided polygon preview.
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom;
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom;
let r = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom;
let poly_path = Path::new(|b| {
for i in 0..6 {
let t = (i as f32) * std::f32::consts::TAU / 6.0 - std::f32::consts::FRAC_PI_2;
let px = cx + r * t.cos();
let py = cy + r * t.sin();
if i == 0 {
b.move_to(Point::new(px, py));
} else {
b.line_to(Point::new(px, py));
}
}
b.close();
});
frame.stroke(&poly_path, stroke_style);
}
_ => {
// Rectangular shapes (Rect, Arrow, Rhombus, etc.) fall
// back to a bounding-box outline for the preview.
let v_x = origin_x + x0.min(x1) * self.zoom;
let v_y = origin_y + y0.min(y1) * self.zoom;
let v_w = (x1 - x0).abs() * self.zoom;
let v_h = (y1 - y0).abs() * self.zoom;
let v_path = Path::rectangle(
Point::new(v_x, v_y),
Size::new(v_w, v_h),
);
frame.stroke(&v_path, stroke_style);
}
}
}
// Draw gradient endpoint preview line.
if let Some(((gx0, gy0), (gx1, gy1))) = self.gradient_drag {
let p0 = Point::new(origin_x + gx0 * self.zoom, origin_y + gy0 * self.zoom);
let p1 = Point::new(origin_x + gx1 * self.zoom, origin_y + gy1 * self.zoom);
let g_path = Path::line(p0, p1);
frame.stroke(&g_path, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.2, 0.8, 1.0)),
width: 2.0 / self.zoom.max(1.0),
..Default::default()
});
// Endpoint markers.
frame.fill(&Path::circle(p0, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0));
frame.fill(&Path::circle(p1, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0));
}
// Draw transform handles (when in transform mode)
@@ -486,6 +598,13 @@ impl canvas::Program<Message> for OverlayProgram {
}
}
// ── Pressure indicator HUD ──────────────────────────────────────
if self.pressure < 1.0 {
let mut pressure_frame = Frame::new(renderer, bounds.size());
crate::panels::pressure_indicator::draw(&mut pressure_frame, bounds, self.pressure);
geometries.push(pressure_frame.into_geometry());
}
geometries
}
@@ -537,16 +656,18 @@ impl canvas::Program<Message> for OverlayProgram {
/// 1. **Bottom**: `iced::widget::Shader` — renders checkerboard + composite texture
/// via a custom wgpu pipeline. Uses `queue.write_texture()` for partial updates.
/// 2. **Top**: `iced::widget::Canvas` — renders selection rects, vector previews,
/// and crosshair cursor (transparent overlay, a few lines only).
/// crosshair cursor, and pressure indicator HUD (transparent overlay).
///
/// ## Arguments
/// * `doc` — document state (engine, composite pixels, zoom, pan, etc.)
/// * `tool_state` — current tool state (for status bar info)
/// * `marching_ants_offset` — animation offset for marching ants (0.0..1.0)
/// * `pressure` — current pen pressure in [0.0, 1.0] for HUD display
pub fn view<'a>(
doc: &'a crate::app::IcedDocument,
tool_state: &'a crate::app::ToolState,
marching_ants_offset: f32,
pressure: f32,
) -> Element<'a, Message> {
let engine_w = doc.engine.canvas_width();
let engine_h = doc.engine.canvas_height();
@@ -568,7 +689,7 @@ pub fn view<'a>(
.width(Length::Fill)
.height(Length::Fill);
// ── Overlay canvas (selection, vector preview, crosshair) ────────────
// ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ──────
let overlay_program = OverlayProgram {
engine_w,
engine_h,
@@ -581,19 +702,75 @@ pub fn view<'a>(
active_handle: TransformHandle::None, // TODO: track hover state
crop_state: Some(doc.crop_state.clone()),
marching_ants_offset,
pressure,
active_tool: tool_state.active_tool,
gradient_drag: doc.gradient_drag,
};
let overlay_canvas = canvas(overlay_program)
.width(Length::Fill)
.height(Length::Fill);
// ── Stack: shader (bottom) + overlay (top) ───────────────────────────
let stacked = Stack::new()
// ── Stack: shader (bottom) + overlay (top) + text editor (when drafting) ──
let mut stacked = Stack::new()
.push(shader_canvas)
.push(overlay_canvas)
.width(Length::Fill)
.height(Length::Fill);
// On-canvas text editor: a positioned text_input + Commit/Cancel buttons
// placed at the draft location so the user can type inline (egui:
// text_overlay.rs:32-192). iced's Stack aligns children to the top-left, so
// we offset with left/top padding computed from the draft's canvas
// position mapped to pane-space.
if let Some(draft) = doc.text_draft.as_ref() {
let (pane_w, pane_h) = doc.pane_size;
let display_w = engine_w as f32 * zoom;
let display_h = engine_h as f32 * zoom;
let origin_x = (pane_w - display_w) / 2.0 + pan_offset.x;
let origin_y = (pane_h - display_h) / 2.0 + pan_offset.y;
let text_screen_x = (origin_x + draft.x * zoom).max(4.0);
let text_screen_y = (origin_y + draft.y * zoom).max(4.0);
let editor_bg = |_theme: &iced::Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.1, 0.1, 0.12, 0.92))),
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.5, 1.0)).width(1).rounded(3),
..Default::default()
};
let editor = container(
column![
iced::widget::text_input("Type text here...", &draft.content)
.on_input(Message::TextContentChanged)
.on_submit(Message::TextCommit)
.size(13)
.width(Length::Fixed(220.0)),
row![
iced::widget::button(iced::widget::text("Commit").size(10))
.on_press(Message::TextCommit)
.padding([2, 8]),
iced::widget::button(iced::widget::text("Cancel").size(10))
.on_press(Message::TextCancel)
.padding([2, 8]),
]
.spacing(6),
]
.spacing(2),
)
.padding(4)
.style(editor_bg);
let positioned_editor = container(editor)
.width(Length::Shrink)
.padding(iced::Padding {
top: text_screen_y,
bottom: 0.0,
left: text_screen_x,
right: 0.0,
});
stacked = stacked.push(positioned_editor);
}
// ── Tool info strip (bottom of canvas area) ─────────────────────────
let overlay_info = if let Some((x0, y0, x1, y1)) = doc.selection_rect {
let w = (x1 - x0).abs() as u32;
@@ -88,7 +88,7 @@ pub fn view<'a>(
.height(20)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, 1.0,
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0,
))),
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
..Default::default()
@@ -98,7 +98,7 @@ pub fn view<'a>(
.height(20)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, 1.0,
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0,
))),
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
..Default::default()
@@ -109,9 +109,13 @@ pub fn view<'a>(
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center);
// ── 2. Hex input ──
// Preserve the current alpha channel: the hex field only edits RGB, but the
// emitted color keeps the existing alpha so transparency is not silently
// destroyed (matching egui's behavior at color.rs:686).
let alpha = fg_color[3];
let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
let hex_input = text_input("#", &hex_str)
.on_input(|input| {
.on_input(move |input| {
let clean = input.trim_start_matches('#').to_uppercase();
if clean.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
@@ -119,7 +123,7 @@ pub fn view<'a>(
u8::from_str_radix(&clean[2..4], 16),
u8::from_str_radix(&clean[4..6], 16),
) {
return Message::FgColorChanged([r, g, b, 255]);
return Message::FgColorChanged([r, g, b, alpha]);
}
}
Message::NoOp
@@ -129,6 +133,24 @@ pub fn view<'a>(
.size(10);
let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center);
// ── 2b. Alpha slider ──
// Allows transparency editing, which egui supports in the layer-style panel
// but the iced picker previously stripped entirely. Slider 0..255.
let a_val = fg_color[3];
let alpha_slider = slider(0..=255u8, a_val, move |v| {
Message::FgColorChanged([fg_color[0], fg_color[1], fg_color[2], v])
})
.width(Length::Fill);
let alpha_row = row![
text("A").size(10).width(Length::Fixed(12.0)),
alpha_slider,
text(format!("{:.0}%", a_val as f32 / 255.0 * 100.0))
.size(9)
.width(Length::Fixed(34.0)),
]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── 3. Tab bar ──
let tab_w = 30;
let make_tab = |label: &'static str, idx: usize, color_tab: usize| {
@@ -162,7 +184,7 @@ pub fn view<'a>(
let mut recent_items: Vec<Element<'_, Message>> = Vec::new();
for &color in recent_colors.iter().take(20) {
let c = color;
let is_selected = [c[0], c[1], c[2], 255] == *fg_color;
let is_selected = c == *fg_color;
let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114;
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
@@ -179,7 +201,7 @@ pub fn view<'a>(
.center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0,
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, c[3] as f32 / 255.0,
))),
border: if is_selected {
iced::Border::default().color(dot_color).width(1.0).rounded(2)
@@ -189,7 +211,7 @@ pub fn view<'a>(
..Default::default()
});
let clickable = iced::widget::mouse_area(swatch)
.on_press(Message::FgColorChanged([c[0], c[1], c[2], 255]))
.on_press(Message::FgColorChanged(c))
.interaction(iced::mouse::Interaction::Pointer);
recent_items.push(clickable.into());
}
@@ -203,6 +225,7 @@ pub fn view<'a>(
text("Color").size(11).font(iced::Font::MONOSPACE),
swatch_row,
hex_row,
alpha_row,
tabs,
tab_content,
horizontal_rule(1),
@@ -40,7 +40,8 @@ pub fn dock_view<'a>(
let doc_tab_bar = document_tab_bar(app, colors);
let canvas = {
let doc = &app.documents[app.active_doc];
crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset)
let pressure = app.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset, pressure)
};
column![doc_tab_bar, canvas]
.width(Length::Fill)
@@ -105,7 +106,9 @@ pub fn dock_view<'a>(
crate::panels::geometry::view(&[], colors)
}
PaneType::ToolSettings => {
crate::panels::tool_settings::view(&app.tool_state, colors)
let fg = app.fg_color;
let draft = app.documents[app.active_doc].text_draft.as_ref();
crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
}
};
@@ -11,6 +11,7 @@ pub mod layer_details;
pub mod layer_styles;
pub mod layers;
pub mod menus;
pub mod pressure_indicator;
pub mod properties;
pub mod script_panel;
pub mod status_bar;
@@ -0,0 +1,69 @@
//! Pressure indicator HUD — visual display of pen/tablet pressure.
//!
//! Renders a horizontal bar showing current pressure during brush strokes.
//! The bar fills proportionally and uses color coding:
//! - Red: low pressure (< 30%)
//! - Yellow: medium pressure (30-70%)
//! - Green: high pressure (> 70%)
//!
//! Positioned in the bottom-left corner of the canvas viewport.
use iced::widget::canvas::{Frame, Path, Stroke};
use iced::Color;
/// Bar dimensions for the pressure indicator.
const BAR_WIDTH: f32 = 120.0;
const BAR_HEIGHT: f32 = 10.0;
const BAR_MARGIN: f32 = 8.0;
/// Draw the pressure indicator HUD onto the canvas frame.
///
/// # Arguments
/// * `frame` — Canvas frame to draw onto
/// * `bounds` — Canvas bounds for positioning
/// * `pressure` — Current pressure value in [0.0, 1.0]
pub fn draw(frame: &mut Frame, bounds: iced::Rectangle, pressure: f32) {
let p = pressure.clamp(0.0, 1.0);
if p >= 1.0 {
// Don't show indicator at full pressure (mouse input)
return;
}
let x = BAR_MARGIN;
let y = bounds.height - BAR_HEIGHT - BAR_MARGIN;
// Background bar
let bg_path = Path::rectangle(
iced::Point::new(x, y),
iced::Size::new(BAR_WIDTH, BAR_HEIGHT),
);
frame.fill(&bg_path, Color::from_rgba(0.15, 0.15, 0.15, 0.8));
// Filled portion
let fill_width = BAR_WIDTH * p;
if fill_width > 0.5 {
let fill_color = if p < 0.3 {
Color::from_rgb(0.86, 0.31, 0.31) // Red
} else if p < 0.7 {
Color::from_rgb(0.86, 0.71, 0.24) // Yellow
} else {
Color::from_rgb(0.31, 0.78, 0.47) // Green
};
let fill_path = Path::rectangle(
iced::Point::new(x, y),
iced::Size::new(fill_width, BAR_HEIGHT),
);
frame.fill(&fill_path, fill_color);
}
// Border
frame.stroke(
&bg_path,
Stroke {
style: iced::widget::canvas::stroke::Style::Solid(Color::from_rgba(0.3, 0.3, 0.3, 0.8)),
width: 1.0,
..Default::default()
},
);
}
@@ -1,45 +1,56 @@
//! Tool Settings panel — shows all properties for the active tool.
//!
//! **Purpose:** Right-side panel showing tool-specific settings like the reference images.
//! - Brush: Size, Opacity, Hardness, Flow, Spacing, Angle, Roundness
//! - Eraser: Size, Opacity, Hardness, Flow
//! - Pen: Size, Opacity, Flow
//! - Vector: Fill, Stroke, Stroke Width, Opacity
//! - Brush: Size, Opacity, Hardness, Flow, Spacing
//! - Eraser: Size, Opacity, Hardness
//! - Vector: Fill, Stroke Width, Opacity, Radius/Points/Sides (shape-specific)
//! - Select: Feather, Anti-alias
//! - Text: Font, Size, Color, Bold, Italic
//! - Magic Wand: Tolerance, Anti-alias, Contiguous
//! - Text: Font, Size, Angle, Color, Alignment, Orientation
//! - Gradient: Type, Opacity
//!
//! All controls emit real Messages wired to `app.rs` handlers; no `NoOp` remains.
use crate::app::{Message, ToolState};
use crate::panels::styles;
use crate::settings::AppSettings;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text};
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input};
use iced::{Element, Length};
/// Build the tool settings panel for the active tool.
pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
pub fn view<'a>(
tool_state: &'a ToolState,
settings: &'a AppSettings,
fg_color: [u8; 4],
text_draft: Option<&'a crate::app::TextDraft>,
colors: ThemeColors,
) -> Element<'a, Message> {
let content = match tool_state.active_tool {
// ── Brush / Eraser / Pen / Spray ──
Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => {
brush_settings(tool_state, colors)
}
// ── Vector tools ──
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle |
Tool::VectorArrow | Tool::VectorStar | Tool::VectorPolygon => {
vector_settings(colors)
}
// ── Select tools ──
Tool::Select | Tool::Lasso | Tool::PolygonSelect => {
select_settings(colors)
}
// ── Magic Wand ──
Tool::MagicWand => magic_wand_settings(colors),
// ── Text ──
Tool::Text => text_settings(colors),
// ── Move ──
Tool::Move => move_settings(colors),
// ── Gradient / FloodFill ──
Tool::Gradient | Tool::FloodFill => fill_settings(colors),
// ── Default ──
Tool::Brush | Tool::Eraser | Tool::Pen => brush_settings(tool_state, settings, colors),
Tool::Spray => spray_settings(tool_state, settings, colors),
Tool::VectorLine
| Tool::VectorRect
| Tool::VectorCircle
| Tool::VectorArrow
| Tool::VectorStar
| Tool::VectorPolygon
| Tool::VectorRhombus
| Tool::VectorCylinder
| Tool::VectorHeart
| Tool::VectorBubble
| Tool::VectorGear
| Tool::VectorCross
| Tool::VectorCrescent
| Tool::VectorBolt
| Tool::VectorArrow4 => vector_settings(tool_state, settings, colors),
Tool::Select | Tool::Lasso | Tool::PolygonSelect => select_settings(settings, colors),
Tool::MagicWand | Tool::SmartSelect => magic_wand_settings(settings, colors),
Tool::Text => text_settings(settings, fg_color, text_draft, colors),
Tool::Move => move_settings(settings, colors),
Tool::Gradient => gradient_settings(settings, colors),
Tool::FloodFill => flood_fill_settings(settings, colors),
_ => column![text("No settings").size(11)].spacing(4),
};
@@ -63,95 +74,199 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
.into()
}
fn brush_settings(ts: &ToolState, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let size = ts.brush_size;
let opacity = ts.brush_opacity;
let hardness = ts.brush_hardness;
fn brush_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Brush Settings", c),
prop_slider("Size", size, 1.0, 200.0, Message::BrushSizeChanged, c),
prop_slider("Opacity", opacity * 100.0, 0.0, 100.0, Message::BrushOpacityChanged, c),
prop_slider("Hardness", hardness * 100.0, 0.0, 100.0, Message::BrushHardnessChanged, c),
prop_slider("Flow", 100.0, 0.0, 100.0, |_v| Message::NoOp, c),
prop_slider("Spacing", 25.0, 1.0, 100.0, |_v| Message::NoOp, c),
prop_slider("Angle", 0.0, 0.0, 360.0, |_v| Message::NoOp, c),
prop_slider("Roundness", 100.0, 1.0, 100.0, |_v| Message::NoOp, c),
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
prop_slider("Flow", ts2.brush_flow * 100.0, 0.0, 100.0, |v| Message::BrushFlowChanged(v / 100.0), c),
prop_slider("Spacing", ts.brush_spacing * 100.0, 1.0, 100.0, |v| Message::BrushSpacingChanged(v / 100.0), c),
]
.spacing(4)
}
fn vector_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn spray_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Vector Settings", c),
color_row("Fill:", [0, 0, 0, 255], c),
color_row("Stroke:", [255, 255, 255, 255], c),
prop_slider("Stroke Width", 1.0, 0.0, 20.0, |_v| Message::NoOp, c),
prop_slider("Opacity", 100.0, 0.0, 100.0, |_v| Message::NoOp, c),
prop_row("Corner:", "Miter", c),
prop_row("Cap:", "Butt", c),
section_header("Spray Settings", c),
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
prop_slider("Particle Size", ts2.spray_particle_size, 1.0, 20.0, Message::SprayParticleSizeChanged, c),
prop_slider("Density", ts2.spray_density as f32, 10.0, 1000.0, |v| Message::SprayDensityChanged(v as u32), c),
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
]
.spacing(4)
}
fn select_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
let mut col = column![
section_header("Vector Settings", c),
prop_slider("Stroke Width", ts2.vector_stroke_width, 0.0, 50.0, Message::VectorStrokeChanged, c),
prop_slider("Opacity", ts2.vector_opacity * 100.0, 0.0, 100.0, |v| Message::VectorOpacityChanged(v / 100.0), c),
iced::widget::checkbox("Fill", ts2.vector_fill)
.on_toggle(Message::VectorFillToggled)
.size(11),
]
.spacing(4);
// Shape-specific controls.
match ts.active_tool {
Tool::VectorRect => {
col = col.push(prop_slider("Corner Radius", ts2.vector_radius, 0.0, 100.0, Message::VectorRadiusChanged, c));
}
Tool::VectorStar => {
col = col.push(prop_slider("Points", ts2.vector_points as f32, 3.0, 20.0, |v| Message::VectorPointsChanged(v as u32), c));
}
Tool::VectorPolygon => {
col = col.push(prop_slider("Sides", ts2.vector_sides as f32, 3.0, 12.0, |v| Message::VectorSidesChanged(v as u32), c));
}
_ => {}
}
col
}
fn select_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Selection Settings", c),
prop_slider("Feather", 0.0, 0.0, 250.0, |_v| Message::NoOp, c),
prop_checkbox("Anti-alias", true, c),
prop_slider("Feather", ts2.select_feather, 0.0, 250.0, Message::SelectionFeatherChanged, c),
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
.on_toggle(|_v| Message::NoOp)
.size(11),
]
.spacing(4)
}
fn magic_wand_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn magic_wand_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Magic Wand Settings", c),
prop_slider("Tolerance", 16.0, 0.0, 255.0, |_v| Message::NoOp, c),
prop_checkbox("Anti-alias", true, c),
prop_checkbox("Contiguous", true, c),
prop_checkbox("Sample All Layers", false, c),
prop_slider("Tolerance", ts2.magic_wand_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
.on_toggle(Message::SelectionContiguousToggled)
.size(11),
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
.on_toggle(|_v| Message::NoOp)
.size(11),
]
.spacing(4)
}
fn text_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn text_settings(
settings: &AppSettings,
fg_color: [u8; 4],
text_draft: Option<&crate::app::TextDraft>,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
let draft_font = text_draft.map(|d| d.font.clone()).unwrap_or_else(|| ts2.text_font.clone());
let draft_size = text_draft.map(|d| d.size).unwrap_or(ts2.text_size);
let draft_angle = text_draft.map(|d| d.angle).unwrap_or(ts2.text_angle);
let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color);
let draft_content = text_draft.map(|d| d.content.clone()).unwrap_or_default();
let alignment = text_draft
.map(|d| format!("{:?}", d.alignment))
.unwrap_or_else(|| "Left".to_string());
let orientation = text_draft
.map(|d| format!("{:?}", d.orientation))
.unwrap_or_else(|| "Horizontal".to_string());
column![
section_header("Text Settings", c),
prop_row("Font:", "Arial", c),
prop_slider("Size", 24.0, 1.0, 200.0, |_v| Message::NoOp, c),
prop_row("Color:", "#000000", c),
prop_checkbox("Bold", false, c),
prop_checkbox("Italic", false, c),
prop_checkbox("Underline", false, c),
row![
text("Font:").size(10).width(Length::Fixed(50.0)),
text_input("Font", &draft_font)
.on_input(Message::TextFontChanged)
.size(10)
.width(Length::Fill),
]
.spacing(4)
.align_y(iced::Alignment::Center),
prop_slider("Size", draft_size, 4.0, 200.0, Message::TextSizeChanged, c),
prop_slider("Angle", draft_angle, -180.0, 180.0, Message::TextAngleChanged, c),
color_row("Color:", draft_color, c),
row![
text("Content:").size(10).width(Length::Fixed(50.0)),
text_input("Text", &draft_content)
.on_input(Message::TextContentChanged)
.size(10)
.width(Length::Fill),
]
.spacing(4)
.align_y(iced::Alignment::Center),
row![
text("Align:").size(10).width(Length::Fixed(50.0)),
button(text("Left").size(9)).on_press(Message::TextAlignChanged("Left".to_string())).padding([2, 4]),
button(text("Center").size(9)).on_press(Message::TextAlignChanged("Center".to_string())).padding([2, 4]),
button(text("Right").size(9)).on_press(Message::TextAlignChanged("Right".to_string())).padding([2, 4]),
]
.spacing(3),
row![
text("Orient:").size(10).width(Length::Fixed(50.0)),
button(text("H").size(9)).on_press(Message::TextOrientationChanged("Horizontal".to_string())).padding([2, 6]),
button(text("V").size(9)).on_press(Message::TextOrientationChanged("Vertical".to_string())).padding([2, 6]),
]
.spacing(3),
row![
button(text("Commit").size(10)).on_press(Message::TextCommit).padding([4, 10]),
button(text("Cancel").size(10)).on_press(Message::TextCancel).padding([4, 10]),
]
.spacing(6),
]
.spacing(4)
}
fn move_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn move_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let _ = settings;
column![
section_header("Move Settings", c),
prop_checkbox("Auto-Select", true, c),
iced::widget::checkbox("Apply to New Layer", true)
.on_toggle(Message::ToolApplyToNewLayerToggled)
.size(11),
]
.spacing(4)
}
fn fill_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
let gtype = ts2.gradient_type;
column![
section_header("Fill Settings", c),
prop_row("Mode:", "Normal", c),
prop_slider("Opacity", 100.0, 0.0, 100.0, |_v| Message::NoOp, c),
prop_checkbox("Contiguous", true, c),
section_header("Gradient Settings", c),
row![
text("Type:").size(10).width(Length::Fixed(40.0)),
button(text("Linear").size(9))
.on_press(Message::GradientTypeChanged(0))
.padding([2, 6]),
button(text("Radial").size(9))
.on_press(Message::GradientTypeChanged(1))
.padding([2, 6]),
]
.spacing(3),
prop_slider("Opacity", ts2.gradient_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
text(if gtype == 1 { "Radial" } else { "Linear" }).size(9),
]
.spacing(4)
}
fn flood_fill_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Flood Fill Settings", c),
prop_slider("Tolerance", ts2.flood_fill_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
.on_toggle(Message::SelectionContiguousToggled)
.size(11),
]
.spacing(4)
}
fn section_header(label: &str, _c: ThemeColors) -> Element<'static, Message> {
let l = label.to_string();
row![
text(l).size(11),
]
.spacing(4)
.into()
row![text(l).size(11)].spacing(4).into()
}
fn prop_slider<F>(label: &str, value: f32, min: f32, max: f32, msg_fn: F, _c: ThemeColors) -> Element<'static, Message>
@@ -161,9 +276,7 @@ where
let l = label.to_string();
let label_text = text(l).size(10).width(Length::Fixed(70.0));
let val_text = text(format!("{:.1}", value)).size(10).width(Length::Fixed(40.0));
let sl = slider(min..=max, value, msg_fn)
.step(0.1)
.width(Length::Fill);
let sl = slider(min..=max, value, msg_fn).step(0.1).width(Length::Fill);
row![label_text, sl, val_text]
.spacing(4)
@@ -171,28 +284,6 @@ where
.into()
}
fn prop_row(label: &str, value: &str, _c: ThemeColors) -> Element<'static, Message> {
let l = label.to_string();
let v = value.to_string();
row![
text(l).size(10).width(Length::Fixed(70.0)),
text(v).size(10),
]
.spacing(4)
.into()
}
fn prop_checkbox(label: &str, checked: bool, _c: ThemeColors) -> Element<'static, Message> {
let mark = if checked { "" } else { "" }.to_string();
let l = label.to_string();
row![
text(mark).size(10),
text(l).size(10),
]
.spacing(4)
.into()
}
fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, Message> {
let l = label.to_string();
let swatch = container(text(""))
@@ -216,4 +307,4 @@ fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, M
.spacing(4)
.align_y(iced::Alignment::Center)
.into()
}
}
@@ -12,7 +12,7 @@ use iced::widget::{button, container, row, slider, svg, text};
use iced::{Element, Length};
/// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row.
pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<'_, Message> {
pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::settings::AppSettings, colors: ThemeColors) -> Element<'a, Message> {
let c = colors;
// ── SVG icon buttons (no text) ──
@@ -37,7 +37,10 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
.width(Length::Fixed(60.0));
let flow_label = text("Flow:").size(10).color(c.text_secondary);
let flow_value = text("100%").size(10).color(c.text_primary);
let flow_value = text(format!("{:.0}%", settings.tool_settings.brush_flow * 100.0)).size(10).color(c.text_primary);
let flow_sl = slider(0.0..=1.0, settings.tool_settings.brush_flow, Message::BrushFlowChanged)
.step(0.01)
.width(Length::Fixed(60.0));
let smooth_label = text("Smooth:").size(10).color(c.text_secondary);
let smooth_value = text("0%").size(10).color(c.text_primary);
@@ -47,7 +50,7 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
sep(c),
opacity_label, opacity_value, opacity_sl,
sep(c),
flow_label, flow_value,
flow_label, flow_value, flow_sl,
sep(c),
smooth_label, smooth_value,
]
@@ -72,28 +75,36 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center)
}
Tool::MagicWand => {
let tol = settings.tool_settings.magic_wand_tolerance;
row![
text("Tolerance:").size(10),
text("16").size(10).width(Length::Fixed(30.0)),
text("Anti-alias").size(10),
text("Contiguous").size(10),
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(v as u8))
.step(1.0)
.width(Length::Fixed(80.0)),
text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)),
].spacing(6).align_y(iced::Alignment::Center)
}
Tool::Select | Tool::Lasso => {
let feather = settings.tool_settings.select_feather;
row![
text("Feather:").size(10),
text("0 px").size(10).width(Length::Fixed(40.0)),
small_btn("Refine Edge", c),
small_btn("Select Subject", c),
slider(0.0..=250.0, feather, Message::SelectionFeatherChanged)
.step(1.0)
.width(Length::Fixed(80.0)),
text(format!("{:.0}px", feather)).size(10).width(Length::Fixed(40.0)),
].spacing(6).align_y(iced::Alignment::Center)
}
Tool::Move => {
row![text("Auto-Select").size(10)].spacing(4).align_y(iced::Alignment::Center)
}
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
let stroke = settings.tool_settings.vector_stroke_width;
row![
text("Fill:").size(10), text("None").size(10),
text("Stroke:").size(10), text("1px").size(10),
text("Stroke:").size(10),
slider(0.0..=50.0, stroke, Message::VectorStrokeChanged)
.step(0.5)
.width(Length::Fixed(60.0)),
text(format!("{:.1}px", stroke)).size(10).width(Length::Fixed(40.0)),
].spacing(4).align_y(iced::Alignment::Center)
}
_ => row![].spacing(0),
@@ -59,9 +59,55 @@ pub struct ToolSettings {
pub text_size: f32,
pub vector_stroke_width: f32,
pub vector_opacity: f32,
/// Whether vector shapes are filled (true) or outlined only (false).
#[serde(default = "default_vector_fill")]
pub vector_fill: bool,
/// Corner radius for VectorRect shapes.
#[serde(default)]
pub vector_radius: f32,
/// Number of points for VectorStar shapes.
#[serde(default = "default_vector_points")]
pub vector_points: u32,
/// Number of sides for VectorPolygon shapes.
#[serde(default = "default_vector_sides")]
pub vector_sides: u32,
/// Spray particle size (size of each particle dot).
#[serde(default = "default_spray_particle_size")]
pub spray_particle_size: f32,
/// Spray density (number of particles per dab).
#[serde(default = "default_spray_density")]
pub spray_density: u32,
/// Gradient type: 0 = linear, 1 = radial.
#[serde(default)]
pub gradient_type: u32,
/// Selection contiguous flag (magic wand / flood fill).
#[serde(default = "default_contiguous")]
pub selection_contiguous: bool,
/// Anti-alias flag for selection tools.
#[serde(default = "default_anti_alias")]
pub selection_anti_alias: bool,
/// Last font used by the Text tool.
#[serde(default = "default_text_font")]
pub text_font: String,
/// Text angle in degrees.
#[serde(default)]
pub text_angle: f32,
/// Whether the Text tool commits to a new layer each time.
#[serde(default = "default_apply_to_new_layer")]
pub tool_apply_to_new_layer: bool,
pub last_active_tool: String,
}
fn default_vector_fill() -> bool { true }
fn default_vector_points() -> u32 { 5 }
fn default_vector_sides() -> u32 { 6 }
fn default_spray_particle_size() -> f32 { 4.0 }
fn default_spray_density() -> u32 { 80 }
fn default_contiguous() -> bool { true }
fn default_anti_alias() -> bool { true }
fn default_text_font() -> String { "Arial".to_string() }
fn default_apply_to_new_layer() -> bool { true }
/// Panel layout positions and visibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelLayout {
@@ -115,6 +161,18 @@ impl Default for ToolSettings {
text_size: 24.0,
vector_stroke_width: 1.0,
vector_opacity: 1.0,
vector_fill: true,
vector_radius: 0.0,
vector_points: 5,
vector_sides: 6,
spray_particle_size: 4.0,
spray_density: 80,
gradient_type: 0,
selection_contiguous: true,
selection_anti_alias: true,
text_font: "Arial".to_string(),
text_angle: 0.0,
tool_apply_to_new_layer: true,
last_active_tool: "Brush".to_string(),
}
}
@@ -19,15 +19,15 @@ use iced::widget::{button, column, container, row, svg, text};
use iced::{Element, Length};
/// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure.
struct ToolSlot {
tools: &'static [Tool],
label: &'static str,
icon: &'static str,
pub struct ToolSlot {
pub tools: &'static [Tool],
pub label: &'static str,
pub icon: &'static str,
}
/// All tool slots matching egui's TOOL_SLOTS structure exactly.
/// Each slot can have multiple tools (sub-tools shown on click).
const TOOL_SLOTS: &[ToolSlot] = &[
pub const TOOL_SLOTS: &[ToolSlot] = &[
ToolSlot { tools: &[Tool::Move], label: "Move", icon: "icons/move.svg" },
ToolSlot { tools: &[Tool::VectorSelect], label: "Vector Select", icon: "icons/vector_select.svg" },
ToolSlot { tools: &[Tool::Select], label: "Rectangle Select", icon: "icons/rect_select.svg" },
@@ -71,11 +71,12 @@ pub fn view<'a>(
colors: ThemeColors,
active_slot: usize,
sub_tools_open: bool,
slot_last_used: &'a [usize],
) -> Element<'a, Message> {
// Tool buttons — single column, with sub-tools popup support
let mut tool_list = column![].spacing(1);
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open);
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used);
tool_list = tool_list.push(btn);
}
@@ -106,6 +107,34 @@ pub fn view<'a>(
..Default::default()
});
// Make the background swatch clickable so a click promotes the background
// color to the foreground (egui: toolbox.rs:355-358).
let bg_color_val = *bg_color;
let bg_clickable = iced::widget::mouse_area(bg_swatch)
.on_press(Message::FgColorChanged(bg_color_val));
// Small reset-to-defaults button (black/white) below the swatches
// (egui: toolbox.rs:351-354 resets fg=black/bg=white on a bottom-left click).
let reset_btn = button(text("D").size(7).color(colors.text_secondary))
.on_press(Message::ResetColors)
.padding(1)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
// Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap)
// with a swap arrow button between them
let color_section = container(
@@ -115,7 +144,7 @@ pub fn view<'a>(
.padding(iced::Padding { top: 0.0, bottom: 0.0, left: 4.0, right: 0.0 })
)
.push(
container(bg_swatch)
container(bg_clickable)
.padding(iced::Padding { top: 10.0, bottom: 0.0, left: 16.0, right: 0.0 })
)
.push(
@@ -157,6 +186,7 @@ pub fn view<'a>(
container(tool_list).padding([2, 0]),
container(text("").height(1)).width(Length::Fill).style(sep_style),
color_section,
container(reset_btn).padding([2, 4]),
]
.width(36)
.spacing(0);
@@ -180,9 +210,16 @@ fn tool_button<'a>(
slot_idx: usize,
active_slot: usize,
sub_tools_open: bool,
slot_last_used: &[usize],
) -> Element<'a, Message> {
let is_active = slot.tools.contains(&tool_state.active_tool);
// Resolve the tool this slot currently represents: the last-used sub-tool
// for the slot, falling back to the primary tool. The icon shown should
// reflect the last-used tool (egui: toolbox.rs:371-383).
let last_idx = slot_last_used.get(slot_idx).copied().unwrap_or(0);
let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]);
// Try to load SVG icon - search multiple paths
let icon_paths = [
std::path::PathBuf::from(slot.icon),
@@ -212,7 +249,7 @@ fn tool_button<'a>(
let has_sub_tools = slot.tools.len() > 1;
let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx;
let primary_tool = slot.tools[0];
let primary_tool = represented_tool;
let active = is_active;
let c = colors;
@@ -375,3 +412,26 @@ fn tool_shortcut(tool: Tool) -> &'static str {
_ => "",
}
}
/// Public accessor for the tool slots list (used by app.rs to resolve
/// last-used sub-tools and by keyboard-shortcut handlers).
pub fn tool_slots() -> &'static [ToolSlot] {
TOOL_SLOTS
}
/// Find the slot index that contains the given tool.
///
/// Returns the first matching slot so a tool always resolves to its home
/// slot (e.g. `VectorStar` → the "Vector Shapes" slot).
pub fn slot_for_tool(tool: Tool) -> Option<usize> {
TOOL_SLOTS.iter().position(|s| s.tools.contains(&tool))
}
/// Get the last-used tool for a slot, defaulting to the primary tool.
pub fn last_used_tool(slot_idx: usize, last_used: &[usize]) -> Tool {
let tidx = last_used.get(slot_idx).copied().unwrap_or(0);
TOOL_SLOTS
.get(slot_idx)
.and_then(|s| s.tools.get(tidx).copied())
.unwrap_or(Tool::Brush)
}