b49106dc1d
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.
2161 lines
96 KiB
Rust
2161 lines
96 KiB
Rust
#![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)",
|
||
);
|
||
});
|
||
});
|
||
}
|