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:
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user