Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs
T
Your Name 3bac629045
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
feat: add unlock icon and update layer lock UI, and fix document loading index management and cache synchronization
2026-07-23 18:16:49 +03:00

596 lines
21 KiB
Rust

//! 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<BlendMode> for BlendModeItem {
fn from(m: BlendMode) -> Self {
Self(m)
}
}
impl From<BlendModeItem> 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<LayerEntry<'a>> {
let mut children_of: std::collections::HashMap<Option<u64>, 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<u64>,
depth: usize,
children_of: &std::collections::HashMap<Option<u64>, Vec<&'a LayerInfo>>,
result: &mut Vec<LayerEntry<'a>>,
) {
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<u64, (u32, u32, Vec<u8>)>,
_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<u64, (u32, u32, Vec<u8>)>,
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),
}
}