GOOD Refactor GUI components and add PlainSlider widget

- Added a new `PlainSlider` widget for keyboard-editable numeric sliders.
- Updated various panels to utilize the new `PlainSlider` for better user interaction.
- Removed unused code and dead code warnings across multiple files.
- Improved organization of modules by adding a `widgets` module.
- Cleaned up imports in several files to streamline the codebase.
- Added Qodana configuration file for code analysis and quality checks.
This commit is contained in:
2026-07-19 00:55:40 +03:00
parent bbabd2acdb
commit 9388e6f096
46 changed files with 1680 additions and 872 deletions
@@ -92,7 +92,7 @@ pub enum AiProvider {
impl AiProvider {
/// Return the default base URL for this provider.
pub fn default_url(&self) -> &str {
pub fn _default_url(&self) -> &str {
match self {
AiProvider::Ollama => "http://localhost:11434",
AiProvider::Claude => "https://api.anthropic.com",
@@ -101,7 +101,7 @@ impl AiProvider {
}
/// Return the display name.
pub fn display_name(&self) -> &str {
pub fn _display_name(&self) -> &str {
match self {
AiProvider::Ollama => "Ollama",
AiProvider::Claude => "Claude",
@@ -120,7 +120,7 @@ pub struct AiChatConfig {
/// Model name to use for chat completions.
pub model: String,
/// Maximum conversation turns before auto-truncation.
pub max_turns: u32,
pub _max_turns: u32,
/// Whether to display reasoning/thinking content from the LLM.
pub reasoning_enabled: bool,
/// Whether to send canvas context (dimensions, layers) with each request.
@@ -133,7 +133,7 @@ impl Default for AiChatConfig {
provider: AiProvider::Ollama,
base_url: "http://localhost:11434".to_string(),
model: "qwen2.5:latest".to_string(),
max_turns: 20,
_max_turns: 20,
reasoning_enabled: false,
canvas_context: true,
}
@@ -156,7 +156,7 @@ pub struct AiChatState {
/// Current reasoning/thinking text being streamed.
pub current_reasoning: String,
/// Available models fetched from the endpoint.
pub available_models: Vec<String>,
pub _available_models: Vec<String>,
}
impl Default for AiChatState {
@@ -168,7 +168,7 @@ impl Default for AiChatState {
is_streaming: false,
current_response: String::new(),
current_reasoning: String::new(),
available_models: Vec::new(),
_available_models: Vec::new(),
}
}
}
+55 -19
View File
@@ -152,10 +152,10 @@ pub struct HcieIcedApp {
/// Pending automated or keyboard-triggered full-viewport screenshot.
pub screenshot_request: Option<crate::cli::ScreenshotRequest>,
/// New Image dialog state.
pub dialog_input: String,
pub dialog_input2: String,
pub show_performance: bool,
pub ui_scale: f32,
pub _dialog_input: String,
pub _dialog_input2: String,
pub _show_performance: bool,
pub _ui_scale: f32,
pub modifiers: iced::keyboard::Modifiers,
pub dialog_new_name: String,
pub dialog_new_width: u32,
@@ -280,7 +280,7 @@ pub struct HcieIcedApp {
/// Whether the dock profile dialog is visible.
pub show_dock_profile_dialog: bool,
/// Current UI language for internationalization.
pub language: i18n::Language,
pub _language: i18n::Language,
/// Context menu screen position (x, y). None means closed.
pub canvas_context_menu: Option<(f32, f32)>,
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be
@@ -1356,10 +1356,10 @@ impl HcieIcedApp {
sub_tools_open: false,
sidebar_expanded: settings.panel_layout.sidebar_expanded,
slot_last_used: vec![0; 20],
dialog_input: String::new(),
dialog_input2: String::new(),
show_performance: false,
ui_scale: 1.0,
_dialog_input: String::new(),
_dialog_input2: String::new(),
_show_performance: false,
_ui_scale: 1.0,
modifiers: iced::keyboard::Modifiers::default(),
canvas_context_menu: None,
settings: settings.clone(),
@@ -1434,7 +1434,7 @@ impl HcieIcedApp {
dock_profile_rename_idx: None,
dock_profile_rename_buf: String::new(),
show_dock_profile_dialog: false,
language: i18n::Language::default(),
_language: i18n::Language::default(),
colors_dirty: false,
vector_shapes_snapshot: None,
vector_drag_last: None,
@@ -1582,7 +1582,7 @@ impl HcieIcedApp {
///
/// The canvas image is centered in the pane, with `pan_offset` as an
/// additional offset from center. This function accounts for both.
fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
fn _screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
let doc = &self.documents[self.active_doc];
let zoom = doc.zoom;
let (pane_w, pane_h) = doc.pane_size;
@@ -1823,7 +1823,7 @@ impl HcieIcedApp {
// structure is no longer valid, and a partial copy would leave zeros in the new buffer.
if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) {
let w = doc.engine.canvas_width() as usize;
let h = doc.engine.canvas_height() as usize;
let _h = doc.engine.canvas_height() as usize;
let [x0, y0, x1, y1] = rect;
let x0 = x0 as usize;
let y0 = y0 as usize;
@@ -5383,6 +5383,20 @@ impl HcieIcedApp {
self.documents[self.active_doc].vector_drag_preview = None;
return Task::none();
}
// Frame-skip throttle: skip processing if less than ~16ms since
// the last update to keep the overlay drawing at ~60 FPS and avoid
// flooding the message queue.
let now = std::time::Instant::now();
let min_interval = std::time::Duration::from_secs_f32(1.0 / 60.0);
if let Some(last) = self.vector_drag_last_render {
if now.duration_since(last) < min_interval {
// Still update pointer tracking even when skipping preview
self.vector_drag_last = Some((x, y));
return Task::none();
}
}
let shape = crate::vector_edit::transform_shape(
&session,
(x, y),
@@ -5399,6 +5413,7 @@ impl HcieIcedApp {
self.documents[self.active_doc].vector_drag_preview = Some(shape);
self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y));
self.vector_drag_last_render = Some(now);
self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
self.vector_drag_last_angle = Some(angle);
self.settings.tool_settings.vector_angle = angle.to_degrees();
@@ -5485,12 +5500,32 @@ impl HcieIcedApp {
}
// ── Vector shape apply / cancel / flip ──────
Message::VectorShapeApply => {
// Commit edits: clear snapshot and deselect.
let doc = &mut self.documents[self.active_doc];
doc.selected_vector_shape = None;
doc.vector_cycle_index = 0;
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
doc.vector_drag_preview = None;
// Commit any pending drag preview to the engine before deselecting,
// then clear the snapshot so Cancel cannot revert.
let need_refresh = {
let doc = &mut self.documents[self.active_doc];
if let Some(shape) = doc.vector_drag_preview.take() {
let shape_idx = doc.selected_vector_shape;
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
let layer_id = doc.engine.active_layer_id();
if let Some(idx) = shape_idx {
doc.engine.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, y2);
doc.engine.set_vector_shape_angle(layer_id, idx, angle);
}
doc.engine.mark_composite_dirty();
doc.engine.is_composite_dirty()
} else {
false
}
};
if need_refresh {
self.refresh_composite_if_needed();
}
self.documents[self.active_doc].selected_vector_shape = None;
self.documents[self.active_doc].vector_cycle_index = 0;
self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None;
self.vector_shapes_snapshot = None;
self.vector_drag_last = None;
self.vector_drag_session = None;
@@ -5510,6 +5545,7 @@ impl HcieIcedApp {
self.vector_drag_session = None;
doc.engine.mark_composite_dirty();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::VectorShapeFlipH => {
@@ -8230,7 +8266,7 @@ mod cycle_one_ux_tests {
/// Find the topmost text layer whose rasterized pixels contain the point
/// `(x, y)` in canvas coordinates.
fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
fn _find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
let ux = x.round().clamp(0.0, u32::MAX as f32) as u32;
let uy = y.round().clamp(0.0, u32::MAX as f32) as u32;
let infos = engine.layer_infos();
@@ -537,7 +537,7 @@ pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec<BrushPreset> {
///
/// # Returns
/// Optional `BrushPreset` if parsing succeeds.
pub fn import_kpp(data: &[u8], name_hint: &str) -> Option<BrushPreset> {
pub fn _import_kpp(data: &[u8], name_hint: &str) -> Option<BrushPreset> {
use std::io::Cursor;
use zip::ZipArchive;
@@ -60,11 +60,11 @@ struct OverlayProgram {
/// Active selection transform (for drawing handles).
selection_transform: Option<SelectionTransform>,
/// Current transform handle being hovered/dragged.
active_handle: TransformHandle,
_active_handle: TransformHandle,
/// Crop tool state (for drawing crop overlay).
crop_state: Option<CropState>,
/// Marching ants animation offset (0.0..1.0).
marching_ants_offset: f32,
_marching_ants_offset: f32,
/// Current pen pressure for HUD display (0.0..1.0, 1.0 = no tablet).
pressure: f32,
/// Active tool (drives vector shape preview geometry).
@@ -82,11 +82,11 @@ struct OverlayProgram {
/// Polygon selection points accumulated during click-by-click (canvas-space).
polygon_points: Vec<(u32, u32)>,
/// Extracted edges for marching ants rendering.
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
_marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
/// Exact horizontal selected-pixel spans for irregular selection fill.
selection_fill_spans: Option<std::sync::Arc<Vec<(u32, u32, u32)>>>,
_selection_fill_spans: Option<std::sync::Arc<Vec<(u32, u32, u32)>>>,
/// Whether selected pixels use quick-mask red instead of the normal blue tint.
quick_mask: bool,
_quick_mask: bool,
/// Active brush diameter in canvas pixels for the footprint cursor.
brush_size: f32,
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
@@ -891,7 +891,7 @@ impl canvas::Program<Message> for OverlayProgram {
// arrow_thickness = stroke * 1.8, head_len = arrow_thickness * 3
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt().max(1.0);
let _len = (dx * dx + dy * dy).sqrt().max(1.0);
let head_angle = dy.atan2(dx);
let arrow_angle = std::f32::consts::PI / 6.0;
let arrow_thickness = self.zoom; // stroke width in canvas space
@@ -1283,6 +1283,51 @@ impl canvas::Program<Message> for OverlayProgram {
}
}
// Draw vector shape preview during a drag (real-time rotated outline).
// The engine composite is NOT updated during drag, so the overlay must
// draw the preview shape to show the user where the shape will end up.
// A solid bright outline is used instead of dashed so the preview is
// clearly visible during fast pointer movements.
if let Some(preview) = self.vector_drag_preview.as_ref() {
let (px1, py1, px2, py2) = preview.normalized_bounds();
let pang = preview.angle();
let cx = (px1 + px2) * 0.5;
let cy = (py1 + py2) * 0.5;
let cos_a = pang.cos();
let sin_a = pang.sin();
let rotate = |x: f32, y: f32| -> (f32, f32) {
let dx = x - cx;
let dy = y - cy;
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
};
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
let rotated_corners: Vec<(f32, f32)> =
corners.iter().map(|&(x, y)| rotate(x, y)).collect();
let preview_path = Path::new(|b| {
for (i, &(rx, ry)) in rotated_corners.iter().enumerate() {
let sx = origin_x + rx * self.zoom;
let sy = origin_y + ry * self.zoom;
if i == 0 {
b.move_to(Point::new(sx, sy));
} else {
b.line_to(Point::new(sx, sy));
}
}
b.close();
});
// Solid bright outline for fast visible feedback
frame.stroke(
&preview_path,
Stroke {
style: canvas::stroke::Style::Solid(
iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95),
),
width: (2.5 / self.zoom.max(1.0)).max(1.0),
..Default::default()
},
);
}
// Draw vector edit handles for selected shape (blue outline, resize + rotation)
if let Some((x1, y1, x2, y2)) = self.selected_vector_bounds {
self.draw_vector_edit_handles(
@@ -1624,12 +1669,17 @@ pub fn view<'a>(
)
} else if let Some(idx) = doc.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(idx) {
let shapes = doc.engine.active_vector_shapes();
// Validate that the selected index exists on the current active layer.
// If the layer changed or shapes were reordered, the index may be stale.
if let Some(ref shapes) = shapes {
if idx < shapes.len() {
let shape = &shapes[idx];
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = doc.engine.vector_shape_angle(layer_id, idx);
(Some((x1, y1, x2, y2)), angle, None)
} else {
// Stale index — silently show nothing until the next valid click
(None, 0.0, None)
}
} else {
@@ -1648,9 +1698,9 @@ pub fn view<'a>(
selection_bounds: doc.selection_bounds,
vector_draw: doc.vector_draw,
selection_transform: doc.selection_transform.clone(),
active_handle: TransformHandle::None, // TODO: track hover state
_active_handle: TransformHandle::None, // TODO: track hover state
crop_state: Some(doc.crop_state.clone()),
marching_ants_offset,
_marching_ants_offset: marching_ants_offset,
pressure,
active_tool: tool_state.active_tool,
star_points,
@@ -1659,9 +1709,9 @@ pub fn view<'a>(
gradient_drag: doc.gradient_drag,
lasso_points: doc.lasso_points.clone(),
polygon_points: doc.polygon_points.clone(),
marching_ants_edges: doc.marching_ants_edges.clone(),
selection_fill_spans: doc.selection_fill_spans.clone(),
quick_mask: doc.quick_mask,
_marching_ants_edges: doc.marching_ants_edges.clone(),
_selection_fill_spans: doc.selection_fill_spans.clone(),
_quick_mask: doc.quick_mask,
brush_size: tool_state.brush_size,
selected_vector_bounds: sel_vec_bounds,
selected_vector_angle: sel_vec_angle,
@@ -31,6 +31,7 @@ pub enum ColorPickerTarget {
/// HSV color representation (Hue, Saturation, Value)
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct Hsv {
pub h: f32, // 0..360
pub s: f32, // 0..1
@@ -38,12 +39,14 @@ pub struct Hsv {
}
impl Hsv {
#[allow(dead_code)]
pub fn to_rgb(&self) -> (u8, u8, u8) {
hsv_to_rgb(self.h, self.s, self.v)
}
}
/// Convert RGB to HSV.
#[allow(dead_code)]
pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
@@ -69,6 +72,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
}
/// Convert HSV to RGB.
#[allow(dead_code)]
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let h = h / 360.0;
let (r, g, b) = if s == 0.0 {
@@ -15,6 +15,7 @@ pub enum DockRole {
/// A movable utility panel.
Tool,
/// Shell UI that never enters the dock manager.
#[allow(dead_code)]
Fixed,
}
@@ -108,6 +108,7 @@ impl PaneType {
}
/// Compact readable label used on narrow auto-hide rails.
#[allow(dead_code)]
pub fn rail_label(self) -> &'static str {
match self {
Self::ColorPicker => "Color",
@@ -40,6 +40,7 @@ pub enum TranslationKey {
/// Returns the translated string for the given language and key.
///
/// Uses match arms to return `&'static str` — no allocations at call site.
#[allow(dead_code)]
pub fn t(lang: Language, key: TranslationKey) -> &'static str {
match lang {
Language::Turkish => match key {
@@ -4,3 +4,4 @@ pub mod cli;
pub mod raster;
pub mod selection;
pub mod vector_edit;
pub mod widgets;
@@ -24,6 +24,7 @@ mod sidebar;
mod theme;
mod vector_edit;
mod viewer;
mod widgets;
use app::HcieIcedApp;
@@ -778,7 +778,7 @@ pub fn view(
.padding([2, 8]);
row![lbl, toggle_btn].spacing(4)
},
{ text("Gradient stops configured via color picker").size(10) },
text("Gradient stops configured via color picker").size(10),
]
.spacing(2)
.into()
@@ -7,7 +7,7 @@
use crate::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors;
use iced::widget::{column, container, horizontal_rule, scrollable, text};
use iced::widget::{column, container, scrollable, text};
use iced::{Element, Length};
/// Build the history panel element.
@@ -229,6 +229,7 @@ fn menu_item_row(
/// All menu definitions matching Photopea's menu structure exactly.
#[derive(Clone)]
struct MenuDef {
#[allow(dead_code)]
label: String,
items: Vec<MenuItem>,
}
@@ -10,10 +10,9 @@ use crate::color_picker::ColorPickerTarget;
use crate::panels::styles;
use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors;
use crate::widgets::plain_slider;
use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape};
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text,
};
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text};
use iced::{Element, Length};
/// Human-readable label for a shape at a given index.
@@ -200,11 +199,15 @@ fn layer_info_section<'a>(
layer_id: u64,
_colors: ThemeColors,
) -> Element<'a, Message> {
let opacity_slider = slider(0.0..=1.0, opacity, move |v| {
Message::LayerSetOpacity(layer_id, v)
})
.step(0.01)
.width(Length::Fill);
let opacity_slider = plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
move |v| Message::LayerSetOpacity(layer_id, v),
);
column![
text("Layer Info").size(SECTION),
@@ -213,18 +216,11 @@ fn layer_info_section<'a>(
text(name).size(BODY),
]
.spacing(4),
row![
text("Opacity").size(BODY).width(Length::Fixed(58.0)),
text(format!("{:.0}%", opacity * 100.0))
.size(BODY)
.width(Length::Fixed(36.0)),
]
.spacing(4),
opacity_slider,
row![
text("Visible").size(BODY).width(Length::Fixed(58.0)),
checkbox("", visible)
.on_toggle(move |v| Message::LayerToggleVisibility(layer_id))
.on_toggle(move |_v| Message::LayerToggleVisibility(layer_id))
.size(11),
]
.spacing(4)
@@ -289,44 +285,42 @@ fn vector_select_properties<'a>(
text("Shape Properties").size(SECTION),
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
row![
text("Stroke").size(BODY),
text(format!("{:.1}", stroke)).size(BODY)
]
.spacing(4),
slider(0.0..=50.0, stroke, move |v| Message::VectorStrokeChanged(v))
.step(0.5)
.width(Length::Fill),
row![
text("Opacity").size(BODY),
text(format!("{:.0}%", opacity * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(
v
))
.step(0.01)
.width(Length::Fill),
row![
text("Hardness").size(BODY),
text(format!("{:.0}%", hardness * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, hardness, move |v| {
Message::GeometryHardnessChanged(v)
})
.step(0.01)
.width(Length::Fill),
row![
text("Angle").size(BODY),
text(format!("{:.0}°", vector_angle)).size(BODY)
]
.spacing(4),
slider(-180.0..=180.0, vector_angle, move |v| {
Message::VectorAngleChanged(v)
})
.step(1.0)
.width(Length::Fill),
plain_slider(
"Stroke",
stroke,
0.0..=50.0,
0.5,
"px",
1,
move |v| Message::VectorStrokeChanged(v),
),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
move |v| Message::VectorOpacityChanged(v),
),
plain_slider(
"Hardness",
hardness,
0.0..=1.0,
0.01,
"%",
0,
move |v| Message::GeometryHardnessChanged(v),
),
plain_slider(
"Angle",
vector_angle,
-180.0..=180.0,
1.0,
"°",
0,
move |v| Message::VectorAngleChanged(v),
),
row![
text("Fill").size(BODY),
checkbox("", filled)
@@ -380,22 +374,24 @@ fn vector_tool_properties<'a>(
) -> Element<'a, Message> {
let mut col = column![
text("Vector Shape").size(11),
row![
text("Stroke").size(10),
text(format!("{:.1}", stroke)).size(10)
]
.spacing(4),
slider(0.0..=50.0, stroke, |v| Message::VectorStrokeChanged(v))
.step(0.5)
.width(Length::Fill),
row![
text("Opacity").size(10),
text(format!("{:.0}%", opacity * 100.0)).size(10)
]
.spacing(4),
slider(0.0..=1.0, opacity, |v| Message::VectorOpacityChanged(v))
.step(0.01)
.width(Length::Fill),
plain_slider(
"Stroke",
stroke,
0.0..=50.0,
0.5,
"px",
1,
|v| Message::VectorStrokeChanged(v),
),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::VectorOpacityChanged(v),
),
row![
text("Fill").size(10),
checkbox("", fill)
@@ -426,45 +422,37 @@ fn vector_tool_properties<'a>(
// Shape-specific controls
match tool {
Tool::VectorRect => {
col = col.push(
row![
text("Radius").size(10),
text(format!("{:.1}", radius)).size(10)
]
.spacing(4),
);
col = col.push(
slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v))
.step(1.0)
.width(Length::Fill),
);
col = col.push(plain_slider(
"Radius",
radius,
0.0..=100.0,
1.0,
"px",
1,
|v| Message::VectorRadiusChanged(v),
));
}
Tool::VectorStar => {
col = col.push(
row![
text("Points").size(10),
text(format!("{}", points)).size(10)
]
.spacing(4),
);
col = col.push(
slider(3.0..=20.0, points as f32, |v| {
Message::VectorPointsChanged(v as u32)
})
.step(1.0)
.width(Length::Fill),
);
col = col.push(plain_slider(
"Points",
points as f32,
3.0..=20.0,
1.0,
"",
0,
|v| Message::VectorPointsChanged(v as u32),
));
}
Tool::VectorPolygon => {
col = col
.push(row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4));
col = col.push(
slider(3.0..=12.0, sides as f32, |v| {
Message::VectorSidesChanged(v as u32)
})
.step(1.0)
.width(Length::Fill),
);
col = col.push(plain_slider(
"Sides",
sides as f32,
3.0..=12.0,
1.0,
"",
0,
|v| Message::VectorSidesChanged(v as u32),
));
}
_ => {}
}
@@ -488,32 +476,35 @@ fn brush_properties<'a>(
hardness: f32,
_colors: ThemeColors,
) -> Element<'a, Message> {
let size_slider = slider(1.0..=200.0, size, |v| Message::BrushSizeChanged(v))
.step(1.0)
.width(Length::Fill);
let opacity_slider = slider(0.0..=1.0, opacity, |v| Message::BrushOpacityChanged(v))
.step(0.01)
.width(Length::Fill);
let hardness_slider = slider(0.0..=1.0, hardness, |v| Message::BrushHardnessChanged(v))
.step(0.01)
.width(Length::Fill);
column![
text("Brush Properties").size(11),
row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4),
size_slider,
row![
text("Opacity").size(10),
text(format!("{:.0}%", opacity * 100.0)).size(10)
]
.spacing(4),
opacity_slider,
row![
text("Hardness").size(10),
text(format!("{:.0}%", hardness * 100.0)).size(10)
]
.spacing(4),
hardness_slider,
plain_slider(
"Size",
size,
1.0..=200.0,
1.0,
"px",
0,
|v| Message::BrushSizeChanged(v),
),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::BrushOpacityChanged(v),
),
plain_slider(
"Hardness",
hardness,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::BrushHardnessChanged(v),
),
]
.spacing(4)
.into()
@@ -106,6 +106,7 @@ pub fn panel_background(colors: ThemeColors) -> iced::widget::container::Style {
}
/// Panel header — #333333 (Photopea header color).
#[allow(dead_code)]
pub fn panel_header(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)),
@@ -53,9 +53,9 @@ pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_wid
pub fn view<'a>(
doc_name: &'a str,
modified: bool,
zoom: f32,
canvas_w: u32,
canvas_h: u32,
_zoom: f32,
_canvas_w: u32,
_canvas_h: u32,
colors: ThemeColors,
active_menu: Option<usize>,
) -> Element<'a, Message> {
@@ -13,7 +13,7 @@ use crate::app::{Message, ToolState};
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input};
use iced::widget::{button, container, row, slider, text, text_input};
use iced::{Element, Length};
/// Build the Photopea-style tool options bar.
@@ -25,8 +25,8 @@ use iced::{Element, Length};
/// - Move: Auto-Select
/// - Text: Font, Size, Color
/// - Gradient: Mode, Opacity
pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
let tool_name = text(tool_name_label(tool_state.active_tool))
pub fn _view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
let tool_name = text(_tool_name_label(tool_state.active_tool))
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_primary),
@@ -202,7 +202,7 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
}
/// Get the display name for a tool.
fn tool_name_label(tool: Tool) -> &'static str {
fn _tool_name_label(tool: Tool) -> &'static str {
match tool {
Tool::Move => "Move Tool",
Tool::Select => "Rectangle Select",
@@ -226,6 +226,7 @@ fn tool_name_label(tool: Tool) -> &'static str {
}
/// A small checkbox-style label (visual only, for display).
#[allow(dead_code)]
fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> {
let mark = if checked { "" } else { "" };
let label_owned = label.to_string();
@@ -236,6 +237,7 @@ fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> {
}
/// A small flat button for tool options.
#[allow(dead_code)]
fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let bg_hover = colors.bg_hover;
let bg_panel = colors.bg_panel;
@@ -17,7 +17,7 @@ use crate::settings::AppSettings;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
button, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
};
use iced::{Element, Length};
@@ -309,10 +309,10 @@ fn text_settings(
let draft_angle = text_draft.map(|d| d.angle).unwrap_or(ts2.text_angle);
let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color);
let draft_content = text_draft.map(|d| d.content.clone()).unwrap_or_default();
let alignment = text_draft
let _alignment = text_draft
.map(|d| format!("{:?}", d.alignment))
.unwrap_or_else(|| "Left".to_string());
let orientation = text_draft
let _orientation = text_draft
.map(|d| format!("{:?}", d.orientation))
.unwrap_or_else(|| "Horizontal".to_string());
@@ -231,6 +231,7 @@ fn svg_btn(
)
}
#[allow(dead_code)]
fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let c = colors;
let l = label.to_string();
@@ -1,9 +1,3 @@
pub mod executor;
pub mod parser;
pub mod types;
pub use executor::execute_actions;
pub use parser::parse_dsl_script;
pub use parser::parse_error_line;
pub use parser::suggest_fix;
pub use parser::DEFAULT_SCRIPT;
@@ -93,6 +93,7 @@ pub fn extract_masked_pixels(
/// * `floating` - The floating selection transform containing pixels to composite
/// * `canvas_w` - Width of the canvas in pixels
/// * `canvas_h` - Height of the canvas in pixels
#[allow(dead_code)]
pub fn composite_floating_to_layer(
layer_pixels: &mut [u8],
floating: &SelectionTransform,
@@ -339,6 +339,7 @@ impl TransformHandle {
];
/// Get cursor icon hint for this handle.
#[allow(dead_code)]
pub fn cursor_hint(&self) -> &'static str {
match self {
TransformHandle::None => "default",
@@ -22,6 +22,7 @@ use iced::{Element, Length};
pub struct ToolSlot {
pub tools: &'static [Tool],
pub label: &'static str,
#[allow(dead_code)]
pub icon: &'static str,
}
@@ -39,6 +39,7 @@ impl ThemeState {
/// **Returns:** The preset used to produce the current color tokens.
/// **Logic & Workflow:** Reads the preset from the same lock as its resolved colors.
/// **Side Effects / Dependencies:** Briefly locks shared theme state.
#[allow(dead_code)]
pub fn preset(&self) -> ThemePreset {
self.state.lock().unwrap().0
}
@@ -183,6 +183,7 @@ impl ViewerState {
}
/// Get subdirectories of a path, cached to avoid repeated disk reads.
#[allow(dead_code)]
pub fn get_subdirs(&mut self, path: &Path) -> &[PathBuf] {
if !self.subdirs_cache.contains_key(path) {
let mut dirs = Vec::new();
@@ -154,6 +154,7 @@ fn thumbnail_card(
}
/// Count how many thumbnails need loading.
#[allow(dead_code)]
pub fn count_unloaded(state: &ViewerState) -> usize {
state
.images
@@ -0,0 +1,10 @@
//! Reusable iced widgets for the HCIE GUI.
//!
//! Contains the keyboard-editable `PlainSlider`, mirroring the egui version
//! used elsewhere in the project.
#[allow(deprecated)]
pub mod plain_slider;
#[allow(deprecated)]
pub use plain_slider::plain_slider;
@@ -0,0 +1,334 @@
//! A keyboard-editable numeric slider widget for iced.
//!
//! Mirrors the egui `PlainSlider` used in the egui GUI so the iced
//! Properties panel supports direct value entry from the keyboard.
//! The widget is implemented as an iced `Component`, which keeps the
//! transient edit state inside the widget instead of in the application
//! state. This trait is deprecated in iced 0.13, but it is still the
//! simplest way to bundle local state with a reusable widget.
#![allow(deprecated)]
use iced::widget::{column, component, mouse_area, row, slider, text, text_input, Component};
use iced::{Element, Length, Renderer, Theme};
use std::sync::Arc;
/// Internal state for a `PlainSlider` instance.
#[derive(Debug, Default, Clone)]
pub(crate) struct State {
/// Whether the user is currently typing a value.
editing: bool,
/// Current contents of the text input while editing.
buffer: String,
}
/// Internal events produced by the widget UI.
#[derive(Debug, Clone)]
pub(crate) enum Event {
/// The slider thumb was dragged to a new value.
SliderChanged(f32),
/// The value label was clicked; switch to text-input mode.
StartEdit,
/// The text input contents changed.
InputChanged(String),
/// The user pressed Enter or otherwise submitted the typed value.
Submit,
}
/// A numeric slider with an editable value label.
///
/// The value label displays the current value with a suffix such as `%`
/// or `px`. Clicking the label replaces it with a text input so the user
/// can type an exact value.
#[derive(Clone)]
pub(crate) struct PlainSlider<Message> {
/// Human-readable label shown to the left of the value.
label: String,
/// Current value. The parent application owns the authoritative value
/// and passes it back on each frame.
value: f32,
/// Inclusive numeric range the value is allowed to occupy.
range: std::ops::RangeInclusive<f32>,
/// Step size used by the slider thumb.
step: f32,
/// Suffix shown after the value (e.g. "%", "px").
suffix: String,
/// Number of decimal places to display.
decimals: usize,
/// Width of the value label / text input area.
input_width: f32,
/// Callback invoked whenever the value changes, either by dragging
/// the slider or by submitting the text input.
#[allow(clippy::type_complexity)]
on_change: Arc<dyn Fn(f32) -> Message>,
}
impl<Message> std::fmt::Debug for PlainSlider<Message> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlainSlider")
.field("label", &self.label)
.field("value", &self.value)
.field("range", &self.range)
.field("step", &self.step)
.field("suffix", &self.suffix)
.field("decimals", &self.decimals)
.field("input_width", &self.input_width)
.field("on_change", &"Arc<dyn Fn(f32) -> Message>")
.finish()
}
}
impl<Message> PlainSlider<Message> {
/// Creates a new editable slider.
///
/// # Arguments
/// * `label` - short label shown next to the value.
/// * `value` - current numeric value.
/// * `range` - inclusive allowed range.
/// * `step` - slider step.
/// * `suffix` - unit suffix (e.g. "%", "px").
/// * `decimals` - number of decimals to display.
/// * `on_change` - callback producing a message for the parent.
pub fn new(
label: impl Into<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
decimals: usize,
on_change: impl Fn(f32) -> Message + 'static,
) -> Self {
Self {
label: label.into(),
value,
range,
step,
suffix: suffix.into(),
decimals,
input_width: 48.0,
on_change: Arc::new(on_change),
}
}
/// Sets the width of the editable value area.
#[allow(dead_code)]
pub fn input_width(mut self, width: f32) -> Self {
self.input_width = width;
self
}
/// Formats a numeric value for display, including the suffix.
///
/// Percentage values are stored normalized (0..1) and displayed
/// multiplied by 100. Other units are displayed as-is.
fn format_value(&self, value: f32) -> String {
if self.suffix == "%" {
format!("{:.*}{}", self.decimals, value * 100.0, self.suffix)
} else if self.suffix.is_empty() {
format!("{:.*}", self.decimals, value)
} else {
format!("{:.*} {}", self.decimals, value, self.suffix)
}
}
/// Parses a raw string back into a numeric value, taking `%` into account.
fn parse_value(&self, raw: &str) -> Option<f32> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let numeric = trimmed
.trim_end_matches(&self.suffix)
.trim()
.replace(',', ".");
let parsed: f32 = numeric.parse().ok()?;
let value = if self.suffix == "%" { parsed / 100.0 } else { parsed };
Some(value.clamp(*self.range.start(), *self.range.end()))
}
}
impl<Message> Component<Message, Theme, Renderer> for PlainSlider<Message>
where
Message: Clone,
{
type State = State;
type Event = Event;
fn update(&mut self, state: &mut State, event: Event) -> Option<Message> {
match event {
Event::SliderChanged(new_value) => {
// Update the local value so the displayed text follows the thumb.
self.value = new_value.clamp(*self.range.start(), *self.range.end());
Some((self.on_change)(self.value))
}
Event::StartEdit => {
state.editing = true;
state.buffer = self.format_value(self.value);
None
}
Event::InputChanged(new_value) => {
state.buffer = new_value;
None
}
Event::Submit => {
if let Some(v) = self.parse_value(&state.buffer) {
self.value = v;
state.editing = false;
state.buffer.clear();
Some((self.on_change)(v))
} else {
state.editing = false;
state.buffer.clear();
None
}
}
}
}
fn view(&self, state: &State) -> Element<'_, Event> {
let value_text = self.format_value(self.value);
let slider_control = slider(self.range.clone(), self.value, Event::SliderChanged)
.step(self.step)
.width(Length::Fill);
if state.editing {
let input = text_input("", &state.buffer)
.width(Length::Fixed(self.input_width))
.size(12)
.on_input(Event::InputChanged)
.on_submit(Event::Submit);
let editor = row![input]
.align_y(iced::Alignment::Center)
.spacing(4);
column![
row![
text(&self.label).size(12).width(Length::Shrink),
editor,
]
.spacing(4)
.align_y(iced::Alignment::Center),
slider_control,
]
.spacing(2)
.into()
} else {
let value_label = mouse_area(
text(value_text)
.size(12)
.width(Length::Fixed(self.input_width))
.align_x(iced::alignment::Horizontal::Right),
)
.on_press(Event::StartEdit);
column![
row![
text(&self.label).size(12).width(Length::Shrink),
value_label,
]
.spacing(4)
.align_y(iced::Alignment::Center),
slider_control,
]
.spacing(2)
.into()
}
}
}
/// Convenience constructor that wraps `PlainSlider` in a lazy component element.
///
/// Use this from panel code in place of the standard `slider` widget.
pub fn plain_slider<Message>(
label: impl Into<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
decimals: usize,
on_change: impl Fn(f32) -> Message + 'static,
) -> Element<'static, Message>
where
Message: 'static + Clone,
{
component(PlainSlider::new(label, value, range, step, suffix, decimals, on_change))
}
#[cfg(test)]
mod tests {
use super::PlainSlider;
use iced::widget::Component;
fn dummy(_: f32) -> () {}
#[test]
fn formats_percentage_without_decimals() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert_eq!(slider.format_value(0.5), "50%");
assert_eq!(slider.format_value(1.0), "100%");
assert_eq!(slider.format_value(0.0), "0%");
}
#[test]
fn formats_pxx_with_decimals() {
let slider = PlainSlider::new("Stroke", 37.0, 0.0..=50.0, 0.5, "px", 1, dummy);
assert_eq!(slider.format_value(37.0), "37.0 px");
}
#[test]
fn parses_percentage_input() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert!((slider.parse_value("75%").unwrap() - 0.75).abs() < 1e-6);
assert!((slider.parse_value("75").unwrap() - 0.75).abs() < 1e-6);
assert_eq!(slider.parse_value(""), None);
}
#[test]
fn parses_px_input() {
let slider = PlainSlider::new("Stroke", 10.0, 0.0..=50.0, 0.5, "px", 1, dummy);
assert!((slider.parse_value("25 px").unwrap() - 25.0).abs() < 1e-6);
assert!((slider.parse_value("25").unwrap() - 25.0).abs() < 1e-6);
}
#[test]
fn clamps_out_of_range_input() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert_eq!(slider.parse_value("150"), Some(1.0));
assert_eq!(slider.parse_value("-10"), Some(0.0));
}
#[test]
fn update_emits_on_slider_drag() {
let mut slider = PlainSlider::new("Size", 10.0, 1.0..=200.0, 1.0, "px", 0, dummy);
let mut state = super::State::default();
let message = slider.update(&mut state, super::Event::SliderChanged(42.0));
assert_eq!(message, Some(()));
assert!((slider.value - 42.0).abs() < 1e-6);
}
#[test]
fn update_commits_valid_text_input() {
let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
let mut state = super::State::default();
slider.update(&mut state, super::Event::StartEdit);
slider.update(&mut state, super::Event::InputChanged("80".to_string()));
let message = slider.update(&mut state, super::Event::Submit);
assert!((slider.value - 0.8).abs() < 1e-6);
assert_eq!(message, Some(()));
assert!(!state.editing);
}
#[test]
fn update_ignores_invalid_text_input() {
let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
let mut state = super::State::default();
slider.update(&mut state, super::Event::StartEdit);
slider.update(&mut state, super::Event::InputChanged("abc".to_string()));
let message = slider.update(&mut state, super::Event::Submit);
assert!((slider.value - 0.5).abs() < 1e-6);
assert_eq!(message, None);
assert!(!state.editing);
}
}