Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs
T
Your Name e9dad7ef0c
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
refactor: clamp canvas pointer movement, prioritize local pan offset, and remove debug logs
2026-07-23 19:44:13 +03:00

867 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tool sidebar — Photopea-style fixed 36px single-column strip on the left edge.
//!
//! **Purpose:** Provides a vertical toolbar matching Photopea's left toolbox.
//! Active tool gets a green (#4caf50) background highlight, matching Photopea's design.
//! Supports sub-tools via click (shows popup with tool variants).
//!
//! **Design reference:** Photopea image editor (93 screenshots analyzed).
//! - Width: 36px
//! - Active tool: green background (#4caf50)
//! - Tool icons: white/light gray, 20px
//! - Color swatches at bottom (foreground/background, diagonal overlap)
//! - Sub-tools: click to show popup with tool variants
use crate::app::{Message, ToolState};
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{button, column, container, row, scrollable, svg, text};
use iced::{Element, Length};
/// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure.
pub struct ToolSlot {
pub tools: &'static [Tool],
pub label: &'static str,
#[allow(dead_code)]
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).
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",
},
ToolSlot {
tools: &[Tool::Lasso, Tool::PolygonSelect],
label: "Lasso",
icon: "icons/lasso.svg",
},
ToolSlot {
tools: &[Tool::MagicWand],
label: "Magic Wand",
icon: "icons/magic_wand.svg",
},
ToolSlot {
tools: &[Tool::SmartSelect, Tool::VisionSelect],
label: "Smart Select",
icon: "icons/smart_select.svg",
},
ToolSlot {
tools: &[Tool::Crop],
label: "Crop",
icon: "icons/crop.svg",
},
ToolSlot {
tools: &[Tool::Eyedropper],
label: "Eyedropper",
icon: "icons/eyedropper.svg",
},
ToolSlot {
tools: &[Tool::Brush],
label: "Brush",
icon: "icons/brush.svg",
},
ToolSlot {
tools: &[Tool::Pen],
label: "Pen",
icon: "icons/pen.svg",
},
ToolSlot {
tools: &[Tool::Spray],
label: "Spray",
icon: "icons/spray.svg",
},
ToolSlot {
tools: &[Tool::Eraser],
label: "Eraser",
icon: "icons/eraser.svg",
},
ToolSlot {
tools: &[Tool::FloodFill],
label: "Flood Fill",
icon: "icons/bucket.svg",
},
ToolSlot {
tools: &[Tool::Gradient],
label: "Gradient",
icon: "icons/gradient.svg",
},
ToolSlot {
tools: &[Tool::Text],
label: "Text",
icon: "icons/text.svg",
},
ToolSlot {
tools: &[Tool::VectorLine],
label: "Vector Line",
icon: "icons/line.svg",
},
ToolSlot {
tools: &[Tool::VectorRect],
label: "Vector Rectangle",
icon: "icons/vector_rect.svg",
},
ToolSlot {
tools: &[Tool::VectorCircle],
label: "Vector Circle",
icon: "icons/circle.svg",
},
ToolSlot {
tools: &[
Tool::VectorArrow,
Tool::VectorStar,
Tool::VectorPolygon,
Tool::VectorRhombus,
Tool::VectorCylinder,
Tool::VectorHeart,
Tool::VectorBubble,
Tool::VectorGear,
Tool::VectorCross,
Tool::VectorCrescent,
Tool::VectorBolt,
Tool::VectorArrow4,
],
label: "Vector Shapes",
icon: "icons/star.svg",
},
ToolSlot {
tools: &[
Tool::SpotRemoval,
Tool::RedEyeRemoval,
Tool::SmartPatch,
Tool::AiObjectRemoval,
],
label: "Retouch",
icon: "icons/spot.svg",
},
ToolSlot {
tools: &[Tool::CustomShape(0)],
label: "Custom Shapes",
icon: "icons/star.svg",
},
];
/// Build the Photopea-style sidebar element.
///
/// When `expanded` is false, renders a single 36px column (default).
/// When `expanded` is true, renders a 2-column grid (72px) showing more tools.
pub fn view<'a>(
tool_state: &'a ToolState,
fg_color: &'a [u8; 4],
bg_color: &'a [u8; 4],
colors: ThemeColors,
open_subtool_slot: Option<usize>,
slot_last_used: &'a [usize],
expanded: bool,
) -> Element<'a, Message> {
let sidebar_width = if expanded { 72 } else { 36 };
// Tool buttons — single column or 2-column grid depending on expanded state
let mut tool_list = column![].spacing(1);
if expanded {
// The open subtool slot owns a full row: 34px parent plus 34px popup.
// Other slots continue to use the regular two-column grid.
let mut row_children = iced::widget::Row::new().spacing(1);
let mut row_items = 0;
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
let btn = tool_button(
slot,
tool_state,
colors,
idx,
open_subtool_slot,
slot_last_used,
);
let popup_row = open_subtool_slot == Some(idx) && slot.tools.len() > 1;
if popup_row {
if row_items > 0 {
tool_list = tool_list.push(row_children);
row_children = iced::widget::Row::new().spacing(1);
row_items = 0;
}
tool_list = tool_list.push(btn);
continue;
}
row_children = row_children.push(btn);
row_items += 1;
if row_items == 2 {
tool_list = tool_list.push(row_children);
row_children = iced::widget::Row::new().spacing(1);
row_items = 0;
}
}
if row_items > 0 {
tool_list = tool_list.push(row_children);
}
} else {
// Single column layout (default)
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
let btn = tool_button(
slot,
tool_state,
colors,
idx,
open_subtool_slot,
slot_last_used,
);
tool_list = tool_list.push(btn);
}
}
// Color swatches at bottom (Photopea-style: diagonal overlapping)
// Foreground (top-left), Background (bottom-right), overlapping diagonally
let fg = fg_color;
let bg = bg_color;
let fg_swatch = container(text(""))
.width(24)
.height(24)
.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,
fg[3] as f32 / 255.0,
))),
border: iced::Border::default()
.rounded(2)
.color(colors.border_high)
.width(2),
..Default::default()
});
let bg_swatch = container(text(""))
.width(24)
.height(24)
.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,
bg[3] as f32 / 255.0,
))),
border: iced::Border::default()
.rounded(2)
.color(colors.border_high)
.width(2),
..Default::default()
});
let fg_color_val = *fg_color;
let fg_clickable = styles::balloon_tooltip(
iced::widget::mouse_area(fg_swatch).on_press(Message::FgColorChanged(fg_color_val)),
"Foreground color - click to apply",
iced::widget::tooltip::Position::Right,
colors,
);
// 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 = styles::balloon_tooltip(
iced::widget::mouse_area(bg_swatch).on_press(Message::FgColorChanged(bg_color_val)),
"Background color - click to promote to foreground",
iced::widget::tooltip::Position::Right,
colors,
);
// 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 = styles::balloon_tooltip(
button(text("D").size(10).color(colors.text_secondary))
.on_press(Message::ResetColors)
.padding([1, 3])
.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()
},
},
),
"Reset foreground/background colors",
iced::widget::tooltip::Position::Right,
colors,
);
// Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap)
// with a swap arrow button between them
let color_section = container(
iced::widget::Stack::new()
.push(container(bg_clickable).padding(iced::Padding {
top: 10.0,
bottom: 0.0,
left: 8.0,
right: 0.0,
}))
.push(container(fg_clickable).padding(iced::Padding {
top: 0.0,
bottom: 0.0,
left: 0.0,
right: 0.0,
}))
.push(
container(styles::balloon_tooltip(
button(text("").size(10).color(colors.text_primary))
.on_press(Message::SwapColors)
.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_primary,
border: iced::Border::default(),
..Default::default()
},
}
},
),
"Swap foreground/background colors",
iced::widget::tooltip::Position::Right,
colors,
))
.padding(iced::Padding {
top: 0.0,
bottom: 0.0,
left: 21.0,
right: 0.0,
}),
),
)
.padding([4, 2])
.width(sidebar_width)
.height(40);
// Separator line
let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.border_low)),
..Default::default()
};
// Toggle expand/collapse button (arrow icon)
let toggle_label = if expanded { "" } else { "" };
let toggle_btn = button(text(toggle_label).size(10).color(colors.text_secondary))
.on_press(Message::SidebarToggleExpanded)
.padding(2)
.width(sidebar_width)
.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()
},
},
);
let sidebar = column![
scrollable(container(tool_list).padding([2, 0])).height(Length::Fill),
container(text("").height(1))
.width(Length::Fill)
.style(sep_style),
color_section,
container(reset_btn).padding([2, 4]),
container(toggle_btn).padding([2, 0]),
]
.width(sidebar_width)
.spacing(0);
container(sidebar)
.style(move |_theme| styles::sidebar_background(colors))
.height(Length::Fill)
.into()
}
/// Create a Photopea-style tool button with green active highlight.
///
/// Active tool: green background (#4caf50) — matches Photopea's toolbox.
/// Clicking a slot with sub-tools toggles the sub-tools popup.
/// Hovered: subtle highlight.
/// Default: transparent.
fn tool_button<'a>(
slot: &ToolSlot,
tool_state: &ToolState,
colors: ThemeColors,
slot_idx: usize,
open_subtool_slot: Option<usize>,
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 represented_icon = tool_icon(represented_tool);
let icon_paths = [
std::path::PathBuf::from(represented_icon),
std::path::Path::new("hcie-iced-app/assets").join(represented_icon),
std::path::PathBuf::from(format!(
"/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}",
represented_icon
)),
];
let icon_color = if is_active {
colors.accent
} else {
colors.text_primary
};
let icon_element: Element<'_, Message> =
if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
let handle = svg::Handle::from_path(icon_path);
svg(handle)
.width(20)
.height(20)
.style(
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(icon_color),
},
)
.into()
} else {
text(slot.label.chars().next().unwrap_or('?').to_string())
.size(14)
.color(icon_color)
.into()
};
// If slot has sub-tools and is active, show sub-tools popup
let has_sub_tools = slot.tools.len() > 1;
let show_popup = has_sub_tools && open_subtool_slot == Some(slot_idx);
let primary_tool = represented_tool;
let active = is_active;
let c = colors;
let btn = iced::widget::button(
container(icon_element)
.width(34)
.height(28)
.center_y(20)
.center_x(20),
)
.on_press(Message::ToolSlotClicked(slot_idx, primary_tool))
.padding(0)
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active | iced::widget::button::Status::Disabled => {
if active {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent_green)),
border: iced::Border::default(),
text_color: iced::Color::WHITE,
..Default::default()
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
border: iced::Border::default(),
text_color: c.text_primary,
..Default::default()
}
}
}
iced::widget::button::Status::Hovered => {
if active {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent_green)),
border: iced::Border::default(),
text_color: iced::Color::WHITE,
..Default::default()
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
border: iced::Border::default(),
text_color: c.text_primary,
..Default::default()
}
}
}
iced::widget::button::Status::Pressed => iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent_green)),
border: iced::Border::default(),
text_color: iced::Color::WHITE,
..Default::default()
},
},
);
let popup_affordance: Element<'a, Message> = if has_sub_tools {
styles::balloon_tooltip(
button(text("").size(12).color(colors.text_primary))
.on_press(Message::SubtoolsToggle(slot_idx))
.padding(0)
.width(12)
.height(14)
.style(move |_theme, _status| iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
colors.bg_app.r,
colors.bg_app.g,
colors.bg_app.b,
0.82,
))),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
}),
format!("Open {} sub-tools", slot.label),
iced::widget::tooltip::Position::Right,
colors,
)
} else {
iced::widget::Space::with_width(0).into()
};
let btn_element: Element<'a, Message> = iced::widget::Stack::new()
.push(btn)
.push(container(popup_affordance).padding(iced::Padding {
top: 14.0,
bottom: 0.0,
left: 22.0,
right: 0.0,
}))
.width(34)
.height(28)
.into();
// Build the tool button with optional sub-tools popup
if show_popup {
let mut popup_grid = column![].spacing(1);
let mut popup_row = row![].spacing(1);
for (tool_index, &tool) in slot.tools.iter().enumerate() {
let tool_name = tool.label();
let is_tool_active = tool == tool_state.active_tool;
let c = colors;
let tool_icon_path = tool_icon(tool);
let resolved_path = resolve_icon_path(tool_icon_path);
let popup_icon: Element<'_, Message> = if resolved_path.exists() {
let handle = svg::Handle::from_path(&resolved_path);
svg(handle)
.width(18)
.height(18)
.style(move |_theme, _status| svg::Style {
color: Some(if is_tool_active {
iced::Color::WHITE
} else {
c.text_primary
}),
})
.into()
} else {
// Fallback: show first letter of tool name when icon is missing
text(tool_name.chars().next().unwrap_or('?').to_string())
.size(14)
.color(if is_tool_active {
iced::Color::WHITE
} else {
c.text_primary
})
.into()
};
// Wrap the icon in a fixed-size, centered container so the popup
// button always occupies 32×28 pixels even when the SVG is still
// loading or the text fallback is used.
let icon_container = container(popup_icon)
.width(20)
.height(20)
.center_x(Length::Fill)
.center_y(Length::Fill);
let item_content = container(icon_container).width(32).height(28);
let item = button(
container(item_content)
.width(32)
.height(28)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.on_press(Message::ToolSelected(tool))
.padding(0)
.width(32)
.height(28)
.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(if is_tool_active {
c.accent_green
} else {
c.bg_hover
})),
text_color: c.text_primary,
border: iced::Border::default()
.color(if is_tool_active {
c.accent
} else {
c.border_high
})
.width(if is_tool_active { 1 } else { 0 }),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(if is_tool_active {
c.accent_green
} else {
c.bg_active
})),
text_color: c.text_primary,
border: iced::Border::default()
.color(if is_tool_active {
c.accent
} else {
c.border_high
})
.width(if is_tool_active { 1 } else { 0 }),
..Default::default()
},
},
);
let shortcut = tool_shortcut(tool);
let tooltip_text = if shortcut.is_empty() {
tool_name.to_string()
} else {
format!("{tool_name} ({shortcut})")
};
popup_row = popup_row.push(styles::balloon_tooltip(
item,
tooltip_text,
iced::widget::tooltip::Position::Right,
colors,
));
if (tool_index + 1) % 2 == 0 || tool_index + 1 == slot.tools.len() {
popup_grid = popup_grid.push(popup_row);
popup_row = row![].spacing(1);
}
}
let popup = container(popup_grid)
.padding(1)
.width(68)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.accent).width(1),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
offset: iced::Vector::new(1.0, 0.0),
blur_radius: 5.0,
},
..Default::default()
});
let parent = container(styles::balloon_tooltip(
btn_element,
format!("{} (sub-tools open)", slot.label),
iced::widget::tooltip::Position::Right,
colors,
))
.width(68);
container(column![parent, popup].spacing(1))
.width(68)
.into()
} else {
// Tooltip on hover
let tooltip_text = slot.label.to_string();
styles::balloon_tooltip(
btn_element,
tooltip_text,
iced::widget::tooltip::Position::Right,
colors,
)
}
}
/// Returns the SVG asset associated with an individual tool, including subtools.
///
/// **Arguments:** `tool` is the concrete tool represented by a toolbox slot or popup row.
/// **Returns:** The crate-relative SVG path for the concrete tool.
/// **Side Effects / Dependencies:** None; callers resolve the returned path against assets.
fn tool_icon(tool: Tool) -> &'static str {
match tool {
Tool::Move => "icons/move.svg",
Tool::VectorSelect => "icons/vector_select.svg",
Tool::Select => "icons/rect_select.svg",
Tool::Lasso | Tool::PolygonSelect => "icons/lasso.svg",
Tool::MagicWand => "icons/magic_wand.svg",
Tool::SmartSelect | Tool::VisionSelect => "icons/smart_select.svg",
Tool::Crop => "icons/crop.svg",
Tool::Eyedropper => "icons/eyedropper.svg",
Tool::Brush => "icons/brush.svg",
Tool::Pen => "icons/pen.svg",
Tool::Spray => "icons/spray.svg",
Tool::Eraser => "icons/eraser.svg",
Tool::FloodFill => "icons/bucket.svg",
Tool::Gradient => "icons/gradient.svg",
Tool::Text => "icons/text.svg",
Tool::VectorLine => "icons/line.svg",
Tool::VectorRect => "icons/vector_rect.svg",
Tool::VectorCircle => "icons/circle.svg",
Tool::VectorArrow => "icons/vector_arrow.svg",
Tool::VectorStar => "icons/star.svg",
Tool::VectorPolygon => "icons/vector_polygon.svg",
Tool::VectorRhombus => "icons/vector_rhombus.svg",
Tool::VectorCylinder => "icons/vector_cylinder.svg",
Tool::VectorHeart => "icons/vector_heart.svg",
Tool::VectorBubble => "icons/vector_bubble.svg",
Tool::VectorGear => "icons/vector_gear.svg",
Tool::VectorCross => "icons/vector_cross.svg",
Tool::VectorCrescent => "icons/vector_crescent.svg",
Tool::VectorBolt => "icons/vector_bolt.svg",
Tool::VectorArrow4 => "icons/vector_arrow4.svg",
Tool::SpotRemoval => "icons/spot.svg",
Tool::RedEyeRemoval => "icons/redeye.svg",
Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg",
Tool::CustomShape(_) => "icons/star.svg",
}
}
/// Resolves an SVG path across supported repository and application working directories.
///
/// **Arguments:** `icon` is the asset-relative SVG path.
/// **Returns:** The first existing path, or the application-relative fallback.
/// **Side Effects / Dependencies:** Reads filesystem metadata without modifying it.
fn resolve_icon_path(icon: &str) -> std::path::PathBuf {
let candidates = [
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets")
.join(icon),
std::path::PathBuf::from(icon),
std::path::Path::new("assets").join(icon),
std::path::Path::new("hcie-iced-app/assets").join(icon),
];
candidates
.into_iter()
.find(|path| path.exists())
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets")
.join(icon)
})
}
/// Get keyboard shortcut display for a tool.
fn tool_shortcut(tool: Tool) -> &'static str {
match tool {
Tool::Move => "V",
Tool::VectorSelect => "A",
Tool::Select => "M",
Tool::Lasso => "L",
Tool::PolygonSelect => "L",
Tool::MagicWand => "W",
Tool::Eyedropper => "I",
Tool::Crop => "C",
Tool::Brush => "B",
Tool::Pen => "P",
Tool::Spray => "B",
Tool::Eraser => "E",
Tool::FloodFill => "G",
Tool::Gradient => "G",
Tool::Text => "T",
Tool::VectorLine => "U",
Tool::VectorRect => "U",
Tool::VectorCircle => "U",
_ => "",
}
}
/// 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)
}
#[cfg(test)]
mod tests {
use super::{resolve_icon_path, tool_icon, TOOL_SLOTS};
/// Verifies every concrete popup choice resolves to an existing SVG asset.
#[test]
fn every_subtool_has_an_svg_icon() {
for tool in TOOL_SLOTS
.iter()
.flat_map(|slot| slot.tools.iter().copied())
{
let path = resolve_icon_path(tool_icon(tool));
assert!(
path.exists(),
"missing icon for {tool:?}: {}",
path.display()
);
}
}
}