GOOD feat: add Layer Styles panel and enhance filter categories

- Introduced a new Layer Styles panel that displays effects with toggle checkboxes for quick enable/disable functionality.
- Updated the filters panel to support collapsible filter categories, allowing users to expand or collapse categories.
- Enhanced the application state to manage expanded filter categories using a HashMap.
- Modified the layout and sizing of various panels to accommodate the new Layer Styles panel.
- Updated theme colors for better visual consistency across the application.
- Improved the toolbar to streamline brush size and opacity controls.
This commit is contained in:
2026-07-20 21:23:04 +03:00
parent 3659b886ff
commit 325bd0d8e8
12 changed files with 347 additions and 508 deletions
@@ -216,6 +216,8 @@ pub struct HcieIcedApp {
pub filter_params: serde_json::Value,
/// Filter preview state.
pub filter_preview_active: bool,
/// Which filter categories are expanded (name → expanded).
pub filter_categories_expanded: std::collections::HashMap<String, bool>,
/// Immutable active-layer pixels used by filter and adjustment previews.
preview_baseline: Option<PreviewBaseline>,
/// Original styles restored when the Layer Style dialog is cancelled.
@@ -758,6 +760,7 @@ pub enum Message {
FilterPreviewToggle(bool),
FilterPreviewApply,
FilterPreviewRestore,
FilterCategoryToggle(String),
// ── Dialogs ─────────────────────────────────────────
DialogOpen(ActiveDialog),
@@ -1634,6 +1637,7 @@ impl HcieIcedApp {
selected_filter: None,
filter_params: serde_json::json!({}),
filter_preview_active: false,
filter_categories_expanded: std::collections::HashMap::new(),
preview_baseline: None,
layer_style_baseline: None,
pending_file_operation: None,
@@ -4851,6 +4855,14 @@ impl HcieIcedApp {
}
}
Message::FilterCategoryToggle(category) => {
let entry = self
.filter_categories_expanded
.entry(category)
.or_insert(true);
*entry = !*entry;
}
// ── Dialogs ───────────────────────────────────
Message::DialogOpen(dialog) => {
if matches!(
@@ -87,7 +87,7 @@ pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy {
width: axis(240.0, 400.0, f32::INFINITY, 30),
height: axis(140.0, 320.0, f32::INFINITY, 30),
},
PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings => PaneSizePolicy {
PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings | PaneType::LayerStyles => PaneSizePolicy {
width: axis(160.0, 280.0, f32::INFINITY, 25),
height: axis(120.0, 280.0, f32::INFINITY, 25),
},
@@ -5,13 +5,13 @@
///! The default layout mirrors the egui GUI arrangement with Canvas centered:
///!
///! ```text
///! +--------+------------+-----------+---------+
///! | | | | Layers |
///! | Brushes| | |---------|
///! |--------| Canvas | ColorBox |Properties|
///! | Filters| |-----------|---------|
///! | | | History | |
///! +--------+------------+-----------+---------+
///! +--------+------------+-----------+
///! | | | ColorBox |
///! | Brushes| |-----------|
///! |--------| Canvas | Layers |
///! | Filters| | |
///! | | | |
///! +--------+------------+-----------+
///! ```
use crate::dock::floating::FloatingDragState;
use crate::dock::manager::{
@@ -59,11 +59,13 @@ pub enum PaneType {
AiScript,
/// Custom SVG shapes panel.
CustomShapes,
/// Layer Styles panel.
LayerStyles,
}
impl PaneType {
/// All panel payloads in stable declaration order.
pub const ALL: [Self; 14] = [
pub const ALL: [Self; 15] = [
Self::Canvas,
Self::Layers,
Self::History,
@@ -78,6 +80,7 @@ impl PaneType {
Self::ToolSettings,
Self::AiScript,
Self::CustomShapes,
Self::LayerStyles,
];
/// Display name for the pane type.
@@ -97,6 +100,7 @@ impl PaneType {
PaneType::ToolSettings => "Tool Settings",
PaneType::AiScript => "AI Script Generator",
PaneType::CustomShapes => "Custom Shapes",
PaneType::LayerStyles => "Layer Styles",
}
}
@@ -138,6 +142,7 @@ impl PaneType {
Self::LayerDetails => "D",
Self::ToolSettings => "T",
Self::CustomShapes => "CS",
Self::LayerStyles => "LS",
Self::Canvas => "",
}
}
@@ -218,10 +223,10 @@ mod tests {
assert!(dock.is_pane_open(PaneType::Brushes));
assert!(dock.invariants_hold());
let properties = pane(&dock, PaneType::Properties);
assert!(dock.float_pane(properties, (1280.0, 800.0)));
assert!(dock.auto_hide_floating(PaneType::Properties, DockEdge::Right));
assert!(dock.auto_hidden[1].contains(&PaneType::Properties));
let filters = pane(&dock, PaneType::Filters);
assert!(dock.float_pane(filters, (1280.0, 800.0)));
assert!(dock.auto_hide_floating(PaneType::Filters, DockEdge::Right));
assert!(dock.auto_hidden[1].contains(&PaneType::Filters));
assert!(dock.invariants_hold());
}
@@ -309,13 +314,13 @@ impl DockState {
///
/// Dock layout (without toolbox), left → right:
/// ```text
/// +------------+-----------+-----------+-----------+
/// | Brushes | | Layers | |
/// |------------| Canvas |-----------|Properties |
/// | Filters | | ColorBox |-----------|
/// | | |-----------| History |
/// +------------+-----------+-----------+-----------+
/// ~14% ~66% ~10% ~10%
/// +------------+-----------+-----------+
/// | Brushes | | ColorBox |
/// |------------| Canvas |-----------|
/// | Filters | | Layers |
/// | | | |
/// +------------+-----------+-----------+
/// ~14% ~66% ~20%
/// ```
///
/// Axis semantics in iced PaneGrid:
@@ -330,41 +335,23 @@ impl DockState {
b: Box::new(Configuration::Pane(PaneType::Filters)),
};
// Right column 1: Layers (top) / ColorBox (bottom) — horizontal split
let right_col1 = Configuration::Split {
// Right column: ColorPicker (top, prominent) / Layers (bottom)
let right_column = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Horizontal,
ratio: 0.5,
a: Box::new(Configuration::Pane(PaneType::Layers)),
b: Box::new(Configuration::Pane(PaneType::ColorPicker)),
ratio: 0.6,
a: Box::new(Configuration::Pane(PaneType::ColorPicker)),
b: Box::new(Configuration::Pane(PaneType::Layers)),
};
// Right column 2: Properties (top) / History (bottom) — horizontal split
let right_col2 = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Horizontal,
ratio: 0.5,
a: Box::new(Configuration::Pane(PaneType::Properties)),
b: Box::new(Configuration::Pane(PaneType::History)),
};
// Right side: right_col1 (left) / right_col2 (right) — vertical split
let right_side = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Vertical,
ratio: 0.5,
a: Box::new(right_col1),
b: Box::new(right_col2),
};
// Center + right: Canvas (left) / right_side (right) — vertical split
// Canvas gets ~66% — right panels need room for layers + properties
// Center + right: Canvas (left) / right_column (right) — vertical split
let center_right = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Vertical,
ratio: 0.66,
a: Box::new(Configuration::Pane(PaneType::Canvas)),
b: Box::new(right_side),
b: Box::new(right_column),
};
// Root: left_column (left) / center_right (right) — vertical split
// Left column gets ~14% of total width.
let root_config = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Vertical,
ratio: 0.14,
@@ -862,6 +849,7 @@ impl DockState {
| PaneType::Properties
| PaneType::History
| PaneType::LayerDetails
| PaneType::LayerStyles
| PaneType::AiScript => iced::widget::pane_grid::Axis::Horizontal,
_ => iced::widget::pane_grid::Axis::Vertical,
};
@@ -192,6 +192,7 @@ pub fn panel_body<'a>(
.engine
.active_layer_is_editable(),
colors,
&app.filter_categories_expanded,
),
PaneType::ColorPicker => crate::color_picker::view(
&app.fg_color,
@@ -302,6 +303,12 @@ pub fn panel_body<'a>(
colors,
)
}
PaneType::LayerStyles => {
let doc = &app.documents[app.active_doc];
let active_id = doc.engine.active_layer_id();
let layer_styles = doc.engine.get_layer_styles(active_id);
crate::panels::layer_styles::panel_view(layer_styles, colors)
}
}
}
@@ -380,14 +387,14 @@ fn document_tab_bar<'a>(
if active {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_active)),
border: iced::Border::default().color(c.accent).width(1),
border: iced::Border::default().color(c.accent).width(1.0),
text_color: c.accent,
..Default::default()
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_panel)),
border: iced::Border::default().color(c.border_low).width(1),
border: iced::Border::default().color(c.border_low).width(1.0),
text_color: c.text_primary,
..Default::default()
}
@@ -397,14 +404,14 @@ fn document_tab_bar<'a>(
if active {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_active)),
border: iced::Border::default().color(c.accent).width(1),
border: iced::Border::default().color(c.accent).width(1.0),
text_color: c.accent,
..Default::default()
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
border: iced::Border::default().color(c.border_high).width(1),
border: iced::Border::default().color(c.border_high).width(1.0),
text_color: c.text_primary,
..Default::default()
}
@@ -412,7 +419,7 @@ fn document_tab_bar<'a>(
}
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_active)),
border: iced::Border::default().color(c.accent).width(1),
border: iced::Border::default().color(c.accent).width(1.0),
text_color: c.accent,
..Default::default()
},
@@ -470,6 +477,7 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
PaneType::ToolSettings => Some("icons/panel-settings.svg"),
PaneType::AiScript => Some("icons/panel-ai.svg"),
PaneType::CustomShapes => Some("icons/star.svg"),
PaneType::LayerStyles => Some("icons/panel-settings.svg"),
}
}
@@ -156,54 +156,84 @@ const FILTER_CATEGORIES: &[FilterCategory] = &[
/// Build the filters panel.
///
/// Shows the filter category list on top, and when a filter is selected,
/// displays its parameter editing panel below the list. The preview checkbox,
/// Apply, and Reset buttons are always at the bottom.
/// Shows the filter category list on top with collapsible groups, and when a
/// filter is selected, displays its parameter editing panel below the list.
/// The preview checkbox, Apply, and Reset buttons are always at the bottom.
pub fn view(
selected_filter: Option<FilterType>,
filter_params: &serde_json::Value,
filter_preview_active: bool,
layer_editable: bool,
colors: ThemeColors,
categories_expanded: &std::collections::HashMap<String, bool>,
) -> Element<'static, Message> {
let mut filter_list = column![].spacing(2);
for category in FILTER_CATEGORIES {
let cat_name = text(category.name).size(12);
let is_expanded = *categories_expanded.get(category.name).unwrap_or(&true);
let arrow = if is_expanded { "\u{25BC}" } else { "\u{25B6}" };
let mut cat_filters = column![].spacing(1).padding(iced::Padding::ZERO.left(12));
let cat_header = button(
row![
text(format!("{} {}", arrow, category.name)).size(12),
]
.spacing(4)
.align_y(iced::Alignment::Center),
)
.on_press(Message::FilterCategoryToggle(category.name.to_string()))
.width(Length::Fill)
.padding([4, 6])
.style(move |_theme, 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::TRANSPARENT)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
});
for entry in category.filters {
let is_selected = selected_filter == Some(entry.filter_type);
let filter_text = text(entry.name).size(11);
let filter_text = if is_selected {
filter_text.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.accent),
})
} else {
filter_text
};
filter_list = filter_list.push(cat_header);
let item_style = if is_selected {
styles::active_item_bg(colors)
} else {
styles::inactive_item_bg(colors)
};
if is_expanded {
let mut cat_filters = column![].spacing(1).padding(iced::Padding::ZERO.left(12));
let item = container(filter_text)
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme| item_style.clone());
for entry in category.filters {
let is_selected = selected_filter == Some(entry.filter_type);
let filter_text = text(entry.name).size(11);
let filter_text = if is_selected {
filter_text.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.accent),
})
} else {
filter_text
};
let filter_type = entry.filter_type;
let clickable =
iced::widget::mouse_area(item).on_press(Message::FilterSelect(filter_type));
let item_style = if is_selected {
styles::active_item_bg(colors)
} else {
styles::inactive_item_bg(colors)
};
cat_filters = cat_filters.push(clickable);
let item = container(filter_text)
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme| item_style.clone());
let filter_type = entry.filter_type;
let clickable =
iced::widget::mouse_area(item).on_press(Message::FilterSelect(filter_type));
cat_filters = cat_filters.push(clickable);
}
filter_list = filter_list.push(cat_filters);
}
filter_list = filter_list.push(cat_name);
filter_list = filter_list.push(cat_filters);
}
let apply_btn = if layer_editable {
@@ -875,3 +875,66 @@ fn slider_row<'a>(
.align_y(iced::Alignment::Center)
.into()
}
/// Docked Layer Styles panel — lists effects with enable checkboxes.
///
/// **Purpose:** A simplified, always-visible panel showing all layer effects
/// with toggle checkboxes. This is the docked counterpart to the floating
/// `view()` dialog; it provides quick enable/disable without full parameter editing.
pub fn panel_view<'a>(
styles_list: Vec<LayerStyle>,
colors: ThemeColors,
) -> Element<'a, Message> {
let mut effects_list = column![].spacing(1);
for (i, &(disc, label, color_arr)) in STYLE_DEFS.iter().enumerate() {
let enabled = style_enabled(disc, &styles_list);
let badge_color = iced::Color::from_rgb(color_arr[0], color_arr[1], color_arr[2]);
let toggle = iced::widget::checkbox("", enabled)
.on_toggle(move |_| Message::LayerStyleToggle(i as u32))
.size(11);
let label_text = text(label)
.size(11)
.width(Length::Fill)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(if enabled {
badge_color
} else {
colors.text_secondary
}),
});
let item_row = row![toggle, label_text]
.spacing(4)
.align_y(iced::Alignment::Center);
let item_style = if enabled {
styles::active_item_bg(colors)
} else {
styles::inactive_item_bg(colors)
};
let item = container(item_row)
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme| item_style.clone());
effects_list = effects_list.push(item);
}
let panel = column![
text("Layer Styles").size(11),
horizontal_rule(1),
scrollable(effects_list).height(Length::Fill),
]
.spacing(2)
.padding(4);
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))
.into()
}
@@ -212,19 +212,6 @@ pub fn view<'a>(
.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 active_metadata = active_info
.map(|layer| {
format!(
"{:?} {}x{} parent {}",
layer.layer_type,
layer.width,
layer.height,
layer
.parent_id
.map_or_else(|| "root".to_string(), |id| id.to_string())
)
})
.unwrap_or_else(|| "No active layer".to_string());
let blend_list = pick_list(
BLEND_MODE_ITEMS,
@@ -272,7 +259,6 @@ pub fn view<'a>(
row![lock_btn, fx_btn]
.spacing(8)
.align_y(iced::Alignment::Center),
text(active_metadata).size(BODY),
]
.spacing(2);
@@ -468,9 +454,6 @@ pub fn view<'a>(
.on_press(Message::LayerAddGroup)
.padding([5, 8])
.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 flatten_btn = button(text("Flatten").size(BODY))
.on_press(Message::LayerFlatten)
.padding([5, 8])
@@ -492,6 +475,9 @@ pub fn view<'a>(
.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]
@@ -704,6 +704,10 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
"Custom Shapes",
MenuCommand::TogglePane(PaneType::CustomShapes),
),
MenuItem::command(
"Layer Styles",
MenuCommand::TogglePane(PaneType::LayerStyles),
),
MenuItem::separator(),
MenuItem::command(
"Theme: Photopea",
@@ -798,7 +802,7 @@ pub fn dropdown_overlay(
let enabled = item.enabled;
// Check if this is a Window menu item that should show a checkmark
let is_window_panel = menu_idx == 8 && item_idx <= 11;
let is_window_panel = menu_idx == 8 && item_idx <= 12;
let is_checked = if is_window_panel {
let pane_type = match item_idx {
0 => Some(PaneType::Layers),
@@ -813,6 +817,7 @@ pub fn dropdown_overlay(
9 => Some(PaneType::LayerDetails),
10 => Some(PaneType::AiScript),
11 => Some(PaneType::CustomShapes),
12 => Some(PaneType::LayerStyles),
_ => None,
};
pane_type
@@ -1254,6 +1259,7 @@ mod tests {
"Window/Layer Details",
"Window/AI Script Generator",
"Window/Custom Shapes",
"Window/Layer Styles",
"Window/Theme: Photopea",
"Window/Theme: Photoshop",
"Window/Theme: ProDark (Monokai)",
@@ -146,30 +146,30 @@ pub fn hover_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
}
}
/// Menu bar background — dark like Photopea's menu bar.
/// Menu bar background — panel color for visual consistency.
#[allow(dead_code)]
pub fn menubar_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
}
/// Title bar background — dark like Photopea's title bar.
/// Title bar background — panel color for visual consistency.
#[allow(dead_code)]
pub fn titlebar_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
}
/// Status bar background — dark like Photopea's status bar.
/// Status bar background — panel color for consistency.
pub fn statusbar_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
@@ -194,10 +194,10 @@ pub fn dropdown_background(colors: ThemeColors) -> iced::widget::container::Styl
}
}
/// Document tab bar background — dark like Photopea's tab bar.
/// Document tab bar background — panel color for visual consistency.
pub fn tabbar_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
@@ -8,14 +8,13 @@ use crate::app::Message;
use crate::panels::menus::MenuCommand;
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
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.
/// Build the compact toolbar: SVG icon buttons + brush size + opacity in one row.
pub fn view<'a>(
tool_state: &'a crate::app::ToolState,
settings: &'a crate::settings::AppSettings,
_settings: &'a crate::settings::AppSettings,
colors: ThemeColors,
) -> Element<'a, Message> {
let c = colors;
@@ -46,8 +45,20 @@ pub fn view<'a>(
.spacing(0)
.align_y(iced::Alignment::Center);
// ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ──
let blend_value = compact_value("Normal".to_string(), "Blend mode: Normal", c);
// ── Brush size + opacity (compact, matching egui) ──
let size_value = compact_value(
format!("S {:.0}px", tool_state.brush_size),
"Brush size",
c,
);
let size_sl = slider(
1.0..=200.0,
tool_state.brush_size,
Message::BrushSizeChanged,
)
.step(1.0)
.width(Length::Fixed(64.0));
let opacity_value = compact_value(
format!("O {:.0}%", tool_state.brush_opacity * 100.0),
"Brush opacity",
@@ -59,144 +70,17 @@ pub fn view<'a>(
Message::BrushOpacityChanged,
)
.step(0.01)
.width(Length::Fixed(52.0));
.width(Length::Fixed(64.0));
let flow_value = compact_value(
format!("F {:.0}%", settings.tool_settings.brush_flow * 100.0),
"Brush flow",
c,
);
let flow_sl = slider(
0.0..=1.0,
settings.tool_settings.brush_flow,
Message::BrushFlowChanged,
)
.step(0.01)
.width(Length::Fixed(52.0));
let smooth_value = compact_value("S 0%".to_string(), "Stroke smoothing", c);
let tool_options = row![
blend_value,
sep(c),
opacity_value,
opacity_sl,
sep(c),
flow_value,
flow_sl,
sep(c),
smooth_value,
]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── Tool-specific options (right side) ──
let tool_options_extra = match tool_state.active_tool {
Tool::Brush | Tool::Eraser | Tool::Pen => {
let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Brush size", c);
let sz_sl = slider(
1.0..=200.0,
tool_state.brush_size,
Message::BrushSizeChanged,
)
.step(1.0)
.width(Length::Fixed(58.0));
let ha = compact_value(
format!("H {:.0}%", tool_state.brush_hardness * 100.0),
"Brush hardness",
c,
);
let ha_sl = slider(
0.0..=1.0,
tool_state.brush_hardness,
Message::BrushHardnessChanged,
)
.step(0.01)
.width(Length::Fixed(58.0));
row![sz, sz_sl, ha, ha_sl]
.spacing(4)
.align_y(iced::Alignment::Center)
}
Tool::Spray => {
let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Spray size", c);
let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged)
.step(1.0)
.width(Length::Fixed(50.0));
let ps = compact_value(
format!("P {:.1}px", settings.tool_settings.spray_particle_size),
"Particle size",
c,
);
let ps_sl = slider(
1.0..=20.0,
settings.tool_settings.spray_particle_size,
Message::SprayParticleSizeChanged,
)
.step(0.1)
.width(Length::Fixed(50.0));
let ds = compact_value(
format!("D {:.0}", settings.tool_settings.spray_density),
"Particle density",
c,
);
let ds_sl = slider(
10.0..=1000.0,
settings.tool_settings.spray_density as f32,
|v| Message::SprayDensityChanged(v as u32),
)
.step(1.0)
.width(Length::Fixed(50.0));
row![sz, sz_sl, ps, ps_sl, ds, ds_sl]
.spacing(4)
.align_y(iced::Alignment::Center)
}
Tool::MagicWand => {
let tol = settings.tool_settings.magic_wand_tolerance;
row![
compact_value(format!("T {:.0}", tol), "Selection tolerance", c),
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(
v as u8
))
.step(1.0)
.width(Length::Fixed(64.0)),
]
.spacing(6)
.align_y(iced::Alignment::Center)
}
Tool::Select | Tool::Lasso => {
let feather = settings.tool_settings.select_feather;
row![
compact_value(format!("F {:.0}px", feather), "Selection feather", c),
slider(0.0..=250.0, feather, Message::SelectionFeatherChanged)
.step(1.0)
.width(Length::Fixed(64.0)),
]
.spacing(6)
.align_y(iced::Alignment::Center)
}
Tool::Move => row![compact_value("Auto".to_string(), "Auto-select layer", c)]
.spacing(4)
.align_y(iced::Alignment::Center),
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
let stroke = settings.tool_settings.vector_stroke_width;
row![
compact_value(format!("{:.1}px", stroke), "Vector stroke width", c),
slider(0.0..=50.0, stroke, Message::VectorStrokeChanged)
.step(0.5)
.width(Length::Fixed(58.0)),
]
.spacing(4)
.align_y(iced::Alignment::Center)
}
_ => row![].spacing(0),
};
let tool_controls = row![size_value, size_sl, sep(c), opacity_value, opacity_sl]
.spacing(4)
.align_y(iced::Alignment::Center);
let bar = row![
actions,
sep(c),
tool_options,
tool_controls,
iced::widget::Space::with_width(Length::Fill),
tool_options_extra,
]
.spacing(4)
.align_y(iced::Alignment::Center)
@@ -264,32 +148,6 @@ fn svg_btn(
)
}
#[allow(dead_code)]
fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let c = colors;
let l = label.to_string();
button(text(l).size(10))
.on_press(Message::NoOp)
.padding([2, 6])
.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(c.bg_hover)),
text_color: c.text_primary,
border: iced::Border::default().color(c.border_high).width(1),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_panel)),
text_color: c.text_primary,
border: iced::Border::default().color(c.border_low).width(1),
..Default::default()
},
},
)
.into()
}
fn sep(colors: ThemeColors) -> Element<'static, Message> {
container(text("").height(16).width(1))
.padding([0, 2])
@@ -82,16 +82,16 @@ impl ThemeColors {
// ── Photopea (Default) ─────────────────────────────────────
// Exact colors from 93 Photopea screenshots.
ThemePreset::Photopea => Self {
bg_app: Color::from_rgb(0.118, 0.118, 0.118), // #1e1e1e - canvas
bg_panel: Color::from_rgb(0.165, 0.165, 0.165), // #2a2a2a - panels
bg_hover: Color::from_rgb(0.227, 0.227, 0.227), // #3a3a3a - hover
bg_active: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - active tab
bg_app: Color::from_rgb(0.110, 0.110, 0.110), // #1c1c1c - canvas (darker)
bg_panel: Color::from_rgb(0.153, 0.153, 0.153), // #272727 - panels (darker)
bg_hover: Color::from_rgb(0.220, 0.220, 0.220), // #383838 - hover
bg_active: Color::from_rgb(0.190, 0.190, 0.190), // #303030 - active tab (darker)
accent: Color::from_rgb(0.290, 0.620, 1.000), // #4a9eff - selections
accent_hover: Color::from_rgb(0.350, 0.680, 1.000),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), // #4caf50 - tool active
danger: Color::from_rgb(0.906, 0.298, 0.235), // #e74c3c - account btn
border_low: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - dividers
border_high: Color::from_rgb(0.267, 0.267, 0.267), // #444444 - borders
border_low: Color::from_rgb(0.180, 0.180, 0.180), // #2e2e2e - dividers (darker)
border_high: Color::from_rgb(0.250, 0.250, 0.250), // #404040 - borders (darker)
text_primary: Color::from_rgb(0.878, 0.878, 0.878), // #e0e0e0 - primary
text_secondary: Color::from_rgb(0.600, 0.600, 0.600), // #999999 - muted
text_disabled: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - disabled
@@ -103,10 +103,10 @@ impl ThemeColors {
},
// ── Photoshop (Legacy) ─────────────────────────────────────
ThemePreset::Photoshop => Self {
bg_app: Color::from_rgb(0.110, 0.110, 0.118),
bg_panel: Color::from_rgb(0.196, 0.196, 0.212),
bg_hover: Color::from_rgb(0.243, 0.243, 0.259),
bg_active: Color::from_rgb(0.227, 0.227, 0.243),
bg_app: Color::from_rgb(0.100, 0.100, 0.108),
bg_panel: Color::from_rgb(0.178, 0.178, 0.194),
bg_hover: Color::from_rgb(0.230, 0.230, 0.246),
bg_active: Color::from_rgb(0.210, 0.210, 0.226),
accent: Color::from_rgb(0.220, 0.471, 0.863),
accent_hover: Color::from_rgb(0.260, 0.520, 0.900),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
@@ -124,10 +124,10 @@ impl ThemeColors {
},
// ── Monokai ────────────────────────────────────────────────
ThemePreset::ProDark => Self {
bg_app: Color::from_rgb(0.086, 0.082, 0.071),
bg_panel: Color::from_rgb(0.153, 0.157, 0.133),
bg_hover: Color::from_rgb(0.212, 0.220, 0.188),
bg_active: Color::from_rgb(0.180, 0.184, 0.160),
bg_app: Color::from_rgb(0.078, 0.074, 0.063),
bg_panel: Color::from_rgb(0.137, 0.141, 0.118),
bg_hover: Color::from_rgb(0.200, 0.208, 0.176),
bg_active: Color::from_rgb(0.164, 0.168, 0.145),
accent: Color::from_rgb(0.902, 0.588, 0.196),
accent_hover: Color::from_rgb(0.940, 0.640, 0.260),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
@@ -145,10 +145,10 @@ impl ThemeColors {
},
// ── Dracula ────────────────────────────────────────────────
ThemePreset::Amoled => Self {
bg_app: Color::from_rgb(0.094, 0.086, 0.133),
bg_panel: Color::from_rgb(0.157, 0.165, 0.212),
bg_hover: Color::from_rgb(0.220, 0.227, 0.290),
bg_active: Color::from_rgb(0.184, 0.192, 0.243),
bg_app: Color::from_rgb(0.084, 0.076, 0.118),
bg_panel: Color::from_rgb(0.141, 0.149, 0.194),
bg_hover: Color::from_rgb(0.208, 0.215, 0.276),
bg_active: Color::from_rgb(0.170, 0.178, 0.226),
accent: Color::from_rgb(1.000, 0.475, 0.776),
accent_hover: Color::from_rgb(1.000, 0.530, 0.820),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),