867 lines
31 KiB
Rust
867 lines
31 KiB
Rust
//! 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()
|
||
);
|
||
}
|
||
}
|
||
}
|