Files
hcie-rust-v3.05/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs
T
phantom b49106dc1d BIG REFACTOR GPT SOL
feat: Refactor Iced panel adapter and introduce build metadata management

- Updated Cargo.toml files across multiple crates to use workspace versioning.
- Enhanced the `iced-panel-adapter` to include a new `plain_slider` module and updated widget rendering to support theme colors.
- Added new theme color utilities for recessed and elevated surfaces in `iced-panel-adapter`.
- Introduced a new `hcie-build-info` crate to manage build metadata, including a build ID system.
- Created a build script to synchronize build IDs across the workspace.
- Added a Makefile for simplified build commands for the Iced application.
- Implemented regression tests for vector shape creation history in the engine API.
- Added a new script for managing Cargo commands with synchronized build ID increments.
- Updated line count report to reflect recent changes in codebase.
- Created a visual plan document for future improvements in the Iced history and panel systems.
2026-07-21 04:25:23 +03:00

2161 lines
96 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.
#![allow(deprecated)]
#![allow(dead_code)]
// V3 Panels — ported from V2 hcie-app/src/app/panels.rs
// Menubar, unified toolbar, status bar, filters, brushes, layers, history, properties, AI stubs.
use crate::app::{
tools::pressure_indicator::PressureIndicator, AppDocument, HcieApp, ThemeColors, ToolState,
};
use crate::event_bus::AppEvent;
use eframe::egui;
use egui::{Color32, RichText};
use hcie_engine_api::Tool;
/// Ensure the cached app icon is decoded once and stored in `app.app_icon`.
///
/// **Logic & Workflow:** Uses `image::load_from_memory` to decode the bundled
/// `hcie-icon.png` asset into an `egui::ColorImage`. On success, stores an
/// owned `ImageSource` inside `HcieApp` so subsequent frames can reuse it.
///
/// **Arguments:**
/// * `app`: Mutable application state. The decoded icon is cached here.
/// * `ctx`: The active egui context used to load the icon into a texture.
fn ensure_icon_cached(app: &mut HcieApp, ctx: &egui::Context) {
if app.app_icon.is_some() {
return;
}
let icon_bytes = include_bytes!("../../../../../assets/icons/hcie-icon.png");
match image::load_from_memory(icon_bytes) {
Ok(img) => {
let rgba = img.into_rgba8();
let (w, h) = rgba.dimensions();
let color_image =
egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba.as_ref());
let texture_handle = ctx.load_texture("hcie-app-icon", color_image, Default::default());
app.app_icon = Some(texture_handle);
}
Err(e) => {
log::warn!("Failed to cache title-bar icon: {}", e);
}
}
}
/// Draw a chain/link toggle button for shared tool properties.
fn draw_shared_toggle(
ui: &mut egui::Ui,
tool: Tool,
flag_set: &mut std::collections::HashSet<Tool>,
colors: ThemeColors,
) -> bool {
let is_shared = flag_set.contains(&tool);
let icon = if is_shared { "\u{2630}" } else { "\u{2261}" }; // ☰ linked, ≡ unlinked
let tooltip = if is_shared {
"Linked to global setting"
} else {
"Independent setting"
};
let btn = egui::Button::new(RichText::new(icon).size(10.0))
.fill(if is_shared {
colors.accent.gamma_multiply(0.2)
} else {
Color32::TRANSPARENT
})
.stroke(if is_shared {
egui::Stroke::new(1.0, colors.accent)
} else {
egui::Stroke::NONE
});
let response = ui.add(btn).on_hover_text(tooltip);
if response.clicked() {
if is_shared {
flag_set.remove(&tool);
} else {
flag_set.insert(tool);
}
return true;
}
false
}
// ── Icon tint helper ─────────────────────────────────────────────────────────
fn get_icon_tint(ctx: &egui::Context) -> Color32 {
if ctx.global_style().visuals.dark_mode {
Color32::from_gray(224)
} else {
Color32::from_gray(80)
}
}
// ── Menu Bar ─────────────────────────────────────────────────────────────────
/// Build the centered title string: `HCIE v{full_version} — <active document name>`.
///
/// **Logic & Workflow:** Reads the active document name from `app.active_doc_ref()`
/// and falls back to `Untitled`. The full version is taken from
/// the shared `hcie-build-info` crate, which includes the synchronized build ID.
///
/// **Arguments:**
/// * `app`: Reference to application state for the active document name.
///
/// **Returns:** The formatted title string, ready to be centered and truncated.
fn build_title(app: &HcieApp) -> String {
let doc = app
.active_doc_ref()
.map(|d| d.name.clone())
.unwrap_or_else(|| "Untitled".to_string());
let version = hcie_build_info::VERSION;
format!("HCIE v{version}{doc}")
}
pub fn show_menu_bar(app: &mut HcieApp, ctx: &egui::Context) {
let colors = ThemeColors::get(app.state.settings.theme);
let bar_h = 28.0;
let frame = egui::Frame::NONE
.fill(colors.title_bar_bg)
.inner_margin(egui::Margin::symmetric(0, 0))
.stroke(egui::Stroke::new(1.0, colors.border_low));
let frame =
crate::app::shell::gui_layout::apply_frame("panel.menubar", frame, colors.title_bar_bg);
egui::Panel::top("menu_bar")
.frame(frame)
.show_separator_line(false)
.show(ctx, |ui| {
ui.set_height(bar_h);
let bar_rect = ui.available_rect_before_wrap();
let drag_id = ui.id().with("title_bar_drag");
// Reserve widths for the left and right clusters first.
let icon_w = 16.0 + 8.0;
let right_cluster_w = if app.state.settings.show_native_decorations {
32.0_f32 // panel-toggle only
} else {
32.0 + (3.0 * 32.0) + 4.0 // panel-toggle + 3 window controls + spacing
};
let center_margin = icon_w + 8.0 + right_cluster_w + 8.0;
// ── CENTERED TITLE ──
let title = build_title(app);
let title_galley = ui.painter().layout(
title,
egui::FontId::proportional(11.0),
colors.text_primary,
(bar_rect.width() - center_margin).max(60.0),
);
let title_size = title_galley.size();
let title_rect = egui::Rect::from_min_size(
egui::pos2(
(bar_rect.center().x - title_size.x / 2.0).clamp(
bar_rect.min.x + icon_w + 8.0,
bar_rect.max.x - right_cluster_w - 8.0 - title_size.x,
),
bar_rect.center().y - title_size.y / 2.0,
),
title_size,
);
ui.painter()
.galley(title_rect.min, title_galley, colors.text_primary);
// ── LEFT: app icon ──
let mut left_cursor = bar_rect.min.x + 8.0;
ensure_icon_cached(app, ui.ctx());
if let Some(icon) = app.app_icon.as_ref() {
let icon_size = egui::vec2(16.0, 16.0);
let icon_rect = egui::Rect::from_min_size(
egui::pos2(left_cursor, bar_rect.center().y - icon_size.y / 2.0),
icon_size,
);
let sized = egui::load::SizedTexture::from_handle(icon);
ui.put(
icon_rect,
egui::Image::new(egui::ImageSource::Texture(sized))
.fit_to_exact_size(icon_size)
.tint(colors.text_primary),
);
left_cursor += icon_size.x + 8.0;
} else {
// Fallback if icon is missing: just advance cursor to prevent overlap
left_cursor += 16.0 + 8.0;
}
// ── LEFT: menus ──
let menu_rect = egui::Rect::from_min_size(
egui::pos2(left_cursor, bar_rect.min.y),
egui::vec2((title_rect.min.x - left_cursor - 8.0).max(60.0), bar_h),
);
ui.allocate_new_ui(
egui::UiBuilder::new()
.max_rect(menu_rect)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
ui.set_height(bar_h);
crate::app::shell::menus::show(app, ui);
},
);
left_cursor = menu_rect.max.x + 8.0;
// ── LEFT: search box (remaining space before title) ──
let search_w = 140.0_f32;
let remaining_for_search = title_rect.min.x - left_cursor - 8.0;
if remaining_for_search >= 40.0 {
let search_rect = egui::Rect::from_min_size(
egui::pos2(left_cursor, bar_rect.min.y + 2.0),
egui::vec2(remaining_for_search.min(search_w), bar_h - 4.0),
);
ui.allocate_new_ui(
egui::UiBuilder::new()
.max_rect(search_rect)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
ui.add(
egui::TextEdit::singleline(&mut app.title_search)
.hint_text(
egui::RichText::new("Search…")
.size(11.0)
.color(colors.text_secondary.gamma_multiply(0.6)),
)
.desired_width(remaining_for_search.min(search_w))
.margin(egui::Margin {
left: 4,
right: 4,
top: 1,
bottom: 1,
}),
);
},
);
}
// ── RIGHT: window controls ──
let mut right_cursor = bar_rect.max.x;
if !app.state.settings.show_native_decorations {
let controls_w = 3.0 * 32.0;
let controls_rect = egui::Rect::from_min_size(
egui::pos2(bar_rect.max.x - controls_w, bar_rect.min.y),
egui::vec2(controls_w, bar_h),
);
ui.allocate_new_ui(
egui::UiBuilder::new()
.max_rect(controls_rect)
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|ui| {
ui.set_height(bar_h);
draw_window_controls(ui, ctx, colors, app, bar_h);
},
);
right_cursor = controls_rect.min.x;
}
// ── RIGHT: panel-toggle button ──
let panel_icon = if app.show_panel_list {
"\u{25A3}"
} else {
"\u{2261}"
}; // ▣ panels, ≡ menu
let panel_btn_rect = egui::Rect::from_min_size(
egui::pos2(right_cursor - 32.0, bar_rect.min.y),
egui::vec2(32.0, bar_h),
);
let panel_resp = ui.put(
panel_btn_rect,
egui::Button::new(
egui::RichText::new(panel_icon)
.size(12.0)
.color(colors.text_primary),
)
.min_size(egui::vec2(32.0, bar_h)),
);
if panel_resp.on_hover_text("Toggle panel list").clicked() {
app.show_panel_list = !app.show_panel_list;
}
// ── DRAG ZONE: only the empty title-bar gaps, not the whole bar ──
// Build a drag rect that covers the title plus any horizontal gaps
// between clusters, but excludes the left icon/menus/search and the
// right panel-toggle/window-controls.
let drag_left = title_rect.min.x - 4.0;
let drag_right = (panel_btn_rect.min.x - 4.0).max(drag_left);
let drag_rect = egui::Rect::from_min_max(
egui::pos2(drag_left.max(bar_rect.min.x), bar_rect.min.y),
egui::pos2(drag_right.min(bar_rect.max.x), bar_rect.max.y),
);
if drag_rect.width() > 4.0 {
let drag_resp = ui.interact(drag_rect, drag_id, egui::Sense::click_and_drag());
if drag_resp.drag_started_by(egui::PointerButton::Primary) {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag_resp.double_clicked() {
let is_max = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_max));
}
}
});
}
fn draw_window_controls(
ui: &mut egui::Ui,
ctx: &egui::Context,
colors: ThemeColors,
app: &mut HcieApp,
bar_h: f32,
) {
let btn_w = 32.0_f32;
let btn_h = bar_h;
let dark = ctx.global_style().visuals.dark_mode;
let icon_active = if dark {
Color32::WHITE
} else {
Color32::from_gray(20)
};
let icon_idle = colors.text_secondary;
ui.add_space(4.0);
ui.spacing_mut().item_spacing.x = 0.0;
let draw_btn = |ui: &mut egui::Ui,
size: egui::Vec2,
hover_bg: Color32,
press_bg: Color32,
draw_icon: &dyn Fn(&egui::Painter, egui::Pos2, Color32)|
-> egui::Response {
let (r, resp) = ui.allocate_exact_size(size, egui::Sense::click());
let p = ui.painter();
let bg = if resp.is_pointer_button_down_on() {
press_bg
} else if resp.hovered() {
hover_bg
} else {
Color32::TRANSPARENT
};
p.rect_filled(r, egui::CornerRadius::same(0), bg);
let tint = if resp.hovered() || resp.is_pointer_button_down_on() {
icon_active
} else {
icon_idle
};
draw_icon(p, r.center(), tint);
resp
};
let close_red = Color32::from_rgb(232, 17, 35);
let close_press = Color32::from_rgb(180, 10, 25);
let resp = draw_btn(
ui,
egui::vec2(btn_w, btn_h),
close_red,
close_press,
&|p, c, col| {
let sz = 4.5;
let stroke = egui::Stroke::new(1.6, col);
p.line_segment([c + egui::vec2(-sz, -sz), c + egui::vec2(sz, sz)], stroke);
p.line_segment([c + egui::vec2(-sz, sz), c + egui::vec2(sz, -sz)], stroke);
},
);
if resp.clicked() {
let has_dirty = app.documents.iter().any(|d| d.engine.is_modified());
if has_dirty {
app.show_exit_confirm = true;
} else {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
let hover_bg = Color32::from_rgba_unmultiplied(
colors.accent.r(),
colors.accent.g(),
colors.accent.b(),
if dark { 30 } else { 20 },
);
let press_bg = Color32::from_rgba_unmultiplied(
colors.accent.r(),
colors.accent.g(),
colors.accent.b(),
if dark { 70 } else { 50 },
);
let resp = draw_btn(
ui,
egui::vec2(btn_w, btn_h),
hover_bg,
press_bg,
&|p, c, col| {
let sz = 4.5;
let stroke = egui::Stroke::new(1.3, col);
if maximized {
let off = 1.5;
let r1 = egui::Rect::from_center_size(
c + egui::vec2(-off, -off),
egui::vec2(sz * 2.0, sz * 2.0),
);
let r2 = egui::Rect::from_center_size(
c + egui::vec2(off, off),
egui::vec2(sz * 2.0, sz * 2.0),
);
p.rect_stroke(r1, 0.0, stroke, egui::StrokeKind::Outside);
p.rect_stroke(r2, 0.0, stroke, egui::StrokeKind::Outside);
} else {
let r = egui::Rect::from_center_size(c, egui::vec2(sz * 2.0, sz * 2.0));
p.rect_stroke(r, 0.0, stroke, egui::StrokeKind::Outside);
}
},
);
if resp.clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
}
let resp = draw_btn(
ui,
egui::vec2(btn_w, btn_h),
hover_bg,
press_bg,
&|p, c, col| {
let stroke = egui::Stroke::new(1.3, col);
p.line_segment(
[c + egui::vec2(-5.0, 0.0), c + egui::vec2(5.0, 0.0)],
stroke,
);
},
);
if resp.clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
}
// ── Unified Toolbar ──────────────────────────────────────────────────────────
/// **Purpose:** Draws a sleek, highly-polished modern toolbar button.
///
/// **Logic & Workflow:** Allocates an exact size centered on the text label.
/// Colors are resolved dynamically from active theme states (idle, hover, click).
/// Paints custom rounded backgrounds, outline borders, and typography.
///
/// **Arguments:**
/// * `ui`: Egui UI builder reference.
/// * `label`: The text or emoji label.
/// * `tooltip`: Hover description tooltips.
/// * `theme`: The active ThemePreset.
fn draw_modern_toolbar_button(
ui: &mut egui::Ui,
label: &str,
tooltip: &str,
theme: crate::app::ThemePreset,
) -> egui::Response {
let layout_id = format!("panel.toolbar.btn.{}", label.trim());
let layout = crate::app::shell::gui_layout::get(&layout_id);
let colors = ThemeColors::get(theme);
let padding = layout.padding.unwrap_or(egui::vec2(9.0, 5.0));
let rounding = layout.rounding(4.0);
let font_id = if let Some(f) = &layout.font {
f.to_egui()
} else {
egui::FontId::proportional(11.5)
};
let text_color_default = colors.text_primary;
let text_size = ui
.painter()
.layout_no_wrap(label.to_string(), font_id.clone(), colors.text_primary)
.size();
let base_size = text_size + padding * 2.0;
let desired_size = layout.size.unwrap_or(base_size);
let (rect, response) = ui.allocate_at_least(desired_size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let (mut bg, mut stroke, mut text_color) = if response.clicked() || response.dragged() {
(
colors.accent,
egui::Stroke::new(1.0, colors.accent),
egui::Color32::WHITE,
)
} else if response.hovered() {
(
colors.bg_hover,
egui::Stroke::new(1.0, colors.accent.gamma_multiply(0.4)),
colors.text_primary,
)
} else {
(
colors.bg_dark_depth,
egui::Stroke::new(1.0, colors.border_low.gamma_multiply(0.3)),
colors.text_primary,
)
};
bg = layout.fill_color(bg);
text_color = layout.tint_color(text_color);
if let Some(sc) = layout.stroke {
stroke = egui::Stroke::new(
layout.stroke_width(stroke.width),
crate::app::shell::gui_layout::color_from_array(sc),
);
}
let _ = text_color_default;
ui.painter().rect_filled(rect, rounding, bg);
if stroke.width > 0.0 {
ui.painter()
.rect_stroke(rect, rounding, stroke, egui::StrokeKind::Outside);
}
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
font_id,
text_color,
);
}
let response = crate::app::shell::gui_layout::track(&layout_id, ui, &response);
if !tooltip.is_empty() {
response.on_hover_text(tooltip)
} else {
response
}
}
/// Icon-only toolbar button with SVG icon and hover tooltip.
/// Renders a 24×24 rounded button with tinted SVG icon.
fn draw_toolbar_icon_button(
ui: &mut egui::Ui,
icon: egui::ImageSource<'static>,
tooltip: &str,
theme: crate::app::ThemePreset,
) -> egui::Response {
let colors = ThemeColors::get(theme);
let size = egui::vec2(24.0, 24.0);
let (rect, response) = ui.allocate_at_least(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let (bg, tint) = if response.clicked() || response.dragged() {
(colors.accent, egui::Color32::WHITE)
} else if response.hovered() {
(colors.bg_hover, colors.text_primary)
} else {
(egui::Color32::TRANSPARENT, colors.text_primary)
};
if bg != egui::Color32::TRANSPARENT {
ui.painter()
.rect_filled(rect, egui::CornerRadius::same(4), bg);
}
let icon_size = 20.0;
let img_rect =
egui::Rect::from_center_size(rect.center(), egui::vec2(icon_size, icon_size));
ui.put(
img_rect,
egui::Image::new(icon)
.tint(tint)
.fit_to_exact_size(egui::vec2(icon_size, icon_size)),
);
}
if !tooltip.is_empty() {
response.on_hover_text(tooltip)
} else {
response
}
}
pub fn show_unified_toolbar(app: &mut HcieApp, ctx: &egui::Context) {
let colors = ThemeColors::get(app.state.settings.theme);
// Fixed toolbar height so the top strip doesn't collapse when the window
// is narrow or when plain sliders are added.
let toolbar_height = 36.0;
let frame = egui::Frame::NONE
.fill(colors.bg_panel)
.inner_margin(egui::Margin::symmetric(4, 4))
.stroke(egui::Stroke::new(1.0, colors.border_low));
let frame = crate::app::shell::gui_layout::apply_frame("panel.toolbar", frame, colors.bg_panel);
egui::Panel::top("unified_toolbar")
.frame(frame)
.min_size(toolbar_height)
.max_height(toolbar_height)
.resizable(false)
.show_separator_line(false)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
// New / Open / Save
macro_rules! icon {
($file:literal) => {
egui::include_image!(concat!("../../../../../assets/icons/", $file))
};
}
if draw_toolbar_icon_button(
ui,
icon!("new_file.svg"),
"New Image (Ctrl+N)",
app.state.settings.theme,
)
.clicked()
{
app.show_new_image = true;
}
if draw_toolbar_icon_button(
ui,
icon!("open_file.svg"),
"Open Image (Ctrl+O)",
app.state.settings.theme,
)
.clicked()
{
app.event_bus.push(AppEvent::FileOpenRequested);
}
if draw_toolbar_icon_button(
ui,
icon!("save_file.svg"),
"Save As (Ctrl+Shift+S)",
app.state.settings.theme,
)
.clicked()
{
app.event_bus.push(AppEvent::FileSaveAsRequested);
}
ui.add_space(4.0);
ui.separator();
ui.add_space(4.0);
// Undo / Redo
if draw_toolbar_icon_button(
ui,
icon!("undo.svg"),
"Undo (Ctrl+Z)",
app.state.settings.theme,
)
.clicked()
{
app.event_bus.push(AppEvent::Undo);
}
if draw_toolbar_icon_button(
ui,
icon!("redo.svg"),
"Redo (Ctrl+Shift+Z)",
app.state.settings.theme,
)
.clicked()
{
app.event_bus.push(AppEvent::Redo);
}
ui.add_space(4.0);
ui.separator();
ui.add_space(4.0);
// Zoom
if draw_toolbar_icon_button(
ui,
icon!("zoomin.svg"),
"Zoom In",
app.state.settings.theme,
)
.clicked()
{
app.event_bus.push(AppEvent::ZoomIn);
}
if draw_toolbar_icon_button(
ui,
icon!("zoomout.svg"),
"Zoom Out",
app.state.settings.theme,
)
.clicked()
{
app.event_bus.push(AppEvent::ZoomOut);
}
let zoom_text = format!(
"{:.0}%",
app.documents
.get(app.active_doc)
.map(|d| d.zoom * 100.0)
.unwrap_or(100.0)
);
ui.label(
RichText::new(zoom_text)
.size(11.0)
.color(colors.text_secondary),
);
ui.add_space(4.0);
ui.separator();
ui.add_space(4.0);
// Tool options
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
egui::ComboBox::from_id_salt("tool_mode_select")
.selected_text(app.state.active_tool.label())
.width(80.0)
.show_ui(ui, |ui| {
let _ = ui.selectable_label(true, "Normal");
let _ = ui.selectable_label(false, "Multiply");
let _ = ui.selectable_label(false, "Screen");
});
if app.state.active_tool.has_size() {
ui.add_space(8.0);
let mut size = app.state.active_size();
if ui
.add(
crate::app::widgets::PlainSlider::new(
"Size",
&mut size,
1.0..=1000.0,
app.state.settings.theme,
)
.logarithmic(true),
)
.changed()
{
app.state.set_active_size(size);
}
}
if app.state.active_tool.has_opacity() {
ui.add_space(8.0);
let mut opacity = app.state.active_opacity();
if ui
.add(crate::app::widgets::PlainSlider::new(
"Opacity",
&mut opacity,
0.0..=1.0,
app.state.settings.theme,
))
.changed()
{
app.state.set_active_opacity(opacity);
}
}
});
// AI status
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if app.state.is_ai_processing {
ui.add(egui::Spinner::new().size(14.0));
ui.label(
RichText::new("AI Processing...")
.size(11.0)
.color(colors.accent),
);
}
});
// Theme toggle
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
use crate::app::ThemePreset;
let is_dark = ctx.global_style().visuals.dark_mode;
let theme_icon = if is_dark {
icon!("theme_light.svg")
} else {
icon!("theme_dark.svg")
};
let theme_tip = if is_dark {
"Switch to Light Theme"
} else {
"Switch to Dark Theme"
};
if draw_toolbar_icon_button(ui, theme_icon, theme_tip, app.state.settings.theme)
.clicked()
{
let new_theme = match app.state.settings.theme {
ThemePreset::Photoshop => ThemePreset::ProDark,
ThemePreset::ProDark => ThemePreset::Amoled,
ThemePreset::Amoled => ThemePreset::PhotoshopLight,
ThemePreset::PhotoshopLight => ThemePreset::ProLight,
ThemePreset::ProLight => ThemePreset::Photoshop,
};
app.event_bus.push(AppEvent::ThemeChanged(new_theme));
}
});
});
});
}
// ── Status Bar ───────────────────────────────────────────────────────────────
/// **Purpose:** Draws the status bar and, on the right side, renders icon buttons for all
/// collapsed (auto-hide) panels so they are always accessible without overlapping any dock content.
///
/// **Logic & Workflow:**
/// The status bar is a `BottomPanel`. Its right side shows:
/// 1. Collapsed LEFT-side panel icons (Tools, Brushes, Filters etc.) grouped together
/// 2. A small separator
/// 3. Collapsed RIGHT-side panel icons (Color, Layers, History etc.) grouped together
/// 4. The zoom slider / canvas info
/// Each icon button is 26×26 with a `5px` rounded soft border.
/// Active (auto-hide open) buttons get an accent border + fill; idle get a faint border.
///
/// **Arguments:**
/// * `app`: Mutable application state reference.
/// * `ctx`: The active egui context.
pub fn show_status_bar(app: &mut HcieApp, ctx: &egui::Context) {
let colors = ThemeColors::get(app.state.settings.theme);
// Expire stale status messages before rendering.
if let Some((_, expires)) = &app.status_message {
if std::time::Instant::now() >= *expires {
app.status_message = None;
}
}
let frame = egui::Frame::NONE
.fill(colors.bg_panel)
.inner_margin(egui::Margin {
left: 10,
right: 10,
top: 2,
bottom: 2,
})
.stroke(egui::Stroke::new(1.0, colors.border_low));
let frame =
crate::app::shell::gui_layout::apply_frame("panel.statusbar", frame, colors.bg_panel);
egui::Panel::bottom("status_bar")
.frame(frame)
.show_separator_line(false)
.show(ctx, |ui| {
ui.horizontal(|ui| {
// ── Left status labels ──────────────────────────────────────────
crate::app::shell::gui_layout::label(
"panel.statusbar.active_tool",
ui,
app.state.active_tool.label(),
colors.text_secondary,
);
if let Some((x, y)) = app.state.cursor_canvas_pos {
crate::app::shell::gui_layout::separator("panel.statusbar.sep_mouse", ui);
crate::app::shell::gui_layout::label(
"panel.statusbar.mouse",
ui,
format!("Mouse: {} x {}", x, y),
colors.text_secondary,
);
}
// ── Center: temporary status message ───────────────────────────
if let Some((text, _)) = &app.status_message {
crate::app::shell::gui_layout::separator("panel.statusbar.sep_msg", ui);
crate::app::shell::gui_layout::label(
"panel.statusbar.message",
ui,
text,
colors.accent,
);
}
// ── Right side layout (right_to_left fills from right) ──────────
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// ── Zoom slider / canvas info (rightmost) ───────────────────
if app.documents.len() > app.active_doc {
let steps = [
0.125_f32, 0.25, 0.33, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0,
];
let mut zoom = app
.documents
.get(app.active_doc)
.map(|d| d.zoom)
.unwrap_or(1.0);
let z_resp = ui.add(
egui::Slider::new(&mut zoom, 0.125..=16.0)
.logarithmic(true)
.show_value(false),
);
let z_resp = crate::app::shell::gui_layout::track(
"panel.statusbar.zoom_slider",
ui,
&z_resp,
);
if z_resp.changed() {
let mut best_step = steps[0];
let mut min_dist = (zoom - best_step).abs();
for &s in &steps[1..] {
let dist = (zoom - s).abs();
if dist < min_dist {
min_dist = dist;
best_step = s;
}
}
app.documents
.get_mut(app.active_doc)
.map(|d| d.zoom = best_step);
}
let current_zoom = app
.documents
.get(app.active_doc)
.map(|d| d.zoom)
.unwrap_or(1.0);
crate::app::shell::gui_layout::label(
"panel.statusbar.zoom_pct",
ui,
format!("{:.1}%", current_zoom * 100.0),
colors.text_secondary,
);
ui.add_space(20.0);
if crate::app::shell::gui_layout::button(
"panel.statusbar.btn_100",
ui,
"100%",
)
.clicked()
{
app.documents.get_mut(app.active_doc).map(|d| d.zoom = 1.0);
}
ui.add_space(8.0);
let doc = &app.documents[app.active_doc];
crate::app::shell::gui_layout::label(
"panel.statusbar.canvas_size",
ui,
format!(
"{} x {} px",
doc.engine.canvas_width(),
doc.engine.canvas_height()
),
colors.text_secondary,
);
}
// ── Separator before collapsed panel icons ──────────────────
ui.separator();
ui.add_space(4.0);
// ── Minimized-panel icon buttons ───────────────────────────
// Collect minimized panels per side (right first since right-to-left).
// Panels not in pinned_panels are considered "minimized": removed from
// dock and represented by a vivid icon in the status bar.
let right_minimized: Vec<&'static str> = crate::app::PANEL_SIDES
.iter()
.filter(|(n, left)| !*left && !app.state.pinned_panels.contains(*n))
.map(|(n, _)| *n)
.collect();
let left_minimized: Vec<&'static str> = crate::app::PANEL_SIDES
.iter()
.filter(|(n, left)| *left && !app.state.pinned_panels.contains(*n))
.map(|(n, _)| *n)
.collect();
// Shared rendering parameters for all icon buttons.
let btn_size = egui::vec2(24.0, 24.0);
let icon_size = egui::vec2(12.0, 12.0);
let rounding = egui::CornerRadius::same(5);
ui.spacing_mut().item_spacing.x = 12.0;
// Draw one minimized panel icon button.
let mut draw_minimized_icon = |ui: &mut egui::Ui, panel_name: &str| {
let is_open = app.auto_hide_active.as_deref() == Some(panel_name);
let (rect, resp) = ui.allocate_exact_size(btn_size, egui::Sense::click());
let hovered = resp.hovered();
// Vivid styling: minimized panels always show accent colour so they
// stay visible and attention-grabbing in the status bar.
// When the overlay is open the button calms down.
let fill = if is_open {
colors.accent.gamma_multiply(0.12)
} else if hovered {
colors.accent.gamma_multiply(0.35)
} else {
colors.accent.gamma_multiply(0.20)
};
let border_col = if is_open {
colors.accent.gamma_multiply(0.6)
} else if hovered {
colors.accent
} else {
colors.accent.gamma_multiply(0.7)
};
let border_w = if hovered { 1.5 } else { 1.0 };
let icon_tint = if is_open {
colors.accent.gamma_multiply(0.8)
} else if hovered {
colors.text_primary
} else {
colors.accent
};
ui.painter().rect_filled(rect, rounding, fill);
ui.painter().rect_stroke(
rect,
rounding,
egui::Stroke::new(border_w, border_col),
egui::StrokeKind::Outside,
);
if let Some(pane) = crate::app::pane_from_name(panel_name) {
if let Some(icon_src) = pane.icon() {
let img_rect =
egui::Rect::from_center_size(rect.center(), icon_size);
ui.put(
img_rect,
egui::Image::new(icon_src)
.tint(icon_tint)
.fit_to_exact_size(icon_size),
);
}
}
let clicked = resp.clicked();
resp.on_hover_text(panel_name);
if clicked {
app.auto_hide_toggle = Some(panel_name.to_string());
}
};
// RIGHT-side minimized panels (rendered first in right-to-left)
for panel_name in &right_minimized {
draw_minimized_icon(ui, panel_name);
}
// Separator between left and right panel groups (only if both non-empty)
if !right_minimized.is_empty() && !left_minimized.is_empty() {
ui.add_space(4.0);
ui.separator();
ui.add_space(4.0);
}
// LEFT-side minimized panels
for panel_name in &left_minimized {
draw_minimized_icon(ui, panel_name);
}
});
});
});
}
// ── Filters UI ───────────────────────────────────────────────────────────────
pub fn show_filters_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
if let Some(doc) = app.active_doc_mut() {
egui_panel_filters::show_filters_ui(ui, &mut doc.engine);
} else {
ui.label("No active document.");
}
}
// ── Brushes UI ──────────────────────────────────────────────────────────────
pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
crate::app::tools::brushes_panel::show_brushes_ui(app, ui);
}
// ── Color Palette ────────────────────────────────────────────────────────────
pub fn show_color_palette(doc: &mut AppDocument, state: &mut ToolState, ui: &mut egui::Ui) {
crate::app::panels::color::show_color_palette(doc, state, ui);
}
// ── Layers Panel (V2 port) ───────────────────────────────────────────────────
struct LayerTreeEntry {
layer_idx: usize,
depth: u32,
is_group: bool,
}
fn flatten_tree(infos: &[hcie_engine_api::LayerInfo]) -> Vec<LayerTreeEntry> {
let mut result = Vec::new();
fn build(
infos: &[hcie_engine_api::LayerInfo],
parent_id: Option<u64>,
depth: u32,
out: &mut Vec<LayerTreeEntry>,
) {
for i in (0..infos.len()).rev() {
if infos[i].parent_id != parent_id {
continue;
}
let is_group = infos[i].layer_type == hcie_engine_api::LayerType::Group;
let collapsed = is_group && infos[i].collapsed;
out.push(LayerTreeEntry {
layer_idx: i,
depth,
is_group,
});
if is_group && !collapsed {
build(infos, Some(infos[i].id), depth + 1, out);
}
}
}
build(infos, None, 0, &mut result);
result
}
/// **Purpose:** Renders the layers list panel including controls for layer visibility,
/// group folding, renaming, ordering, blend mode selection, and opacity adjustment.
///
/// **Logic & Workflow:**
/// 1. Retrieve the total number of layers and active layer ID from the engine.
/// 2. Flatten the nested layer structure into a depth-annotated `LayerTreeEntry` vector.
/// 3. Loop through entries and draw a custom row styling for each layer (highlighting the active layer).
/// 4. Disable horizontal auto-shrinking (`.auto_shrink([false, true])`) on the layers list `ScrollArea`
/// to force the list to take the full width of the parent docking container.
/// 5. For each layer row, force the container frame and inner `ui.horizontal` layout to occupy
/// the full available width (`ui.set_width(ui.available_width())`).
/// 6. Determine remaining horizontal gap space by subtracting the end coordinate of the thumbnail (`label_start_x`)
/// and spacing from the current right-to-left layout cursor's maximum x-coordinate.
/// 7. Add a clickable label for the layer name inside the gap. Since the row expands to full available width,
/// the name is correctly rendered and receives click events to set the active layer.
/// 8. Below the scroll area, draw sliders and dropdowns for opacity and blend mode of the active layer.
///
/// **Arguments:**
/// * `doc` - Mutable reference to the current active document.
/// * `state` - Mutable reference to the UI tools settings.
/// * `event_bus` - Dispatcher for application-level actions.
/// * `ui` - The target egui UI context object.
///
/// **Side Effects / Dependencies:**
/// Modifies active layer state, layer visibility, order, opacity, and blend modes in the core document engine.
pub fn show_layers(
doc: &mut AppDocument,
state: &mut ToolState,
event_bus: &mut crate::event_bus::EventBus,
ui: &mut egui::Ui,
) {
use hcie_engine_api::BlendMode;
let colors = ThemeColors::get(state.settings.theme);
let layer_count = doc.engine.get_layer_count();
if layer_count == 0 {
return;
}
let infos = doc.engine.layer_infos();
let active_id = doc.engine.active_layer_id();
let _panel_width = ui.available_width();
// While a destructive preview dialog (Adjustments, etc.) is open, the active
// layer is locked. Refuse layer selection clicks and show a notice so the
// user understands why switching is blocked. Visibility/blend toggles remain
// available because they don't change which layer the filter targets.
let layer_switch_locked = state.locked_active_layer.is_some();
ui.spacing_mut().item_spacing.y = 4.0;
if layer_switch_locked {
ui.add_space(2.0);
ui.label(
egui::RichText::new(
"⛔ Layer locked — close the open Adjustments dialog to switch layers",
)
.color(colors.text_secondary)
.size(10.0),
);
ui.add_space(2.0);
}
// Blend mode + opacity, Locks + Fill — active layer controls at the very top
if let Some(info) = doc.engine.get_layer_info(active_id) {
let mut current_blend = info.blend_mode;
// Row 1: Blend Mode Combo & Opacity DragValue (kept narrow to fit 200px)
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
let blend_resp = egui::ComboBox::from_id_salt("layer_blend")
.selected_text(current_blend.label())
.width(70.0)
.show_ui(ui, |ui| {
for &mode in BlendMode::ALL {
if ui
.selectable_value(&mut current_blend, mode, mode.label())
.changed()
{
doc.engine.set_layer_blend_mode(info.id, mode);
}
}
});
crate::app::shell::gui_layout::track(
"panel.layers.blend_combo",
ui,
&blend_resp.response,
);
ui.add_space(1.0);
ui.label(
egui::RichText::new("Op:")
.size(10.0)
.color(colors.text_secondary),
);
let mut opacity_percent = (info.opacity * 100.0).round() as i32;
let op_resp = ui.add(
egui::DragValue::new(&mut opacity_percent)
.range(0..=100)
.suffix("%")
.speed(1.0)
.update_while_editing(false),
);
let op_resp =
crate::app::shell::gui_layout::track("panel.layers.opacity_slider", ui, &op_resp);
if op_resp.changed() {
doc.engine
.set_layer_opacity(info.id, opacity_percent as f32 / 100.0);
}
});
ui.add_space(2.0);
// Row 2: Lock States & Fill DragValue (tight spacing for 200px panel)
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
ui.label(
egui::RichText::new("Lock:")
.size(10.0)
.color(colors.text_secondary),
);
// Engine layer lock
let is_locked = info.locked;
let lock_icon = if is_locked { "\u{25CF}" } else { "\u{25CB}" }; // ● locked, ○ unlocked
let lock_resp = ui.selectable_label(is_locked, lock_icon);
if lock_resp.clicked() {
doc.engine.set_layer_locked(info.id, !is_locked);
event_bus.push(AppEvent::RenderRequested);
}
let _ =
crate::app::shell::gui_layout::track("panel.layers.lock_toggle", ui, &lock_resp);
ui.add_space(3.0);
ui.label(
egui::RichText::new("Fill:")
.size(10.0)
.color(colors.text_secondary),
);
let mut fill_percent = 100;
ui.add(
egui::DragValue::new(&mut fill_percent)
.range(0..=100)
.suffix("%")
.speed(1.0)
.update_while_editing(false),
);
});
ui.add_space(2.0);
ui.separator();
ui.add_space(2.0);
}
// Top controls (blend/opacity/lock/fill) already drawn above this block.
// The outer track_panel/scrollable_panel wrapper handles scrolling, so the
// layer list itself flows naturally and shares the available space with the
// bottom toolbar.
let panel_width = ui.available_width();
// flatten_tree iterates engine layers in reverse order, so the
// resulting vector already has the highest engine-index layer first.
// In a standard top-down layer panel the topmost layer must be
// drawn first and Background (engine index 0) must end up at the
// bottom, so we keep the flatten_tree order as-is.
let entries = flatten_tree(&infos);
for entry in &entries {
let i = entry.layer_idx;
let info = &infos[i];
let is_active = info.id == active_id;
let indent = entry.depth as f32 * 12.0;
let bg = if is_active {
colors.accent.gamma_multiply(0.3)
} else {
egui::Color32::TRANSPARENT
};
let has_border = is_active;
let border_color = if is_active {
colors.accent.gamma_multiply(0.5)
} else {
Color32::TRANSPARENT
};
let frame = egui::Frame::NONE
.fill(bg)
.stroke(egui::Stroke::new(
if has_border { 1.0 } else { 0.0 },
border_color,
))
.corner_radius(6.0)
.inner_margin(egui::Margin::symmetric(4, 4));
// Retrieve style badges before showing the frame
let style_badges = doc.engine.get_layer_style_badges(info.id);
frame.show(ui, |ui| {
ui.set_width(panel_width);
ui.horizontal(|ui| {
ui.set_width(panel_width);
ui.spacing_mut().item_spacing.x = 2.0;
ui.add_space(indent);
// Group collapse
if entry.is_group {
let arrow = if info.collapsed { "" } else { "" };
let gid = format!("panel.layers.row{}.collapse", i);
if crate::app::shell::gui_layout::selectable_label(&gid, ui, false, arrow)
.clicked()
{
doc.engine.toggle_group_collapsed(info.id);
}
} else {
ui.add_space(9.0);
}
// Visibility
let (vis_text, vis_color) = if info.visible {
("\u{25C9}", colors.text_primary) // ◉ filled circle = visible
} else {
("\u{25CB}", colors.text_secondary.gamma_multiply(0.4)) // ○ hollow circle = hidden
};
let vid = format!("panel.layers.row{}.visibility", i);
let vis_btn =
egui::Button::new(RichText::new(vis_text).color(vis_color).size(10.0))
.frame(false)
.min_size(egui::vec2(12.0, 14.0));
let vis_resp = ui.add(vis_btn);
let vis_resp = crate::app::shell::gui_layout::track(&vid, ui, &vis_resp);
if vis_resp.clicked() {
doc.engine.set_layer_visible(info.id, !info.visible);
}
// Thumbnail or folder icon for groups
let thumb_size = egui::Vec2::new(32.0, 24.0);
let (thumb_rect, thumb_resp) =
ui.allocate_exact_size(thumb_size, egui::Sense::click());
let tid = format!("panel.layers.row{}.thumb", i);
let thumb_resp = crate::app::shell::gui_layout::track(&tid, ui, &thumb_resp);
if thumb_resp.clicked() && !layer_switch_locked {
doc.engine.set_active_layer(info.id);
}
if ui.is_rect_visible(thumb_rect) {
crate::app::widgets::draw_checkerboard(
ui,
thumb_rect,
3.0,
if colors.is_light {
Color32::from_gray(245)
} else {
Color32::from_gray(35)
},
if colors.is_light {
Color32::from_gray(220)
} else {
Color32::from_gray(20)
},
);
let p = ui.painter();
if entry.is_group {
p.text(
thumb_rect.center(),
egui::Align2::CENTER_CENTER,
"\u{25A0}", // ■ group indicator
egui::FontId::proportional(12.0),
colors.accent,
);
} else if let Some(tex) = doc.layer_textures.get(&i) {
p.image(
tex.id(),
thumb_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
p.rect_stroke(
thumb_rect,
1.0,
egui::Stroke::new(1.0, colors.border_high.gamma_multiply(0.6)),
egui::StrokeKind::Outside,
);
}
// Store the left edge of the remaining space for the layer name
let label_start_x = ui.cursor().min.x;
// RTL layout: badges+buttons right, label left
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 3.0;
// Delete + move buttons (only active layer)
if is_active {
if ui
.small_button("\u{2715}")
.on_hover_text("Delete")
.clicked()
{
// ✕
event_bus.push(AppEvent::LayerDeleted(info.id));
}
if i < layer_count - 1 && ui.small_button("\u{25B4}").clicked() {
// ▴
doc.engine.move_layer(info.id, i + 1);
event_bus.push(AppEvent::RenderRequested);
}
if i > 0 && ui.small_button("\u{25BE}").clicked() {
// ▾
doc.engine.move_layer(info.id, i - 1);
event_bus.push(AppEvent::RenderRequested);
}
if i > 0
&& ui
.small_button("\u{2193}")
.on_hover_text("Merge Down")
.clicked()
{
// ↓
event_bus.push(AppEvent::MergeDown);
}
}
// FX button
let has_styles = !style_badges.is_empty();
let fx_label = if has_styles { "fx*" } else { "fx" };
let fx_color = if has_styles {
colors.accent
} else {
colors.text_secondary
};
let fx_btn = egui::Button::new(
RichText::new(fx_label).size(8.0).strong().color(fx_color),
)
.frame(true)
.corner_radius(egui::CornerRadius::same(2))
.fill(if has_styles {
colors.accent.gamma_multiply(0.15)
} else {
colors.bg_dark_depth
})
.stroke(egui::Stroke::new(
0.5,
if has_styles {
colors.accent
} else {
colors.border_low
},
));
if ui.add(fx_btn).clicked() {
event_bus.push(AppEvent::OpenLayerStyles(info.id));
}
// FX badges (hidden when space is very tight)
if label_start_x > 80.0 {
for (tag, enabled) in style_badges.iter().rev() {
if *enabled {
let bc = match *tag {
"DS" => Color32::from_rgb(220, 100, 100),
"IS" => Color32::from_rgb(180, 120, 120),
"OG" => Color32::from_rgb(220, 220, 100),
"IG" => Color32::from_rgb(180, 180, 100),
"BE" => Color32::from_rgb(100, 180, 220),
"St" => Color32::from_rgb(180, 140, 220),
_ => Color32::GRAY,
};
ui.add(egui::Label::new(
RichText::new(*tag).color(bc).size(7.5).strong(),
));
}
}
// Type badge
let badge = match info.layer_type {
hcie_engine_api::LayerType::Vector => {
Some(("V", Color32::from_rgb(100, 200, 100)))
}
hcie_engine_api::LayerType::Text => {
Some(("T", Color32::from_rgb(100, 160, 240)))
}
hcie_engine_api::LayerType::Group => {
Some(("G", Color32::from_rgb(220, 180, 100)))
}
hcie_engine_api::LayerType::Mask => {
Some(("M", Color32::from_rgb(200, 100, 200)))
}
_ => None,
};
if let Some((c, col)) = badge {
ui.add(egui::Label::new(
RichText::new(c).color(col).size(8.0).strong(),
));
}
}
// Layer name — added directly to RTL layout to occupy the remaining space
let label_width =
(ui.cursor().max.x - label_start_x - ui.spacing().item_spacing.x).max(0.0);
let name_lbl = egui::Label::new(if is_active {
RichText::new(&info.name)
.strong()
.size(10.5)
.color(colors.text_primary)
} else {
RichText::new(&info.name)
.size(10.5)
.color(colors.text_secondary)
})
.halign(egui::Align::Min)
.truncate();
let name_resp = ui.add_sized(
egui::vec2(label_width, ui.spacing().interact_size.y),
name_lbl.sense(egui::Sense::click()),
);
let nid = format!("panel.layers.row{}.name", i);
let name_resp = crate::app::shell::gui_layout::track(&nid, ui, &name_resp);
if name_resp.clicked() && !layer_switch_locked {
doc.engine.set_active_layer(info.id);
}
});
});
});
}
// Bottom toolbar is drawn last so it sits directly under the layer list
// and cannot overlap the list.
ui.add_space(2.0);
ui.separator();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
if crate::app::shell::gui_layout::button("panel.layers.btn.add", ui, "+Lay").clicked() {
doc.engine.add_layer("Layer");
event_bus.push(AppEvent::RenderRequested);
}
if crate::app::shell::gui_layout::button("panel.layers.btn.add_vector", ui, "+Vec")
.clicked()
{
let name = format!("Vector {}", doc.engine.get_layer_count());
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
let pixels = vec![0; (w * h * 4) as usize];
doc.engine
.create_vector_layer_from_pixels(&name, pixels, w, h);
event_bus.push(AppEvent::RenderRequested);
}
if crate::app::shell::gui_layout::button("panel.layers.btn.group", ui, "+Grp").clicked() {
doc.engine.add_group("Group");
event_bus.push(AppEvent::RenderRequested);
}
if crate::app::shell::gui_layout::button("panel.layers.btn.flatten", ui, "Flatten")
.clicked()
{
event_bus.push(AppEvent::FlattenImage);
}
let del_resp = crate::app::shell::gui_layout::button("panel.layers.btn.delete", ui, "🗑");
if del_resp.clicked() {
let id = doc.engine.active_layer_id();
if id != 0 {
event_bus.push(AppEvent::LayerDeleted(id));
}
}
});
ui.add_space(2.0);
}
// ── History Panel ────────────────────────────────────────────────────────────
pub fn show_history(doc: &mut AppDocument, ui: &mut egui::Ui) {
let current = doc.engine.history_current();
let len = doc.engine.history_len();
crate::app::shell::gui_layout::label(
"panel.history.summary",
ui,
format!("Steps: {} (current: {})", len, current),
ui.visuals().weak_text_color(),
);
crate::app::shell::gui_layout::separator("panel.history.sep", ui);
let is_orig = current == -1;
let orig_resp = ui.selectable_label(
is_orig,
format!("{} [Original Image]", if is_orig { "" } else { " " }),
);
let orig_resp =
crate::app::shell::gui_layout::track("panel.history.entry.original", ui, &orig_resp);
if orig_resp.clicked() && !is_orig {
doc.engine.jump_to_history(-1);
}
for i in 0..len {
let mut label = doc
.engine
.history_description(i)
.unwrap_or_else(|| format!("Step {}", i));
if label == "Brush Stroke" {
if let Some(refined) = doc.history_brush_names.get(&i) {
if !refined.is_empty() {
label = refined.clone();
}
}
}
let is_current = i as i32 == current;
let hid = format!("panel.history.entry.{}", i);
let resp = ui.selectable_label(
is_current,
format!("{} {}", if is_current { "" } else { " " }, label),
);
let resp = crate::app::shell::gui_layout::track(&hid, ui, &resp);
if resp.clicked() && !is_current {
doc.engine.jump_to_history(i as i32);
}
}
}
// ── Properties Panel ───────────────────────────────────────────────────────────
pub fn show_properties(
doc: &mut AppDocument,
state: &mut ToolState,
event_bus: &mut crate::event_bus::EventBus,
ui: &mut egui::Ui,
) {
egui::Frame::NONE
.inner_margin(egui::Margin { left: 6, ..Default::default() })
.show(ui, |ui| {
use hcie_engine_api::Tool;
use hcie_engine_api::BlendMode;
use crate::app::widgets::PlainSlider;
use crate::app::VisionTask;
use crate::app::VisionBackend;
let colors = ThemeColors::get(state.settings.theme);
if let Some(info) = doc.engine.get_layer_info(doc.engine.active_layer_id()) {
crate::app::shell::gui_layout::label("panel.properties.name", ui, &info.name, colors.text_primary);
ui.add_space(4.0);
let mut opacity = info.opacity;
let op_resp = ui.add(PlainSlider::new("Opacity", &mut opacity, 0.0..=1.0, state.settings.theme));
let op_resp = crate::app::shell::gui_layout::track("panel.properties.opacity_slider", ui, &op_resp);
if op_resp.changed() {
doc.engine.set_layer_opacity(info.id, opacity);
}
let mut visible = info.visible;
if crate::app::shell::gui_layout::checkbox("panel.properties.visible", ui, &mut visible, "Visible").changed() {
doc.engine.set_layer_visible(info.id, visible);
}
ui.add_space(4.0);
crate::app::shell::gui_layout::label("panel.properties.blend_label", ui, "Blend Mode", colors.text_secondary);
let blend_combo = egui::ComboBox::from_id_salt("prop_blend_mode")
.selected_text(info.blend_mode.label())
.width(140.0)
.show_ui(ui, |ui| {
for &mode in BlendMode::ALL {
if ui.selectable_label(info.blend_mode == mode, mode.label()).clicked() {
doc.engine.set_layer_blend_mode(info.id, mode);
}
}
});
crate::app::shell::gui_layout::track("panel.properties.blend_combo", ui, &blend_combo.response);
ui.add_space(4.0);
crate::app::shell::gui_layout::label("panel.properties.type", ui, format!("Type: {:?}", info.layer_type), colors.text_secondary);
crate::app::shell::gui_layout::label("panel.properties.size", ui, format!("{} x {} px", info.width, info.height), colors.text_secondary);
} else {
crate::app::shell::gui_layout::label("panel.properties.empty", ui, "No active layer", colors.text_secondary);
}
ui.add_space(8.0);
crate::app::shell::gui_layout::separator("panel.properties.sep1", ui);
crate::app::shell::gui_layout::section_label("panel.properties.tool_heading", ui, "Tool Settings", colors.accent);
ui.add_space(4.0);
// Wrap the whole tool-settings block so it is selectable/editable in
// design mode as a single element. Individual sliders can be broken out
// later if finer control is needed.
crate::app::shell::gui_layout::begin_block("panel.properties.tool_block", ui);
let mut changed = false;
match state.active_tool {
Tool::Pen => {
changed |= draw_shared_toggle(ui, Tool::Pen, &mut state.tool_configs.shared.use_shared_size, colors);
changed |= ui.add(PlainSlider::new("Size", state.tool_configs.active_size_mut(Tool::Pen), 1.0..=100.0, state.settings.theme).suffix("px")).changed();
changed |= draw_shared_toggle(ui, Tool::Pen, &mut state.tool_configs.shared.use_shared_opacity, colors);
changed |= ui.add(PlainSlider::new("Opacity", state.tool_configs.active_opacity_mut(Tool::Pen), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= draw_shared_toggle(ui, Tool::Pen, &mut state.tool_configs.shared.use_shared_hardness, colors);
changed |= ui.add(PlainSlider::new("Hardness", state.tool_configs.active_hardness_mut(Tool::Pen), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= ui.checkbox(&mut state.tool_configs.brush.cyclic_color, "Cyclic Color").changed();
PressureIndicator::show(ui, state.current_pressure, colors);
}
Tool::Brush => {
changed |= draw_shared_toggle(ui, Tool::Brush, &mut state.tool_configs.shared.use_shared_size, colors);
changed |= ui.add(PlainSlider::new("Size", state.tool_configs.active_size_mut(Tool::Brush), 1.0..=500.0, state.settings.theme).logarithmic(true).suffix("px")).changed();
changed |= draw_shared_toggle(ui, Tool::Brush, &mut state.tool_configs.shared.use_shared_opacity, colors);
changed |= ui.add(PlainSlider::new("Opacity", state.tool_configs.active_opacity_mut(Tool::Brush), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= draw_shared_toggle(ui, Tool::Brush, &mut state.tool_configs.shared.use_shared_hardness, colors);
changed |= ui.add(PlainSlider::new("Hardness", state.tool_configs.active_hardness_mut(Tool::Brush), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= ui.add(PlainSlider::new("Flow", &mut state.tool_configs.brush.flow, 0.0..=1.0, state.settings.theme)).changed();
changed |= ui.add(PlainSlider::new("Spacing", &mut state.tool_configs.brush.spacing, 0.01..=1.0, state.settings.theme)).changed();
let is_natural = matches!(state.tool_configs.brush.style,
hcie_engine_api::tools::BrushStyle::Meadow |
hcie_engine_api::tools::BrushStyle::Leaf |
hcie_engine_api::tools::BrushStyle::Dirt);
if is_natural {
changed |= ui.add(PlainSlider::new("Density", &mut state.tool_configs.brush.density, 0.2..=3.0, state.settings.theme)).changed();
}
changed |= ui.checkbox(&mut state.tool_configs.brush.color_variant, "Color Variant").changed();
if state.tool_configs.brush.color_variant {
changed |= ui.add(PlainSlider::new("Variant Amount", &mut state.tool_configs.brush.variant_amount, 0.0..=1.0, state.settings.theme)).changed();
}
changed |= ui.checkbox(&mut state.tool_configs.brush.cyclic_color, "Cyclic Color").changed();
if state.tool_configs.brush.cyclic_color {
changed |= ui.add(PlainSlider::new("Speed", &mut state.tool_configs.brush.cyclic_speed, 0.01..=5.0, state.settings.theme)).changed();
}
PressureIndicator::show(ui, state.current_pressure, colors);
}
Tool::Eraser => {
changed |= draw_shared_toggle(ui, Tool::Eraser, &mut state.tool_configs.shared.use_shared_size, colors);
changed |= ui.add(PlainSlider::new("Size", state.tool_configs.active_size_mut(Tool::Eraser), 1.0..=500.0, state.settings.theme).logarithmic(true).suffix("px")).changed();
changed |= draw_shared_toggle(ui, Tool::Eraser, &mut state.tool_configs.shared.use_shared_opacity, colors);
changed |= ui.add(PlainSlider::new("Opacity", state.tool_configs.active_opacity_mut(Tool::Eraser), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= draw_shared_toggle(ui, Tool::Eraser, &mut state.tool_configs.shared.use_shared_hardness, colors);
changed |= ui.add(PlainSlider::new("Hardness", state.tool_configs.active_hardness_mut(Tool::Eraser), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= ui.checkbox(&mut state.tool_configs.brush.cyclic_color, "Cyclic Color").changed();
PressureIndicator::show(ui, state.current_pressure, colors);
}
Tool::Spray => {
changed |= draw_shared_toggle(ui, Tool::Spray, &mut state.tool_configs.shared.use_shared_size, colors);
changed |= ui.add(PlainSlider::new("Radius", state.tool_configs.active_size_mut(Tool::Spray), 5.0..=200.0, state.settings.theme).logarithmic(true).suffix("px")).changed();
changed |= ui.add(PlainSlider::new("Particle Size", &mut state.tool_configs.spray.dot_size, 1.0..=20.0, state.settings.theme).suffix("px")).changed();
changed |= ui.add(PlainSlider::new("Density", &mut state.tool_configs.spray.density, 10..=1000, state.settings.theme)).changed();
changed |= draw_shared_toggle(ui, Tool::Spray, &mut state.tool_configs.shared.use_shared_opacity, colors);
changed |= ui.add(PlainSlider::new("Opacity", state.tool_configs.active_opacity_mut(Tool::Spray), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= draw_shared_toggle(ui, Tool::Spray, &mut state.tool_configs.shared.use_shared_hardness, colors);
changed |= ui.add(PlainSlider::new("Hardness", state.tool_configs.active_hardness_mut(Tool::Spray), 0.0..=1.0, state.settings.theme).suffix("%")).changed();
changed |= ui.checkbox(&mut state.tool_configs.brush.cyclic_color, "Cyclic Color").changed();
PressureIndicator::show(ui, state.current_pressure, colors);
}
Tool::FloodFill => {
changed |= ui.add(PlainSlider::new("Tolerance", &mut state.tool_configs.flood_fill.tolerance, 0..=255, state.settings.theme)).changed();
}
Tool::MagicWand => {
changed |= ui.add(PlainSlider::new("Tolerance", &mut state.tool_configs.magic_wand.tolerance, 0..=255, state.settings.theme)).changed();
}
t if t.is_shape_tool() => {
changed |= ui.add(PlainSlider::new("Stroke", &mut state.tool_configs.vector.stroke_size, 1.0..=50.0, state.settings.theme).suffix("px")).changed();
changed |= ui.add(PlainSlider::new("Opacity", &mut state.tool_configs.vector.opacity, 0.0..=1.0, state.settings.theme)).changed();
changed |= ui.checkbox(&mut state.tool_configs.vector.fill_shape, "Fill Shape").changed();
if t == Tool::VectorRect {
changed |= ui.add(PlainSlider::new("Radius", &mut state.tool_configs.vector.round_rect_radius, 0.0..=100.0, state.settings.theme).suffix("px")).changed();
}
if t == Tool::VectorStar {
changed |= ui.add(PlainSlider::new("Points", &mut state.tool_configs.vector.star_points, 3..=20, state.settings.theme)).changed();
changed |= ui.add(PlainSlider::new("Inner Radius", &mut state.tool_configs.vector.star_inner_radius, 0.1..=0.9, state.settings.theme)).changed();
}
if t == Tool::VectorPolygon {
changed |= ui.add(PlainSlider::new("Sides", &mut state.tool_configs.vector.polygon_sides, 3..=12, state.settings.theme)).changed();
}
changed |= ui.add(PlainSlider::new("Hardness", &mut state.tool_configs.vector.hardness, 0.0..=1.0, state.settings.theme)).changed();
// Edit SVG button for CustomShape (visible in Properties panel without switching to VectorSelect)
if matches!(state.active_tool, Tool::CustomShape(_)) {
if let Some(shape_idx) = state.selected_vector_shape {
let layer_id = doc.engine.active_layer_id();
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
ui.add_space(4.0);
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor { layer_id, shape_idx });
}
}
}
}
}
}
}
Tool::Text => {
crate::app::tools::text_editor::draw_text_properties(doc, state, event_bus, ui);
}
Tool::Gradient => {
ui.horizontal(|ui| {
changed |= ui.radio_value(&mut state.tool_configs.gradient.gradient_type, hcie_engine_api::tools::GradientType::Linear, "Linear").changed();
changed |= ui.radio_value(&mut state.tool_configs.gradient.gradient_type, hcie_engine_api::tools::GradientType::Radial, "Radial").changed();
});
}
Tool::VectorSelect => {
if let Some(shape_idx) = state.selected_vector_shape {
let layer_id = doc.engine.active_layer_id();
ui.label(RichText::new(format!("Shape #{}", shape_idx + 1)).strong().color(colors.accent));
ui.add_space(4.0);
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
// ── Edit SVG button for SvgShape variants ──
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
ui.horizontal(|ui| {
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor {
layer_id,
shape_idx,
});
}
});
ui.add_space(4.0);
}
let stroke = shape.stroke();
let mut new_stroke = stroke;
changed |= ui.add(PlainSlider::new("Stroke", &mut new_stroke, 0.0..=100.0, state.settings.theme).suffix("px")).changed();
if new_stroke != stroke {
doc.engine.set_vector_shape_stroke(layer_id, shape_idx, new_stroke);
}
let opacity = shape.shape_opacity();
let mut new_opacity = opacity;
changed |= ui.add(PlainSlider::new("Opacity", &mut new_opacity, 0.0..=1.0, state.settings.theme)).changed();
if (new_opacity - opacity).abs() > 0.001 {
doc.engine.set_vector_shape_opacity(layer_id, shape_idx, new_opacity);
}
let color = shape.color();
let mut rgba = egui::Rgba::from_rgba_premultiplied(
color[0] as f32 / 255.0,
color[1] as f32 / 255.0,
color[2] as f32 / 255.0,
color[3] as f32 / 255.0,
);
if egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::OnlyBlend).changed() {
let c: [u8; 4] = [
(rgba.r() * 255.0) as u8,
(rgba.g() * 255.0) as u8,
(rgba.b() * 255.0) as u8,
(rgba.a() * 255.0) as u8,
];
doc.engine.set_vector_shape_color(layer_id, shape_idx, c);
}
let hardness = shape.hardness();
let mut new_hardness = hardness;
changed |= ui.add(PlainSlider::new("Hardness", &mut new_hardness, 0.0..=1.0, state.settings.theme)).changed();
if (new_hardness - hardness).abs() > 0.001 {
doc.engine.set_vector_shape_hardness(layer_id, shape_idx, new_hardness);
}
if shape.fill_color().is_some() {
let fill = shape.fill().unwrap_or(false);
let mut new_fill = fill;
changed |= ui.checkbox(&mut new_fill, "Fill").changed();
if new_fill != fill {
doc.engine.set_vector_shape_fill(layer_id, shape_idx, new_fill);
event_bus.push(AppEvent::RenderRequested);
}
if fill {
let fc = shape.fill_color().unwrap_or([0; 4]);
let mut rgba = egui::Rgba::from_rgba_premultiplied(
fc[0] as f32 / 255.0,
fc[1] as f32 / 255.0,
fc[2] as f32 / 255.0,
fc[3] as f32 / 255.0,
);
ui.horizontal(|ui| {
ui.label("Fill Color:");
if egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::OnlyBlend).changed() {
let c: [u8; 4] = [
(rgba.r() * 255.0) as u8,
(rgba.g() * 255.0) as u8,
(rgba.b() * 255.0) as u8,
(rgba.a() * 255.0) as u8,
];
doc.engine.set_vector_shape_fill_color(layer_id, shape_idx, c);
}
});
}
}
let (left, top, right, bottom) = shape.bounds();
ui.label(RichText::new(format!("Pos: ({:.0}, {:.0}) → ({:.0}, {:.0})", left, top, right, bottom)).size(10.0).color(colors.text_secondary));
}
}
ui.add_space(8.0);
if ui.add(egui::Button::new(RichText::new("🗑 Delete Shape").size(11.0)).fill(Color32::from_rgb(120, 30, 30))).clicked() {
event_bus.push(AppEvent::VectorShapeDeleted(layer_id, shape_idx));
event_bus.push(AppEvent::RenderRequested);
state.selected_vector_shape = None;
}
} else {
ui.label(RichText::new("Click a shape on canvas to select it.").size(11.0).color(colors.text_secondary));
}
}
Tool::SmartSelect => {
ui.label(RichText::new("Smart Selection").strong().color(colors.accent));
ui.add_space(4.0);
changed |= ui.checkbox(&mut state.tool_configs.smart_select.use_ai, "AI Mode (SAM model)").changed();
ui.add_space(4.0);
if state.tool_configs.smart_select.use_ai {
ui.label(RichText::new("AI: Uses composite (all layers) for selection. Requires SAM .gguf model loaded via hcie-vision.").size(10.0).color(colors.text_secondary));
ui.add_space(6.0);
ui.label(RichText::new("SAM Model (.gguf):").size(10.0).color(colors.text_secondary));
let sam_path = &mut state.vision_sam_model_path;
let sam_exists = std::path::Path::new(sam_path.as_str()).exists();
ui.horizontal(|ui| {
ui.add_sized(
egui::vec2(ui.available_width() - 28.0, 18.0),
egui::TextEdit::singleline(sam_path).font(egui::FontId::proportional(10.5)),
);
if ui.small_button("\u{2026}").on_hover_text("Browse").clicked() {
if let Some(file) = rfd::FileDialog::new()
.add_filter("GGUF Model", &["gguf", "bin"])
.pick_file()
{
*sam_path = file.display().to_string();
}
}
});
if sam_exists {
ui.label(RichText::new(" \u{2713} Model found").size(9.5).color(egui::Color32::from_rgb(100, 200, 120)));
} else if !sam_path.is_empty() {
ui.label(RichText::new(" \u{2717} File not found — download or set path").size(9.5).color(egui::Color32::from_rgb(220, 140, 60)));
}
changed |= ui.add(PlainSlider::new("AI Tolerance", &mut state.tool_configs.smart_select.tolerance, 0..=255, state.settings.theme)).changed();
} else {
ui.label(RichText::new("Local: Edge-aware flood fill on active layer. Uses color tolerance to find boundaries.").size(10.0).color(colors.text_secondary));
ui.add_space(6.0);
changed |= ui.add(PlainSlider::new("Tolerance", &mut state.tool_configs.smart_select.tolerance, 0..=255, state.settings.theme)).changed();
}
}
Tool::VisionSelect => {
ui.label(RichText::new("Vision Tools").strong().color(colors.accent));
ui.add_space(4.0);
ui.label(RichText::new("AI-powered tools. Works in fallback mode without models.").size(10.0).color(colors.text_secondary));
ui.add_space(8.0);
ui.label(RichText::new("Task:").size(11.0).color(colors.text_primary));
egui::ComboBox::from_id_salt("vision_task_combo")
.selected_text(match state.vision_task {
VisionTask::Segmentation => "Object Segmentation",
VisionTask::BackgroundRemoval => "Background Removal",
VisionTask::Inpainting => "Inpainting",
VisionTask::SuperResolution => "Super Resolution",
})
.width(ui.available_width())
.show_ui(ui, |ui| {
ui.selectable_value(&mut state.vision_task, VisionTask::Segmentation, "Object Segmentation");
ui.selectable_value(&mut state.vision_task, VisionTask::BackgroundRemoval, "Background Removal");
ui.selectable_value(&mut state.vision_task, VisionTask::Inpainting, "Inpainting");
ui.selectable_value(&mut state.vision_task, VisionTask::SuperResolution, "Super Resolution");
});
ui.add_space(8.0);
let path = match state.vision_task {
VisionTask::Segmentation => &mut state.vision_sam_model_path,
VisionTask::BackgroundRemoval => &mut state.vision_birefnet_model_path,
VisionTask::Inpainting => &mut state.vision_migan_model_path,
_ => &mut state.vision_sam_model_path,
};
let exists = std::path::Path::new(path.as_str()).exists();
ui.label(RichText::new("Model (.gguf):").size(10.0).color(colors.text_secondary));
ui.horizontal(|ui| {
let resp = ui.add_sized(
egui::vec2(ui.available_width() - 28.0, 18.0),
egui::TextEdit::singleline(path).font(egui::FontId::proportional(10.5)),
);
if resp.changed() { /* path updated in-place */ }
if ui.small_button("\u{2026}").on_hover_text("Browse").clicked() {
if let Some(file) = rfd::FileDialog::new()
.add_filter("GGUF Model", &["gguf", "bin"])
.pick_file()
{
*path = file.display().to_string();
}
}
});
// Status indicator
if path.is_empty() {
ui.label(RichText::new(" No model — using fallback (flood fill)").size(9.5).color(colors.text_secondary));
} else if exists {
ui.label(RichText::new(" \u{2713} Model file found").size(9.5).color(egui::Color32::from_rgb(100, 200, 120)));
} else {
ui.label(RichText::new(" \u{2717} File not found — will use fallback").size(9.5).color(egui::Color32::from_rgb(220, 140, 60)));
}
ui.add_space(6.0);
ui.label(RichText::new("Backend:").size(10.0).color(colors.text_secondary));
let backend_before = state.vision_backend;
ui.horizontal(|ui| {
ui.selectable_value(&mut state.vision_backend, VisionBackend::Cpu, "CPU");
ui.selectable_value(&mut state.vision_backend, VisionBackend::Gpu, "GPU");
});
if state.vision_backend != backend_before {
event_bus.push(AppEvent::VisionBackendChanged(state.vision_backend));
}
ui.add_space(10.0);
let run_btn = egui::Button::new(RichText::new("RUN TASK").strong().color(Color32::WHITE))
.fill(colors.accent)
.min_size(egui::vec2(ui.available_width(), 28.0));
if ui.add(run_btn).clicked() {
event_bus.push(AppEvent::VisionRunTask);
}
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
// Model download help
egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(), egui::Id::new("vision_model_download_info"), false,
)
.show_header(ui, |ui| {
ui.label(RichText::new("Download Models").size(10.0).color(colors.text_secondary));
})
.body(|ui| {
if ui.small_button("Create models/ directory").clicked() {
let _ = std::fs::create_dir_all("models");
}
ui.add_space(4.0);
struct ModelInfo { name: &'static str, file: &'static str, url: &'static str, size: &'static str }
let models = [
ModelInfo { name: "MobileSAM", file: "models/mobile_sam.gguf", url: "https://huggingface.co/Acly/MobileSAM-GGUF", size: "~27MB" },
ModelInfo { name: "BiRefNet HR", file: "models/birefnet.gguf", url: "https://huggingface.co/gobeldan/BiRefNet_HR-GGUF?show_file_info=BiRefNet_HR-F16.gguf", size: "~440MB" },
ModelInfo { name: "RealESRGAN", file: "models/esrgan.gguf", url: "https://huggingface.co/Acly/Real-ESRGAN-GGUF", size: "~33MB" },
];
for m in &models {
let exists = std::path::Path::new(m.file).exists();
let status = if exists { "\u{2713}" } else { "\u{25CB}" };
let status_color = if exists { egui::Color32::from_rgb(100, 200, 120) } else { colors.text_secondary };
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} {}", status, m.name)).size(10.0).color(status_color));
ui.label(RichText::new(m.size).size(9.0).color(colors.text_secondary));
if !exists && ui.small_button("Download").on_hover_text(format!("Open: {}", m.url)).clicked() {
let _ = std::process::Command::new("xdg-open").arg(m.url).spawn();
}
});
ui.label(RichText::new(format!("{}", m.file)).size(8.5).monospace().color(colors.text_secondary));
}
ui.add_space(4.0);
ui.label(RichText::new("After download, set the path above or rename to match.").size(9.0).color(colors.text_secondary));
});
}
_ => {
ui.label("No properties for this tool.");
}
}
crate::app::shell::gui_layout::end_block("panel.properties.tool_block", ui);
if changed {
ui.ctx().request_repaint();
}
});
}
// ── AI Chat / Comfy Stubs ────────────────────────────────────────────────────
pub fn show_comfy_ai(app: &mut HcieApp, ui: &mut egui::Ui) {
let colors = ThemeColors::get(app.state.settings.theme);
crate::app::shell::gui_layout::label(
"panel.ai.comfy_title",
ui,
"ComfyUI",
colors.text_primary,
);
ui.add_space(4.0);
crate::app::shell::gui_layout::label(
"panel.ai.comfy_status",
ui,
format!("Status: {:?}", app.state.comfy_status),
colors.text_secondary,
);
ui.add_space(4.0);
crate::app::shell::gui_layout::label(
"panel.ai.comfy_placeholder",
ui,
"(ComfyUI integration — placeholder)",
colors.text_secondary,
);
if crate::app::shell::gui_layout::button("panel.ai.comfy_btn_check", ui, "Check Status")
.clicked()
{
// TODO: trigger status check via hcie-vision
}
}
pub fn show_ai_chat(app: &mut HcieApp, ui: &mut egui::Ui) {
let active_idx = app.active_doc;
if let Some(doc) = app.documents.get_mut(active_idx) {
let mut history_tuples: Vec<(String, String)> = app
.state
.ai_chat_history
.iter()
.map(|m| (m.role.clone(), m.content.clone()))
.collect();
egui_panel_ai_chat::show_ai_chat_panel(
ui,
&mut doc.engine,
&mut app.state.ai_chat_thinking,
&mut history_tuples,
&mut app.state.ai_chat_config.url,
&mut app.state.ai_chat_config.model,
&mut app.state.ai_chat_models,
&mut app.state.ai_chat_buffer,
&mut app.state.ai_chat_show_reasoning,
&mut app.state.ai_chat_max_turns,
&mut app.ai_chat_current_reasoning,
);
app.state.ai_chat_history = history_tuples
.into_iter()
.map(|(role, content)| crate::app::AiChatMessage { role, content })
.collect();
} else {
ui.label("No active document.");
}
}
// ── Script & AI Script ──────────────────────────────────────────────────────
pub fn show_plugins_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
let engine = if let Some(doc) = app.documents.get_mut(0) {
&mut doc.engine
} else {
ui.label("No active document");
return;
};
let script_editor = &mut app.state.script_editor;
let ai_gen = &mut app.state.ai_script_generator;
ui.vertical(|ui| {
egui::CollapsingHeader::new("\u{1f3a8} HCIE Script Editor")
.default_open(true)
.show(ui, |ui| {
script_editor.show(ui, engine);
});
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
egui::CollapsingHeader::new("\u{1f916} AI Script Generator")
.default_open(true)
.show(ui, |ui| {
let copy = ai_gen.show(ui, engine);
if copy {
script_editor.script = ai_gen.generated_script.clone();
script_editor.last_error = None;
script_editor.error_line = None;
}
});
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
// Real HciePlugin system (from plugins/integration.rs + registry)
egui::CollapsingHeader::new("\u{1f9e9} HciePlugin System (Real)")
.default_open(true)
.show(ui, |ui| {
let count = {
let host = &mut app.plugin_host;
host.registry_mut().len()
};
ui.label(format!("Registered plugins: {}", count));
// Note: Full plugin UI rendering temporarily disabled due to borrow checker in collapsing closure.
// The core system (event dispatch to DebugSignalLogger) is active.
// TODO: Move plugin UI rendering to a separate panel or use RefCell for host if needed.
ui.label(
"(Plugin UI contributions will appear here once borrow issues are resolved)",
);
});
});
}