feat(iced): parity improvements for canvas, tools, settings and sidebar
This commit is contained in:
Generated
+1
@@ -2730,6 +2730,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,12 @@ struct OverlayProgram {
|
||||
crop_state: Option<CropState>,
|
||||
/// Marching ants animation offset (0.0..1.0).
|
||||
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).
|
||||
active_tool: hcie_engine_api::Tool,
|
||||
/// Gradient drag preview endpoints in canvas-space.
|
||||
gradient_drag: Option<((f32, f32), (f32, f32))>,
|
||||
}
|
||||
|
||||
/// Overlay state — tracks hover and cursor position for crosshair.
|
||||
@@ -369,6 +375,15 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
let sel_w = sw as f32 * self.zoom;
|
||||
let sel_h = sh as f32 * self.zoom;
|
||||
|
||||
// Semi-transparent fill over the selection bounds so the
|
||||
// selected region is visually distinguished (egui draws a
|
||||
// purple fill over selected pixels at mod.rs:1655).
|
||||
let fill_path = Path::rectangle(
|
||||
Point::new(sel_x, sel_y),
|
||||
Size::new(sel_w, sel_h),
|
||||
);
|
||||
frame.fill(&fill_path, iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12));
|
||||
|
||||
// Calculate dash offset for marching ants animation
|
||||
// The offset moves the dash pattern to create the marching effect
|
||||
let dash_length = 8.0;
|
||||
@@ -405,17 +420,11 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
}
|
||||
|
||||
// Draw vector shape preview
|
||||
// Draw vector shape preview — shape geometry matches the active
|
||||
// tool so the user sees what will be drawn (egui draws the actual
|
||||
// shape outline, not just a bounding box).
|
||||
if let Some(((x0, y0), (x1, y1))) = self.vector_draw {
|
||||
let v_x = origin_x + x0.min(x1) * self.zoom;
|
||||
let v_y = origin_y + y0.min(y1) * self.zoom;
|
||||
let v_w = (x1 - x0).abs() * self.zoom;
|
||||
let v_h = (y1 - y0).abs() * self.zoom;
|
||||
let v_path = Path::rectangle(
|
||||
Point::new(v_x, v_y),
|
||||
Size::new(v_w, v_h),
|
||||
);
|
||||
frame.stroke(&v_path, Stroke {
|
||||
let stroke_style = Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 0.8, 0.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
@@ -423,7 +432,110 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
offset: 0,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match self.active_tool {
|
||||
hcie_engine_api::Tool::VectorCircle => {
|
||||
// Ellipse preview using the bounding box center + radii.
|
||||
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom;
|
||||
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom;
|
||||
let rx = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom;
|
||||
let ry = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom;
|
||||
let ellipse_path = Path::new(|b| {
|
||||
// Approximate ellipse with 48 segments.
|
||||
let segments = 48;
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
|
||||
let px = cx + rx * t.cos();
|
||||
let py = cy + ry * t.sin();
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(px, py));
|
||||
} else {
|
||||
b.line_to(Point::new(px, py));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&ellipse_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::Tool::VectorLine => {
|
||||
// Straight line from start to end.
|
||||
let p0 = Point::new(origin_x + x0 * self.zoom, origin_y + y0 * self.zoom);
|
||||
let p1 = Point::new(origin_x + x1 * self.zoom, origin_y + y1 * self.zoom);
|
||||
let line_path = Path::line(p0, p1);
|
||||
frame.stroke(&line_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::Tool::VectorStar => {
|
||||
// 5-point star preview (ignores settings points for the preview).
|
||||
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom;
|
||||
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom;
|
||||
let r_out = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom;
|
||||
let r_in = r_out * 0.5;
|
||||
let star_path = Path::new(|b| {
|
||||
for i in 0..10 {
|
||||
let t = (i as f32) * std::f32::consts::PI / 5.0 - std::f32::consts::FRAC_PI_2;
|
||||
let r = if i % 2 == 0 { r_out } else { r_in };
|
||||
let px = cx + r * t.cos();
|
||||
let py = cy + r * t.sin();
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(px, py));
|
||||
} else {
|
||||
b.line_to(Point::new(px, py));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&star_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::Tool::VectorPolygon => {
|
||||
// 6-sided polygon preview.
|
||||
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom;
|
||||
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom;
|
||||
let r = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom;
|
||||
let poly_path = Path::new(|b| {
|
||||
for i in 0..6 {
|
||||
let t = (i as f32) * std::f32::consts::TAU / 6.0 - std::f32::consts::FRAC_PI_2;
|
||||
let px = cx + r * t.cos();
|
||||
let py = cy + r * t.sin();
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(px, py));
|
||||
} else {
|
||||
b.line_to(Point::new(px, py));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&poly_path, stroke_style);
|
||||
}
|
||||
_ => {
|
||||
// Rectangular shapes (Rect, Arrow, Rhombus, etc.) fall
|
||||
// back to a bounding-box outline for the preview.
|
||||
let v_x = origin_x + x0.min(x1) * self.zoom;
|
||||
let v_y = origin_y + y0.min(y1) * self.zoom;
|
||||
let v_w = (x1 - x0).abs() * self.zoom;
|
||||
let v_h = (y1 - y0).abs() * self.zoom;
|
||||
let v_path = Path::rectangle(
|
||||
Point::new(v_x, v_y),
|
||||
Size::new(v_w, v_h),
|
||||
);
|
||||
frame.stroke(&v_path, stroke_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw gradient endpoint preview line.
|
||||
if let Some(((gx0, gy0), (gx1, gy1))) = self.gradient_drag {
|
||||
let p0 = Point::new(origin_x + gx0 * self.zoom, origin_y + gy0 * self.zoom);
|
||||
let p1 = Point::new(origin_x + gx1 * self.zoom, origin_y + gy1 * self.zoom);
|
||||
let g_path = Path::line(p0, p1);
|
||||
frame.stroke(&g_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.2, 0.8, 1.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
..Default::default()
|
||||
});
|
||||
// Endpoint markers.
|
||||
frame.fill(&Path::circle(p0, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0));
|
||||
frame.fill(&Path::circle(p1, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0));
|
||||
}
|
||||
|
||||
// Draw transform handles (when in transform mode)
|
||||
@@ -486,6 +598,13 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pressure indicator HUD ──────────────────────────────────────
|
||||
if self.pressure < 1.0 {
|
||||
let mut pressure_frame = Frame::new(renderer, bounds.size());
|
||||
crate::panels::pressure_indicator::draw(&mut pressure_frame, bounds, self.pressure);
|
||||
geometries.push(pressure_frame.into_geometry());
|
||||
}
|
||||
|
||||
geometries
|
||||
}
|
||||
|
||||
@@ -537,16 +656,18 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
/// 1. **Bottom**: `iced::widget::Shader` — renders checkerboard + composite texture
|
||||
/// via a custom wgpu pipeline. Uses `queue.write_texture()` for partial updates.
|
||||
/// 2. **Top**: `iced::widget::Canvas` — renders selection rects, vector previews,
|
||||
/// and crosshair cursor (transparent overlay, a few lines only).
|
||||
/// crosshair cursor, and pressure indicator HUD (transparent overlay).
|
||||
///
|
||||
/// ## Arguments
|
||||
/// * `doc` — document state (engine, composite pixels, zoom, pan, etc.)
|
||||
/// * `tool_state` — current tool state (for status bar info)
|
||||
/// * `marching_ants_offset` — animation offset for marching ants (0.0..1.0)
|
||||
/// * `pressure` — current pen pressure in [0.0, 1.0] for HUD display
|
||||
pub fn view<'a>(
|
||||
doc: &'a crate::app::IcedDocument,
|
||||
tool_state: &'a crate::app::ToolState,
|
||||
marching_ants_offset: f32,
|
||||
pressure: f32,
|
||||
) -> Element<'a, Message> {
|
||||
let engine_w = doc.engine.canvas_width();
|
||||
let engine_h = doc.engine.canvas_height();
|
||||
@@ -568,7 +689,7 @@ pub fn view<'a>(
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
// ── Overlay canvas (selection, vector preview, crosshair) ────────────
|
||||
// ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ──────
|
||||
let overlay_program = OverlayProgram {
|
||||
engine_w,
|
||||
engine_h,
|
||||
@@ -581,19 +702,75 @@ pub fn view<'a>(
|
||||
active_handle: TransformHandle::None, // TODO: track hover state
|
||||
crop_state: Some(doc.crop_state.clone()),
|
||||
marching_ants_offset,
|
||||
pressure,
|
||||
active_tool: tool_state.active_tool,
|
||||
gradient_drag: doc.gradient_drag,
|
||||
};
|
||||
|
||||
let overlay_canvas = canvas(overlay_program)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
// ── Stack: shader (bottom) + overlay (top) ───────────────────────────
|
||||
let stacked = Stack::new()
|
||||
// ── Stack: shader (bottom) + overlay (top) + text editor (when drafting) ──
|
||||
let mut stacked = Stack::new()
|
||||
.push(shader_canvas)
|
||||
.push(overlay_canvas)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
// On-canvas text editor: a positioned text_input + Commit/Cancel buttons
|
||||
// placed at the draft location so the user can type inline (egui:
|
||||
// text_overlay.rs:32-192). iced's Stack aligns children to the top-left, so
|
||||
// we offset with left/top padding computed from the draft's canvas
|
||||
// position mapped to pane-space.
|
||||
if let Some(draft) = doc.text_draft.as_ref() {
|
||||
let (pane_w, pane_h) = doc.pane_size;
|
||||
let display_w = engine_w as f32 * zoom;
|
||||
let display_h = engine_h as f32 * zoom;
|
||||
let origin_x = (pane_w - display_w) / 2.0 + pan_offset.x;
|
||||
let origin_y = (pane_h - display_h) / 2.0 + pan_offset.y;
|
||||
let text_screen_x = (origin_x + draft.x * zoom).max(4.0);
|
||||
let text_screen_y = (origin_y + draft.y * zoom).max(4.0);
|
||||
|
||||
let editor_bg = |_theme: &iced::Theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.1, 0.1, 0.12, 0.92))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.5, 1.0)).width(1).rounded(3),
|
||||
..Default::default()
|
||||
};
|
||||
let editor = container(
|
||||
column![
|
||||
iced::widget::text_input("Type text here...", &draft.content)
|
||||
.on_input(Message::TextContentChanged)
|
||||
.on_submit(Message::TextCommit)
|
||||
.size(13)
|
||||
.width(Length::Fixed(220.0)),
|
||||
row![
|
||||
iced::widget::button(iced::widget::text("Commit").size(10))
|
||||
.on_press(Message::TextCommit)
|
||||
.padding([2, 8]),
|
||||
iced::widget::button(iced::widget::text("Cancel").size(10))
|
||||
.on_press(Message::TextCancel)
|
||||
.padding([2, 8]),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(4)
|
||||
.style(editor_bg);
|
||||
|
||||
let positioned_editor = container(editor)
|
||||
.width(Length::Shrink)
|
||||
.padding(iced::Padding {
|
||||
top: text_screen_y,
|
||||
bottom: 0.0,
|
||||
left: text_screen_x,
|
||||
right: 0.0,
|
||||
});
|
||||
|
||||
stacked = stacked.push(positioned_editor);
|
||||
}
|
||||
|
||||
// ── Tool info strip (bottom of canvas area) ─────────────────────────
|
||||
let overlay_info = if let Some((x0, y0, x1, y1)) = doc.selection_rect {
|
||||
let w = (x1 - x0).abs() as u32;
|
||||
|
||||
@@ -88,7 +88,7 @@ pub fn view<'a>(
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, 1.0,
|
||||
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
..Default::default()
|
||||
@@ -98,7 +98,7 @@ pub fn view<'a>(
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, 1.0,
|
||||
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
..Default::default()
|
||||
@@ -109,9 +109,13 @@ pub fn view<'a>(
|
||||
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center);
|
||||
|
||||
// ── 2. Hex input ──
|
||||
// Preserve the current alpha channel: the hex field only edits RGB, but the
|
||||
// emitted color keeps the existing alpha so transparency is not silently
|
||||
// destroyed (matching egui's behavior at color.rs:686).
|
||||
let alpha = fg_color[3];
|
||||
let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
|
||||
let hex_input = text_input("#", &hex_str)
|
||||
.on_input(|input| {
|
||||
.on_input(move |input| {
|
||||
let clean = input.trim_start_matches('#').to_uppercase();
|
||||
if clean.len() == 6 {
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||
@@ -119,7 +123,7 @@ pub fn view<'a>(
|
||||
u8::from_str_radix(&clean[2..4], 16),
|
||||
u8::from_str_radix(&clean[4..6], 16),
|
||||
) {
|
||||
return Message::FgColorChanged([r, g, b, 255]);
|
||||
return Message::FgColorChanged([r, g, b, alpha]);
|
||||
}
|
||||
}
|
||||
Message::NoOp
|
||||
@@ -129,6 +133,24 @@ pub fn view<'a>(
|
||||
.size(10);
|
||||
let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center);
|
||||
|
||||
// ── 2b. Alpha slider ──
|
||||
// Allows transparency editing, which egui supports in the layer-style panel
|
||||
// but the iced picker previously stripped entirely. Slider 0..255.
|
||||
let a_val = fg_color[3];
|
||||
let alpha_slider = slider(0..=255u8, a_val, move |v| {
|
||||
Message::FgColorChanged([fg_color[0], fg_color[1], fg_color[2], v])
|
||||
})
|
||||
.width(Length::Fill);
|
||||
let alpha_row = row![
|
||||
text("A").size(10).width(Length::Fixed(12.0)),
|
||||
alpha_slider,
|
||||
text(format!("{:.0}%", a_val as f32 / 255.0 * 100.0))
|
||||
.size(9)
|
||||
.width(Length::Fixed(34.0)),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── 3. Tab bar ──
|
||||
let tab_w = 30;
|
||||
let make_tab = |label: &'static str, idx: usize, color_tab: usize| {
|
||||
@@ -162,7 +184,7 @@ pub fn view<'a>(
|
||||
let mut recent_items: Vec<Element<'_, Message>> = Vec::new();
|
||||
for &color in recent_colors.iter().take(20) {
|
||||
let c = color;
|
||||
let is_selected = [c[0], c[1], c[2], 255] == *fg_color;
|
||||
let is_selected = c == *fg_color;
|
||||
let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114;
|
||||
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
|
||||
|
||||
@@ -179,7 +201,7 @@ pub fn view<'a>(
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0,
|
||||
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, c[3] as f32 / 255.0,
|
||||
))),
|
||||
border: if is_selected {
|
||||
iced::Border::default().color(dot_color).width(1.0).rounded(2)
|
||||
@@ -189,7 +211,7 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
});
|
||||
let clickable = iced::widget::mouse_area(swatch)
|
||||
.on_press(Message::FgColorChanged([c[0], c[1], c[2], 255]))
|
||||
.on_press(Message::FgColorChanged(c))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
recent_items.push(clickable.into());
|
||||
}
|
||||
@@ -203,6 +225,7 @@ pub fn view<'a>(
|
||||
text("Color").size(11).font(iced::Font::MONOSPACE),
|
||||
swatch_row,
|
||||
hex_row,
|
||||
alpha_row,
|
||||
tabs,
|
||||
tab_content,
|
||||
horizontal_rule(1),
|
||||
|
||||
@@ -40,7 +40,8 @@ pub fn dock_view<'a>(
|
||||
let doc_tab_bar = document_tab_bar(app, colors);
|
||||
let canvas = {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset)
|
||||
let pressure = app.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
|
||||
crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset, pressure)
|
||||
};
|
||||
column![doc_tab_bar, canvas]
|
||||
.width(Length::Fill)
|
||||
@@ -105,7 +106,9 @@ pub fn dock_view<'a>(
|
||||
crate::panels::geometry::view(&[], colors)
|
||||
}
|
||||
PaneType::ToolSettings => {
|
||||
crate::panels::tool_settings::view(&app.tool_state, colors)
|
||||
let fg = app.fg_color;
|
||||
let draft = app.documents[app.active_doc].text_draft.as_ref();
|
||||
crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod layer_details;
|
||||
pub mod layer_styles;
|
||||
pub mod layers;
|
||||
pub mod menus;
|
||||
pub mod pressure_indicator;
|
||||
pub mod properties;
|
||||
pub mod script_panel;
|
||||
pub mod status_bar;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Pressure indicator HUD — visual display of pen/tablet pressure.
|
||||
//!
|
||||
//! Renders a horizontal bar showing current pressure during brush strokes.
|
||||
//! The bar fills proportionally and uses color coding:
|
||||
//! - Red: low pressure (< 30%)
|
||||
//! - Yellow: medium pressure (30-70%)
|
||||
//! - Green: high pressure (> 70%)
|
||||
//!
|
||||
//! Positioned in the bottom-left corner of the canvas viewport.
|
||||
|
||||
use iced::widget::canvas::{Frame, Path, Stroke};
|
||||
use iced::Color;
|
||||
|
||||
/// Bar dimensions for the pressure indicator.
|
||||
const BAR_WIDTH: f32 = 120.0;
|
||||
const BAR_HEIGHT: f32 = 10.0;
|
||||
const BAR_MARGIN: f32 = 8.0;
|
||||
|
||||
/// Draw the pressure indicator HUD onto the canvas frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `frame` — Canvas frame to draw onto
|
||||
/// * `bounds` — Canvas bounds for positioning
|
||||
/// * `pressure` — Current pressure value in [0.0, 1.0]
|
||||
pub fn draw(frame: &mut Frame, bounds: iced::Rectangle, pressure: f32) {
|
||||
let p = pressure.clamp(0.0, 1.0);
|
||||
if p >= 1.0 {
|
||||
// Don't show indicator at full pressure (mouse input)
|
||||
return;
|
||||
}
|
||||
|
||||
let x = BAR_MARGIN;
|
||||
let y = bounds.height - BAR_HEIGHT - BAR_MARGIN;
|
||||
|
||||
// Background bar
|
||||
let bg_path = Path::rectangle(
|
||||
iced::Point::new(x, y),
|
||||
iced::Size::new(BAR_WIDTH, BAR_HEIGHT),
|
||||
);
|
||||
frame.fill(&bg_path, Color::from_rgba(0.15, 0.15, 0.15, 0.8));
|
||||
|
||||
// Filled portion
|
||||
let fill_width = BAR_WIDTH * p;
|
||||
if fill_width > 0.5 {
|
||||
let fill_color = if p < 0.3 {
|
||||
Color::from_rgb(0.86, 0.31, 0.31) // Red
|
||||
} else if p < 0.7 {
|
||||
Color::from_rgb(0.86, 0.71, 0.24) // Yellow
|
||||
} else {
|
||||
Color::from_rgb(0.31, 0.78, 0.47) // Green
|
||||
};
|
||||
|
||||
let fill_path = Path::rectangle(
|
||||
iced::Point::new(x, y),
|
||||
iced::Size::new(fill_width, BAR_HEIGHT),
|
||||
);
|
||||
frame.fill(&fill_path, fill_color);
|
||||
}
|
||||
|
||||
// Border
|
||||
frame.stroke(
|
||||
&bg_path,
|
||||
Stroke {
|
||||
style: iced::widget::canvas::stroke::Style::Solid(Color::from_rgba(0.3, 0.3, 0.3, 0.8)),
|
||||
width: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,56 @@
|
||||
//! Tool Settings panel — shows all properties for the active tool.
|
||||
//!
|
||||
//! **Purpose:** Right-side panel showing tool-specific settings like the reference images.
|
||||
//! - Brush: Size, Opacity, Hardness, Flow, Spacing, Angle, Roundness
|
||||
//! - Eraser: Size, Opacity, Hardness, Flow
|
||||
//! - Pen: Size, Opacity, Flow
|
||||
//! - Vector: Fill, Stroke, Stroke Width, Opacity
|
||||
//! - Brush: Size, Opacity, Hardness, Flow, Spacing
|
||||
//! - Eraser: Size, Opacity, Hardness
|
||||
//! - Vector: Fill, Stroke Width, Opacity, Radius/Points/Sides (shape-specific)
|
||||
//! - Select: Feather, Anti-alias
|
||||
//! - Text: Font, Size, Color, Bold, Italic
|
||||
//! - Magic Wand: Tolerance, Anti-alias, Contiguous
|
||||
//! - Text: Font, Size, Angle, Color, Alignment, Orientation
|
||||
//! - Gradient: Type, Opacity
|
||||
//!
|
||||
//! All controls emit real Messages wired to `app.rs` handlers; no `NoOp` remains.
|
||||
|
||||
use crate::app::{Message, ToolState};
|
||||
use crate::panels::styles;
|
||||
use crate::settings::AppSettings;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text};
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the tool settings panel for the active tool.
|
||||
pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
|
||||
pub fn view<'a>(
|
||||
tool_state: &'a ToolState,
|
||||
settings: &'a AppSettings,
|
||||
fg_color: [u8; 4],
|
||||
text_draft: Option<&'a crate::app::TextDraft>,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let content = match tool_state.active_tool {
|
||||
// ── Brush / Eraser / Pen / Spray ──
|
||||
Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => {
|
||||
brush_settings(tool_state, colors)
|
||||
}
|
||||
// ── Vector tools ──
|
||||
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle |
|
||||
Tool::VectorArrow | Tool::VectorStar | Tool::VectorPolygon => {
|
||||
vector_settings(colors)
|
||||
}
|
||||
// ── Select tools ──
|
||||
Tool::Select | Tool::Lasso | Tool::PolygonSelect => {
|
||||
select_settings(colors)
|
||||
}
|
||||
// ── Magic Wand ──
|
||||
Tool::MagicWand => magic_wand_settings(colors),
|
||||
// ── Text ──
|
||||
Tool::Text => text_settings(colors),
|
||||
// ── Move ──
|
||||
Tool::Move => move_settings(colors),
|
||||
// ── Gradient / FloodFill ──
|
||||
Tool::Gradient | Tool::FloodFill => fill_settings(colors),
|
||||
// ── Default ──
|
||||
Tool::Brush | Tool::Eraser | Tool::Pen => brush_settings(tool_state, settings, colors),
|
||||
Tool::Spray => spray_settings(tool_state, settings, colors),
|
||||
Tool::VectorLine
|
||||
| Tool::VectorRect
|
||||
| Tool::VectorCircle
|
||||
| Tool::VectorArrow
|
||||
| Tool::VectorStar
|
||||
| Tool::VectorPolygon
|
||||
| Tool::VectorRhombus
|
||||
| Tool::VectorCylinder
|
||||
| Tool::VectorHeart
|
||||
| Tool::VectorBubble
|
||||
| Tool::VectorGear
|
||||
| Tool::VectorCross
|
||||
| Tool::VectorCrescent
|
||||
| Tool::VectorBolt
|
||||
| Tool::VectorArrow4 => vector_settings(tool_state, settings, colors),
|
||||
Tool::Select | Tool::Lasso | Tool::PolygonSelect => select_settings(settings, colors),
|
||||
Tool::MagicWand | Tool::SmartSelect => magic_wand_settings(settings, colors),
|
||||
Tool::Text => text_settings(settings, fg_color, text_draft, colors),
|
||||
Tool::Move => move_settings(settings, colors),
|
||||
Tool::Gradient => gradient_settings(settings, colors),
|
||||
Tool::FloodFill => flood_fill_settings(settings, colors),
|
||||
_ => column![text("No settings").size(11)].spacing(4),
|
||||
};
|
||||
|
||||
@@ -63,95 +74,199 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
.into()
|
||||
}
|
||||
|
||||
fn brush_settings(ts: &ToolState, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let size = ts.brush_size;
|
||||
let opacity = ts.brush_opacity;
|
||||
let hardness = ts.brush_hardness;
|
||||
|
||||
fn brush_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Brush Settings", c),
|
||||
prop_slider("Size", size, 1.0, 200.0, Message::BrushSizeChanged, c),
|
||||
prop_slider("Opacity", opacity * 100.0, 0.0, 100.0, Message::BrushOpacityChanged, c),
|
||||
prop_slider("Hardness", hardness * 100.0, 0.0, 100.0, Message::BrushHardnessChanged, c),
|
||||
prop_slider("Flow", 100.0, 0.0, 100.0, |_v| Message::NoOp, c),
|
||||
prop_slider("Spacing", 25.0, 1.0, 100.0, |_v| Message::NoOp, c),
|
||||
prop_slider("Angle", 0.0, 0.0, 360.0, |_v| Message::NoOp, c),
|
||||
prop_slider("Roundness", 100.0, 1.0, 100.0, |_v| Message::NoOp, c),
|
||||
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
|
||||
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
|
||||
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
|
||||
prop_slider("Flow", ts2.brush_flow * 100.0, 0.0, 100.0, |v| Message::BrushFlowChanged(v / 100.0), c),
|
||||
prop_slider("Spacing", ts.brush_spacing * 100.0, 1.0, 100.0, |v| Message::BrushSpacingChanged(v / 100.0), c),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn vector_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn spray_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Vector Settings", c),
|
||||
color_row("Fill:", [0, 0, 0, 255], c),
|
||||
color_row("Stroke:", [255, 255, 255, 255], c),
|
||||
prop_slider("Stroke Width", 1.0, 0.0, 20.0, |_v| Message::NoOp, c),
|
||||
prop_slider("Opacity", 100.0, 0.0, 100.0, |_v| Message::NoOp, c),
|
||||
prop_row("Corner:", "Miter", c),
|
||||
prop_row("Cap:", "Butt", c),
|
||||
section_header("Spray Settings", c),
|
||||
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
|
||||
prop_slider("Particle Size", ts2.spray_particle_size, 1.0, 20.0, Message::SprayParticleSizeChanged, c),
|
||||
prop_slider("Density", ts2.spray_density as f32, 10.0, 1000.0, |v| Message::SprayDensityChanged(v as u32), c),
|
||||
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
|
||||
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn select_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
let mut col = column![
|
||||
section_header("Vector Settings", c),
|
||||
prop_slider("Stroke Width", ts2.vector_stroke_width, 0.0, 50.0, Message::VectorStrokeChanged, c),
|
||||
prop_slider("Opacity", ts2.vector_opacity * 100.0, 0.0, 100.0, |v| Message::VectorOpacityChanged(v / 100.0), c),
|
||||
iced::widget::checkbox("Fill", ts2.vector_fill)
|
||||
.on_toggle(Message::VectorFillToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
// Shape-specific controls.
|
||||
match ts.active_tool {
|
||||
Tool::VectorRect => {
|
||||
col = col.push(prop_slider("Corner Radius", ts2.vector_radius, 0.0, 100.0, Message::VectorRadiusChanged, c));
|
||||
}
|
||||
Tool::VectorStar => {
|
||||
col = col.push(prop_slider("Points", ts2.vector_points as f32, 3.0, 20.0, |v| Message::VectorPointsChanged(v as u32), c));
|
||||
}
|
||||
Tool::VectorPolygon => {
|
||||
col = col.push(prop_slider("Sides", ts2.vector_sides as f32, 3.0, 12.0, |v| Message::VectorSidesChanged(v as u32), c));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
col
|
||||
}
|
||||
|
||||
fn select_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Selection Settings", c),
|
||||
prop_slider("Feather", 0.0, 0.0, 250.0, |_v| Message::NoOp, c),
|
||||
prop_checkbox("Anti-alias", true, c),
|
||||
prop_slider("Feather", ts2.select_feather, 0.0, 250.0, Message::SelectionFeatherChanged, c),
|
||||
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
|
||||
.on_toggle(|_v| Message::NoOp)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn magic_wand_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn magic_wand_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Magic Wand Settings", c),
|
||||
prop_slider("Tolerance", 16.0, 0.0, 255.0, |_v| Message::NoOp, c),
|
||||
prop_checkbox("Anti-alias", true, c),
|
||||
prop_checkbox("Contiguous", true, c),
|
||||
prop_checkbox("Sample All Layers", false, c),
|
||||
prop_slider("Tolerance", ts2.magic_wand_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
|
||||
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
|
||||
.on_toggle(Message::SelectionContiguousToggled)
|
||||
.size(11),
|
||||
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
|
||||
.on_toggle(|_v| Message::NoOp)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn text_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn text_settings(
|
||||
settings: &AppSettings,
|
||||
fg_color: [u8; 4],
|
||||
text_draft: Option<&crate::app::TextDraft>,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
let draft_font = text_draft.map(|d| d.font.clone()).unwrap_or_else(|| ts2.text_font.clone());
|
||||
let draft_size = text_draft.map(|d| d.size).unwrap_or(ts2.text_size);
|
||||
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
|
||||
.map(|d| format!("{:?}", d.alignment))
|
||||
.unwrap_or_else(|| "Left".to_string());
|
||||
let orientation = text_draft
|
||||
.map(|d| format!("{:?}", d.orientation))
|
||||
.unwrap_or_else(|| "Horizontal".to_string());
|
||||
|
||||
column![
|
||||
section_header("Text Settings", c),
|
||||
prop_row("Font:", "Arial", c),
|
||||
prop_slider("Size", 24.0, 1.0, 200.0, |_v| Message::NoOp, c),
|
||||
prop_row("Color:", "#000000", c),
|
||||
prop_checkbox("Bold", false, c),
|
||||
prop_checkbox("Italic", false, c),
|
||||
prop_checkbox("Underline", false, c),
|
||||
row![
|
||||
text("Font:").size(10).width(Length::Fixed(50.0)),
|
||||
text_input("Font", &draft_font)
|
||||
.on_input(Message::TextFontChanged)
|
||||
.size(10)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
prop_slider("Size", draft_size, 4.0, 200.0, Message::TextSizeChanged, c),
|
||||
prop_slider("Angle", draft_angle, -180.0, 180.0, Message::TextAngleChanged, c),
|
||||
color_row("Color:", draft_color, c),
|
||||
row![
|
||||
text("Content:").size(10).width(Length::Fixed(50.0)),
|
||||
text_input("Text", &draft_content)
|
||||
.on_input(Message::TextContentChanged)
|
||||
.size(10)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![
|
||||
text("Align:").size(10).width(Length::Fixed(50.0)),
|
||||
button(text("Left").size(9)).on_press(Message::TextAlignChanged("Left".to_string())).padding([2, 4]),
|
||||
button(text("Center").size(9)).on_press(Message::TextAlignChanged("Center".to_string())).padding([2, 4]),
|
||||
button(text("Right").size(9)).on_press(Message::TextAlignChanged("Right".to_string())).padding([2, 4]),
|
||||
]
|
||||
.spacing(3),
|
||||
row![
|
||||
text("Orient:").size(10).width(Length::Fixed(50.0)),
|
||||
button(text("H").size(9)).on_press(Message::TextOrientationChanged("Horizontal".to_string())).padding([2, 6]),
|
||||
button(text("V").size(9)).on_press(Message::TextOrientationChanged("Vertical".to_string())).padding([2, 6]),
|
||||
]
|
||||
.spacing(3),
|
||||
row![
|
||||
button(text("Commit").size(10)).on_press(Message::TextCommit).padding([4, 10]),
|
||||
button(text("Cancel").size(10)).on_press(Message::TextCancel).padding([4, 10]),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn move_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn move_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let _ = settings;
|
||||
column![
|
||||
section_header("Move Settings", c),
|
||||
prop_checkbox("Auto-Select", true, c),
|
||||
iced::widget::checkbox("Apply to New Layer", true)
|
||||
.on_toggle(Message::ToolApplyToNewLayerToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn fill_settings(c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
let gtype = ts2.gradient_type;
|
||||
column![
|
||||
section_header("Fill Settings", c),
|
||||
prop_row("Mode:", "Normal", c),
|
||||
prop_slider("Opacity", 100.0, 0.0, 100.0, |_v| Message::NoOp, c),
|
||||
prop_checkbox("Contiguous", true, c),
|
||||
section_header("Gradient Settings", c),
|
||||
row![
|
||||
text("Type:").size(10).width(Length::Fixed(40.0)),
|
||||
button(text("Linear").size(9))
|
||||
.on_press(Message::GradientTypeChanged(0))
|
||||
.padding([2, 6]),
|
||||
button(text("Radial").size(9))
|
||||
.on_press(Message::GradientTypeChanged(1))
|
||||
.padding([2, 6]),
|
||||
]
|
||||
.spacing(3),
|
||||
prop_slider("Opacity", ts2.gradient_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
|
||||
text(if gtype == 1 { "Radial" } else { "Linear" }).size(9),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn flood_fill_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Flood Fill Settings", c),
|
||||
prop_slider("Tolerance", ts2.flood_fill_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
|
||||
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
|
||||
.on_toggle(Message::SelectionContiguousToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn section_header(label: &str, _c: ThemeColors) -> Element<'static, Message> {
|
||||
let l = label.to_string();
|
||||
row![
|
||||
text(l).size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
row![text(l).size(11)].spacing(4).into()
|
||||
}
|
||||
|
||||
fn prop_slider<F>(label: &str, value: f32, min: f32, max: f32, msg_fn: F, _c: ThemeColors) -> Element<'static, Message>
|
||||
@@ -161,9 +276,7 @@ where
|
||||
let l = label.to_string();
|
||||
let label_text = text(l).size(10).width(Length::Fixed(70.0));
|
||||
let val_text = text(format!("{:.1}", value)).size(10).width(Length::Fixed(40.0));
|
||||
let sl = slider(min..=max, value, msg_fn)
|
||||
.step(0.1)
|
||||
.width(Length::Fill);
|
||||
let sl = slider(min..=max, value, msg_fn).step(0.1).width(Length::Fill);
|
||||
|
||||
row![label_text, sl, val_text]
|
||||
.spacing(4)
|
||||
@@ -171,28 +284,6 @@ where
|
||||
.into()
|
||||
}
|
||||
|
||||
fn prop_row(label: &str, value: &str, _c: ThemeColors) -> Element<'static, Message> {
|
||||
let l = label.to_string();
|
||||
let v = value.to_string();
|
||||
row![
|
||||
text(l).size(10).width(Length::Fixed(70.0)),
|
||||
text(v).size(10),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn prop_checkbox(label: &str, checked: bool, _c: ThemeColors) -> Element<'static, Message> {
|
||||
let mark = if checked { "☑" } else { "☐" }.to_string();
|
||||
let l = label.to_string();
|
||||
row![
|
||||
text(mark).size(10),
|
||||
text(l).size(10),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, Message> {
|
||||
let l = label.to_string();
|
||||
let swatch = container(text(""))
|
||||
@@ -216,4 +307,4 @@ fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, M
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use iced::widget::{button, container, row, slider, svg, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row.
|
||||
pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<'_, Message> {
|
||||
pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::settings::AppSettings, colors: ThemeColors) -> Element<'a, Message> {
|
||||
let c = colors;
|
||||
|
||||
// ── SVG icon buttons (no text) ──
|
||||
@@ -37,7 +37,10 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
|
||||
.width(Length::Fixed(60.0));
|
||||
|
||||
let flow_label = text("Flow:").size(10).color(c.text_secondary);
|
||||
let flow_value = text("100%").size(10).color(c.text_primary);
|
||||
let flow_value = text(format!("{:.0}%", settings.tool_settings.brush_flow * 100.0)).size(10).color(c.text_primary);
|
||||
let flow_sl = slider(0.0..=1.0, settings.tool_settings.brush_flow, Message::BrushFlowChanged)
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(60.0));
|
||||
|
||||
let smooth_label = text("Smooth:").size(10).color(c.text_secondary);
|
||||
let smooth_value = text("0%").size(10).color(c.text_primary);
|
||||
@@ -47,7 +50,7 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
|
||||
sep(c),
|
||||
opacity_label, opacity_value, opacity_sl,
|
||||
sep(c),
|
||||
flow_label, flow_value,
|
||||
flow_label, flow_value, flow_sl,
|
||||
sep(c),
|
||||
smooth_label, smooth_value,
|
||||
]
|
||||
@@ -72,28 +75,36 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
|
||||
row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::MagicWand => {
|
||||
let tol = settings.tool_settings.magic_wand_tolerance;
|
||||
row![
|
||||
text("Tolerance:").size(10),
|
||||
text("16").size(10).width(Length::Fixed(30.0)),
|
||||
text("Anti-alias").size(10),
|
||||
text("Contiguous").size(10),
|
||||
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(v as u8))
|
||||
.step(1.0)
|
||||
.width(Length::Fixed(80.0)),
|
||||
text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)),
|
||||
].spacing(6).align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::Select | Tool::Lasso => {
|
||||
let feather = settings.tool_settings.select_feather;
|
||||
row![
|
||||
text("Feather:").size(10),
|
||||
text("0 px").size(10).width(Length::Fixed(40.0)),
|
||||
small_btn("Refine Edge", c),
|
||||
small_btn("Select Subject", c),
|
||||
slider(0.0..=250.0, feather, Message::SelectionFeatherChanged)
|
||||
.step(1.0)
|
||||
.width(Length::Fixed(80.0)),
|
||||
text(format!("{:.0}px", feather)).size(10).width(Length::Fixed(40.0)),
|
||||
].spacing(6).align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::Move => {
|
||||
row![text("Auto-Select").size(10)].spacing(4).align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
|
||||
let stroke = settings.tool_settings.vector_stroke_width;
|
||||
row![
|
||||
text("Fill:").size(10), text("None").size(10),
|
||||
text("Stroke:").size(10), text("1px").size(10),
|
||||
text("Stroke:").size(10),
|
||||
slider(0.0..=50.0, stroke, Message::VectorStrokeChanged)
|
||||
.step(0.5)
|
||||
.width(Length::Fixed(60.0)),
|
||||
text(format!("{:.1}px", stroke)).size(10).width(Length::Fixed(40.0)),
|
||||
].spacing(4).align_y(iced::Alignment::Center)
|
||||
}
|
||||
_ => row![].spacing(0),
|
||||
|
||||
@@ -59,9 +59,55 @@ pub struct ToolSettings {
|
||||
pub text_size: f32,
|
||||
pub vector_stroke_width: f32,
|
||||
pub vector_opacity: f32,
|
||||
/// Whether vector shapes are filled (true) or outlined only (false).
|
||||
#[serde(default = "default_vector_fill")]
|
||||
pub vector_fill: bool,
|
||||
/// Corner radius for VectorRect shapes.
|
||||
#[serde(default)]
|
||||
pub vector_radius: f32,
|
||||
/// Number of points for VectorStar shapes.
|
||||
#[serde(default = "default_vector_points")]
|
||||
pub vector_points: u32,
|
||||
/// Number of sides for VectorPolygon shapes.
|
||||
#[serde(default = "default_vector_sides")]
|
||||
pub vector_sides: u32,
|
||||
/// Spray particle size (size of each particle dot).
|
||||
#[serde(default = "default_spray_particle_size")]
|
||||
pub spray_particle_size: f32,
|
||||
/// Spray density (number of particles per dab).
|
||||
#[serde(default = "default_spray_density")]
|
||||
pub spray_density: u32,
|
||||
/// Gradient type: 0 = linear, 1 = radial.
|
||||
#[serde(default)]
|
||||
pub gradient_type: u32,
|
||||
/// Selection contiguous flag (magic wand / flood fill).
|
||||
#[serde(default = "default_contiguous")]
|
||||
pub selection_contiguous: bool,
|
||||
/// Anti-alias flag for selection tools.
|
||||
#[serde(default = "default_anti_alias")]
|
||||
pub selection_anti_alias: bool,
|
||||
/// Last font used by the Text tool.
|
||||
#[serde(default = "default_text_font")]
|
||||
pub text_font: String,
|
||||
/// Text angle in degrees.
|
||||
#[serde(default)]
|
||||
pub text_angle: f32,
|
||||
/// Whether the Text tool commits to a new layer each time.
|
||||
#[serde(default = "default_apply_to_new_layer")]
|
||||
pub tool_apply_to_new_layer: bool,
|
||||
pub last_active_tool: String,
|
||||
}
|
||||
|
||||
fn default_vector_fill() -> bool { true }
|
||||
fn default_vector_points() -> u32 { 5 }
|
||||
fn default_vector_sides() -> u32 { 6 }
|
||||
fn default_spray_particle_size() -> f32 { 4.0 }
|
||||
fn default_spray_density() -> u32 { 80 }
|
||||
fn default_contiguous() -> bool { true }
|
||||
fn default_anti_alias() -> bool { true }
|
||||
fn default_text_font() -> String { "Arial".to_string() }
|
||||
fn default_apply_to_new_layer() -> bool { true }
|
||||
|
||||
/// Panel layout positions and visibility.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PanelLayout {
|
||||
@@ -115,6 +161,18 @@ impl Default for ToolSettings {
|
||||
text_size: 24.0,
|
||||
vector_stroke_width: 1.0,
|
||||
vector_opacity: 1.0,
|
||||
vector_fill: true,
|
||||
vector_radius: 0.0,
|
||||
vector_points: 5,
|
||||
vector_sides: 6,
|
||||
spray_particle_size: 4.0,
|
||||
spray_density: 80,
|
||||
gradient_type: 0,
|
||||
selection_contiguous: true,
|
||||
selection_anti_alias: true,
|
||||
text_font: "Arial".to_string(),
|
||||
text_angle: 0.0,
|
||||
tool_apply_to_new_layer: true,
|
||||
last_active_tool: "Brush".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,15 +19,15 @@ use iced::widget::{button, column, container, row, svg, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure.
|
||||
struct ToolSlot {
|
||||
tools: &'static [Tool],
|
||||
label: &'static str,
|
||||
icon: &'static str,
|
||||
pub struct ToolSlot {
|
||||
pub tools: &'static [Tool],
|
||||
pub label: &'static str,
|
||||
pub icon: &'static str,
|
||||
}
|
||||
|
||||
/// All tool slots matching egui's TOOL_SLOTS structure exactly.
|
||||
/// Each slot can have multiple tools (sub-tools shown on click).
|
||||
const TOOL_SLOTS: &[ToolSlot] = &[
|
||||
pub const TOOL_SLOTS: &[ToolSlot] = &[
|
||||
ToolSlot { tools: &[Tool::Move], label: "Move", icon: "icons/move.svg" },
|
||||
ToolSlot { tools: &[Tool::VectorSelect], label: "Vector Select", icon: "icons/vector_select.svg" },
|
||||
ToolSlot { tools: &[Tool::Select], label: "Rectangle Select", icon: "icons/rect_select.svg" },
|
||||
@@ -71,11 +71,12 @@ pub fn view<'a>(
|
||||
colors: ThemeColors,
|
||||
active_slot: usize,
|
||||
sub_tools_open: bool,
|
||||
slot_last_used: &'a [usize],
|
||||
) -> Element<'a, Message> {
|
||||
// Tool buttons — single column, with sub-tools popup support
|
||||
let mut tool_list = column![].spacing(1);
|
||||
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
|
||||
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open);
|
||||
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used);
|
||||
tool_list = tool_list.push(btn);
|
||||
}
|
||||
|
||||
@@ -106,6 +107,34 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Make the background swatch clickable so a click promotes the background
|
||||
// color to the foreground (egui: toolbox.rs:355-358).
|
||||
let bg_color_val = *bg_color;
|
||||
let bg_clickable = iced::widget::mouse_area(bg_swatch)
|
||||
.on_press(Message::FgColorChanged(bg_color_val));
|
||||
|
||||
// Small reset-to-defaults button (black/white) below the swatches
|
||||
// (egui: toolbox.rs:351-354 resets fg=black/bg=white on a bottom-left click).
|
||||
let reset_btn = button(text("D").size(7).color(colors.text_secondary))
|
||||
.on_press(Message::ResetColors)
|
||||
.padding(1)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap)
|
||||
// with a swap arrow button between them
|
||||
let color_section = container(
|
||||
@@ -115,7 +144,7 @@ pub fn view<'a>(
|
||||
.padding(iced::Padding { top: 0.0, bottom: 0.0, left: 4.0, right: 0.0 })
|
||||
)
|
||||
.push(
|
||||
container(bg_swatch)
|
||||
container(bg_clickable)
|
||||
.padding(iced::Padding { top: 10.0, bottom: 0.0, left: 16.0, right: 0.0 })
|
||||
)
|
||||
.push(
|
||||
@@ -157,6 +186,7 @@ pub fn view<'a>(
|
||||
container(tool_list).padding([2, 0]),
|
||||
container(text("").height(1)).width(Length::Fill).style(sep_style),
|
||||
color_section,
|
||||
container(reset_btn).padding([2, 4]),
|
||||
]
|
||||
.width(36)
|
||||
.spacing(0);
|
||||
@@ -180,9 +210,16 @@ fn tool_button<'a>(
|
||||
slot_idx: usize,
|
||||
active_slot: usize,
|
||||
sub_tools_open: bool,
|
||||
slot_last_used: &[usize],
|
||||
) -> Element<'a, Message> {
|
||||
let is_active = slot.tools.contains(&tool_state.active_tool);
|
||||
|
||||
// Resolve the tool this slot currently represents: the last-used sub-tool
|
||||
// for the slot, falling back to the primary tool. The icon shown should
|
||||
// reflect the last-used tool (egui: toolbox.rs:371-383).
|
||||
let last_idx = slot_last_used.get(slot_idx).copied().unwrap_or(0);
|
||||
let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]);
|
||||
|
||||
// Try to load SVG icon - search multiple paths
|
||||
let icon_paths = [
|
||||
std::path::PathBuf::from(slot.icon),
|
||||
@@ -212,7 +249,7 @@ fn tool_button<'a>(
|
||||
let has_sub_tools = slot.tools.len() > 1;
|
||||
let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx;
|
||||
|
||||
let primary_tool = slot.tools[0];
|
||||
let primary_tool = represented_tool;
|
||||
let active = is_active;
|
||||
let c = colors;
|
||||
|
||||
@@ -375,3 +412,26 @@ fn tool_shortcut(tool: Tool) -> &'static str {
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Public accessor for the tool slots list (used by app.rs to resolve
|
||||
/// last-used sub-tools and by keyboard-shortcut handlers).
|
||||
pub fn tool_slots() -> &'static [ToolSlot] {
|
||||
TOOL_SLOTS
|
||||
}
|
||||
|
||||
/// Find the slot index that contains the given tool.
|
||||
///
|
||||
/// Returns the first matching slot so a tool always resolves to its home
|
||||
/// slot (e.g. `VectorStar` → the "Vector Shapes" slot).
|
||||
pub fn slot_for_tool(tool: Tool) -> Option<usize> {
|
||||
TOOL_SLOTS.iter().position(|s| s.tools.contains(&tool))
|
||||
}
|
||||
|
||||
/// Get the last-used tool for a slot, defaulting to the primary tool.
|
||||
pub fn last_used_tool(slot_idx: usize, last_used: &[usize]) -> Tool {
|
||||
let tidx = last_used.get(slot_idx).copied().unwrap_or(0);
|
||||
TOOL_SLOTS
|
||||
.get(slot_idx)
|
||||
.and_then(|s| s.tools.get(tidx).copied())
|
||||
.unwrap_or(Tool::Brush)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user