//! Layers panel — Photoshop-like layer management with thumbnails, badges, //! blend mode selector, opacity/fill controls, group hierarchy, and effects display. //! //! Layout (top to bottom): //! 1. Active layer controls — blend mode pick_list, opacity slider, lock toggle //! 2. Scrollable layer list — each row shows indent, collapse arrow, visibility, thumbnail, //! name, FX icon, and type badge. Layers with effects show expandable effect sub-items. //! 3. Bottom toolbar — add layer, add group, flatten, delete, move up/down //! //! Z-order convention: //! The engine renders layers in array order: the first layer is at the back, the //! last layer is at the front. The UI list mirrors this order, so the topmost //! (front-most) layer appears at the bottom of the list. Therefore "Move Up" in //! the toolbar moves the layer toward the end of the array / front of the canvas, //! and "Move Down" moves it toward the start / back. use crate::app::Message; use crate::panels::styles; use crate::panels::typography::BODY; use crate::theme::ThemeColors; use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::{BlendMode, LayerInfo, LayerType}; use iced::widget::{ button, column, container, horizontal_rule, pick_list, row, scrollable, svg, text, Space, }; use iced::{Element, Length}; use std::path::Path; /// Wrapper for BlendMode to provide Display for pick_list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct BlendModeItem(BlendMode); impl std::fmt::Display for BlendModeItem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0.label()) } } impl From for BlendModeItem { fn from(m: BlendMode) -> Self { Self(m) } } impl From for BlendMode { fn from(m: BlendModeItem) -> Self { m.0 } } const BLEND_MODE_ITEMS: &[BlendModeItem] = &[ BlendModeItem(BlendMode::Normal), BlendModeItem(BlendMode::Dissolve), BlendModeItem(BlendMode::Darken), BlendModeItem(BlendMode::Multiply), BlendModeItem(BlendMode::ColorBurn), BlendModeItem(BlendMode::LinearBurn), BlendModeItem(BlendMode::DarkerColor), BlendModeItem(BlendMode::Lighten), BlendModeItem(BlendMode::Screen), BlendModeItem(BlendMode::ColorDodge), BlendModeItem(BlendMode::LinearDodge), BlendModeItem(BlendMode::LighterColor), BlendModeItem(BlendMode::Overlay), BlendModeItem(BlendMode::SoftLight), BlendModeItem(BlendMode::HardLight), BlendModeItem(BlendMode::VividLight), BlendModeItem(BlendMode::LinearLight), BlendModeItem(BlendMode::PinLight), BlendModeItem(BlendMode::HardMix), BlendModeItem(BlendMode::Difference), BlendModeItem(BlendMode::Exclusion), BlendModeItem(BlendMode::Subtract), BlendModeItem(BlendMode::Divide), BlendModeItem(BlendMode::Hue), BlendModeItem(BlendMode::Saturation), BlendModeItem(BlendMode::Color), BlendModeItem(BlendMode::Luminosity), BlendModeItem(BlendMode::PassThrough), ]; /// Tree entry for rendering nested layer hierarchy. struct LayerEntry<'a> { info: &'a LayerInfo, depth: usize, is_group: bool, } /// Build a flat rendering order from the layer list, respecting group hierarchy. /// Layers are returned in visual order: the top/front-most layer first and the /// bottom/back-most layer last. This matches Photoshop-style panels where the /// layer you see on top of the canvas appears at the top of the list. fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec> { let mut children_of: std::collections::HashMap, Vec<&LayerInfo>> = std::collections::HashMap::new(); for info in layers.iter().rev() { children_of.entry(info.parent_id).or_default().push(info); } let mut result = Vec::new(); fn walk<'a>( parent_id: Option, depth: usize, children_of: &std::collections::HashMap, Vec<&'a LayerInfo>>, result: &mut Vec>, ) { if let Some(children) = children_of.get(&parent_id) { // children is already in reverse-engine order (front-most first); walk // it as-is so the list renders front-to-back. for info in children { let is_group = info.layer_type == LayerType::Group; result.push(LayerEntry { info, depth, is_group, }); if is_group && !info.collapsed { walk(Some(info.id), depth + 1, children_of, result); } } } } walk(None, 0, &children_of, &mut result); result } /// Photoshop-like flat button style (no background, subtle border on hover). fn flat_btn_style(colors: ThemeColors) -> iced::widget::button::Style { iced::widget::button::Style { background: Some(iced::Background::Color(iced::Color::TRANSPARENT)), border: iced::Border::default().color(iced::Color::TRANSPARENT), text_color: colors.text_primary, ..Default::default() } } /// Helper to create a small SVG icon button. /// Falls back to a text button if the SVG file is missing. fn svg_icon_btn<'a>( icon_path: &str, _tip: &'a str, msg: Message, colors: ThemeColors, size: f32, ) -> Element<'a, Message> { let full_path = Path::new("hcie-iced-app/assets").join(icon_path); if full_path.exists() { let icon = svg(svg::Handle::from_path(&full_path)) .width(size) .height(size) .style(move |_theme: &iced::Theme, _status: svg::Status| svg::Style { color: Some(colors.text_primary), }); button(icon) .on_press(msg) .padding([2, 3]) .style(move |_theme, _status: iced::widget::button::Status| flat_btn_style(colors)) .into() } else { // Fallback to text button(text(_tip).size((size * 0.6) as u16)) .on_press(msg) .padding([2, 3]) .style(move |_theme, _status: iced::widget::button::Status| flat_btn_style(colors)) .into() } } /// Build the thumbnail element for a layer row from the cache or as a fallback icon. fn layer_thumbnail<'a>( entry: &LayerEntry<'a>, thumb_cache: &std::collections::HashMap)>, _thumb_gen: u64, colors: ThemeColors, ) -> Element<'a, Message> { if entry.is_group { return container(text("\u{1F4C1}").size(16)) .width(50) .height(38) .center_y(Length::Fixed(38.0)) .into(); } if let Some((tw, th, pixels)) = thumb_cache.get(&entry.info.id) { let (tw32, th32) = (*tw as u32, *th as u32); let handle = iced::widget::image::Handle::from_rgba(tw32, th32, pixels.clone()); let img = iced::widget::Image::new(handle) .width(Length::Fixed(*tw as f32)) .height(Length::Fixed(*th as f32)); return container(img) .width(50) .height(38) .center_y(Length::Fixed(38.0)) .padding(1) .style(move |_theme| iced::widget::container::Style { border: iced::Border::default().color(colors.border_high).width(1), ..Default::default() }) .into(); } // Fallback: type-based icon let (icon_char, icon_color) = match entry.info.layer_type { LayerType::Vector => ("V", iced::Color::from_rgb(0.4, 0.8, 0.4)), LayerType::Text => ("T", iced::Color::from_rgb(0.4, 0.6, 1.0)), LayerType::Mask => ("M", iced::Color::from_rgb(0.8, 0.4, 0.8)), _ => ("\u{25A3}", colors.text_secondary), }; container(text(icon_char).size(12).style(move |_theme: &iced::Theme| { iced::widget::text::Style { color: Some(icon_color), } })) .width(50) .height(38) .center_x(Length::Fixed(50.0)) .center_y(Length::Fixed(38.0)) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 1.0, 1.0, 1.0, 0.05, ))), border: iced::Border::default().color(colors.border_high).width(1), ..Default::default() }) .into() } /// Build the layers panel element. pub fn view<'a>( layers: &'a [LayerInfo], active_layer_id: u64, engine: &hcie_engine_api::Engine, colors: ThemeColors, thumb_cache: &'a std::collections::HashMap)>, thumb_gen: u64, ) -> Element<'a, Message> { let entries = build_tree(layers); // ── Active layer controls ────────────────────────────── let active_info = layers.iter().find(|l| l.id == active_layer_id); let active_blend = active_info .map(|l| l.blend_mode) .unwrap_or(BlendMode::Normal); let active_opacity = active_info.map(|l| l.opacity).unwrap_or(1.0); let active_locked = active_info.map(|l| l.locked).unwrap_or(false); let blend_list = pick_list( BLEND_MODE_ITEMS, Some(BlendModeItem(active_blend)), move |item| Message::LayerSetBlendMode(active_layer_id, item.into()), ) .width(Length::Fill) .text_size(BODY); let opacity_slider = plain_slider( "Opacity", active_opacity, 0.0..=1.0, 0.01, "%", 0, colors, move |v| Message::LayerSetOpacity(active_layer_id, v), ); let opacity_row = opacity_slider; let lock_icon = if active_locked { "\u{1F512}" } else { "\u{1F513}" }; let lock_btn = button(text(lock_icon).size(11)) .on_press(Message::LayerToggleLock(active_layer_id)) .padding([1, 4]) .style(move |_theme, _status| flat_btn_style(colors)); let fx_btn = button(text("fx").size(11)) .on_press(Message::OpenLayerStyleDialog) .padding([1, 4]) .style(move |_theme, _status| flat_btn_style(colors)); let controls = column![ row![blend_list].spacing(2), opacity_row, text("Fill opacity: unavailable in hcie-engine-api") .size(BODY) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(colors.text_secondary), }), row![lock_btn, fx_btn] .spacing(8) .align_y(iced::Alignment::Center), ] .spacing(2); // ── Layer list ───────────────────────────────────────── let mut layer_list = column![].spacing(1); for entry in &entries { let info = entry.info; let is_active = info.id == active_layer_id; let depth = entry.depth; // Indent spacer let indent: Element<'a, Message> = Space::with_width(Length::Fixed((depth as f32) * 14.0)).into(); // Collapse arrow (groups only) let collapse: Element<'a, Message> = if entry.is_group { let arrow = if info.collapsed { "\u{25B6}" } else { "\u{25BC}" }; button(text(arrow).size(8)) .on_press(Message::LayerToggleCollapse(info.id)) .padding([0, 1]) .style(move |_theme, _status| flat_btn_style(colors)) .into() } else { Space::with_width(Length::Fixed(12.0)).into() }; // Visibility toggle (eye icon) let vis_icon_path = if info.visible { "icons/visible.svg" } else { "icons/eye_closed.svg" }; let vis_tip = if info.visible { "Hide" } else { "Show" }; let vis_btn = svg_icon_btn( vis_icon_path, vis_tip, Message::LayerToggleVisibility(info.id), colors, 14.0, ); let lock_icon_path = if info.locked { "icons/lock.svg" } else { "icons/unlock.svg" }; let row_lock = svg_icon_btn( lock_icon_path, if info.locked { "Unlock" } else { "Lock" }, Message::LayerToggleLock(info.id), colors, 12.0, ); // Thumbnail let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors); // Layer name let c = if is_active { colors.accent } else { colors.text_primary }; let name_text = text(&info.name) .size(BODY) .width(Length::Fill) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) }); // "fx" icon for layers with effects (Photoshop-style) let fx_icon: Element<'a, Message> = if info.has_effects { button(text("fx").size(8).style(move |_theme: &iced::Theme| { iced::widget::text::Style { color: Some(colors.accent), } })) .on_press(Message::LayerStyleToggle(0)) // Opens styles panel .padding([0, 2]) .style(move |_theme, _status| flat_btn_style(colors)) .into() } else { Space::with_width(Length::Fixed(1.0)).into() }; // Type badge let type_badge: Element<'a, Message> = match info.layer_type { LayerType::Vector => text("V") .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.4, 0.8, 0.4)), }) .into(), LayerType::Text => text("T") .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.4, 0.6, 1.0)), }) .into(), LayerType::Group => text("G") .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.85, 0.7, 0.3)), }) .into(), LayerType::Mask => text("M") .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)), }) .into(), _ => text("").size(8).into(), }; // Main layer row let name_row = row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,] .spacing(2) .align_y(iced::Alignment::Center); let layer_item = if is_active { container(name_row) .width(Length::Fill) .padding([2, 3]) .style(move |_theme| { let accent_bg = iced::Color::from_rgba( colors.accent.r, colors.accent.g, colors.accent.b, 0.18, ); iced::widget::container::Style { background: Some(iced::Background::Color(accent_bg)), border: iced::Border::default() .color(iced::Color::from_rgba( colors.accent.r, colors.accent.g, colors.accent.b, 0.4, )) .width(1), ..Default::default() } }) } else { container(name_row) .width(Length::Fill) .padding([2, 3]) .style(move |_theme| styles::inactive_item_bg(colors)) }; let clickable = iced::widget::mouse_area(layer_item).on_press(Message::LayerSelect(info.id)); let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center); layer_list = layer_list.push(row_with_indent); // ── Expandable effects sub-items (Photoshop-style) ── if info.has_effects && is_active { let badges = engine.get_layer_style_badges(info.id); let effects_depth = depth + 2; for (code, enabled) in &badges { if *enabled { let effect_name = effect_full_name(code); let effect_color = fx_color(code); let effect_indent = Space::with_width(Length::Fixed((effects_depth as f32) * 14.0)); let effect_row = row![ effect_indent, text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| { iced::widget::text::Style { color: Some(colors.text_secondary), } }), text(effect_name) .size(BODY) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(effect_color), }), ] .spacing(2) .align_y(iced::Alignment::Center); layer_list = layer_list.push(effect_row); } } } } // ── Bottom toolbar ───────────────────────────────────── let add_btn = svg_icon_btn( "icons/new_file.svg", "New Layer", Message::LayerAdd, colors, 16.0, ); let group_btn = svg_icon_btn( "icons/panel-layers.svg", "New Group", Message::LayerAddGroup, colors, 16.0, ); let flatten_btn = button(text("Flatten").size(BODY)) .on_press(Message::LayerFlatten) .padding([5, 8]) .style(move |_theme, _status| flat_btn_style(colors)); let del_btn = svg_icon_btn( "icons/close.svg", "Delete Layer", Message::LayerDelete(active_layer_id), colors, 14.0, ); let move_up_btn = button(text("\u{25B2}").size(BODY)) .on_press(Message::LayerMoveUp(active_layer_id)) .padding([3, 4]) .style(move |_theme, _status| flat_btn_style(colors)); let move_down_btn = button(text("\u{25BC}").size(BODY)) .on_press(Message::LayerMoveDown(active_layer_id)) .padding([3, 4]) .style(move |_theme, _status| flat_btn_style(colors)); let fx_btn = button(text("fx").size(BODY)) .on_press(Message::OpenLayerStyleDialog) .padding([3, 6]) .style(move |_theme, _status| flat_btn_style(colors)); let duplicate_btn = button(text("Duplicate").size(BODY)).padding([5, 8]); let mask_btn = button(text("Mask").size(BODY)).padding([5, 8]); let rasterize_btn = button(text("Rasterize").size(BODY)).padding([5, 8]); let toolbar = column![ text("Unavailable: duplicate, mask, rasterize").size(BODY), row![duplicate_btn, mask_btn] .spacing(2) .align_y(iced::Alignment::Center), row![rasterize_btn].align_y(iced::Alignment::Center), row![add_btn, group_btn] .spacing(2) .align_y(iced::Alignment::Center), row![flatten_btn, fx_btn] .spacing(2) .align_y(iced::Alignment::Center), row![move_up_btn, move_down_btn, del_btn] .spacing(2) .align_y(iced::Alignment::Center), ] .spacing(2); // ── Panel layout (no duplicate title — dock tab provides it) ── let panel = column![ controls, horizontal_rule(1), scrollable(layer_list).height(Length::Fill), horizontal_rule(1), toolbar, ] .spacing(2) .padding(4); container(panel) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| styles::panel_background(colors)) .into() } /// Map FX badge code to full effect name (for expandable sub-items). fn effect_full_name(code: &str) -> &'static str { match code { "DS" => "Drop Shadow", "IS" => "Inner Shadow", "OG" => "Outer Glow", "IG" => "Inner Glow", "BE" => "Bevel & Emboss", "St" => "Satin", "CO" => "Color Overlay", "GO" => "Gradient Overlay", "PO" => "Pattern Overlay", "Sr" => "Stroke", _ => "Effect", } } /// Map FX badge code to a display color (matches egui palette). fn fx_color(code: &str) -> iced::Color { match code { "DS" => iced::Color::from_rgb(0.86, 0.39, 0.39), "IS" => iced::Color::from_rgb(0.71, 0.47, 0.47), "OG" => iced::Color::from_rgb(0.86, 0.86, 0.39), "IG" => iced::Color::from_rgb(0.71, 0.71, 0.39), "BE" => iced::Color::from_rgb(0.39, 0.71, 0.86), "St" => iced::Color::from_rgb(0.71, 0.55, 0.86), _ => iced::Color::from_rgb(0.5, 0.5, 0.5), } }