feat(iced): add filter parameter panels for all 25+ filters

- Create filter_params.rs with per-filter parameter editing UI
- Support all FilterType variants: blur, sharpen, pixelate, distort,
  stylize, color/light adjustments, dehaze, noise pattern
- Float/int sliders with labels and numeric display
- Toggle buttons for boolean params and mode selectors
- Integrate parameter panel into filters.rs below the filter list
- Update dock/view.rs to pass filter_params to the filters panel
This commit is contained in:
2026-07-15 16:11:27 +03:00
parent 4f04fbda11
commit c7082046aa
4 changed files with 710 additions and 2 deletions
@@ -66,7 +66,7 @@ pub fn dock_view<'a>(
)
}
PaneType::Filters => {
crate::panels::filters::view(app.selected_filter, colors)
crate::panels::filters::view(app.selected_filter, &app.filter_params, colors)
}
PaneType::ColorPicker => {
crate::color_picker::view(&app.fg_color, &app.bg_color, &app.recent_colors, app.color_tab)
@@ -0,0 +1,688 @@
//! Filter parameter panels — per-filter parameter editing UI.
//!
//! **Purpose:** Provides slider/input controls for each of the 25+ filter types,
//! allowing users to tune filter parameters in real-time with live preview.
//!
//! **Pattern:** Each filter gets a dedicated view function that reads current
//! parameter values from a `serde_json::Value` and emits
//! `Message::FilterParamChanged(key, value)` on user interaction.
//! Default values match the engine's `unwrap_or` fallbacks for consistency.
use crate::app::Message;
use crate::theme::ThemeColors;
use hcie_engine_api::FilterType;
use iced::widget::{column, container, row, slider, text};
use iced::{Element, Length};
use serde_json::json;
/// Read a float parameter from the JSON params, falling back to a default.
fn param_f64(params: &serde_json::Value, key: &str, default: f64) -> f64 {
params
.get(key)
.and_then(|v| v.as_f64())
.unwrap_or(default)
}
/// Read an integer parameter from the JSON params, falling back to a default.
fn param_u64(params: &serde_json::Value, key: &str, default: u64) -> u64 {
params
.get(key)
.and_then(|v| v.as_u64())
.unwrap_or(default)
}
/// Read a boolean parameter from the JSON params, falling back to a default.
fn param_bool(params: &serde_json::Value, key: &str, default: bool) -> bool {
params
.get(key)
.and_then(|v| v.as_bool())
.unwrap_or(default)
}
/// Read a string parameter from the JSON params, falling back to a default.
fn param_str<'a>(params: &'a serde_json::Value, key: &str, default: &'a str) -> &'a str {
params
.get(key)
.and_then(|v| v.as_str())
.unwrap_or(default)
}
/// Build a labeled float slider row.
///
/// Returns a row with label, slider, and numeric display. The slider
/// emits `Message::FilterParamChanged(filter_id, json!({key: new_value}))`
/// when the user drags it.
fn float_slider(
label: &str,
key: &str,
filter_id: &str,
value: f64,
min: f64,
max: f64,
step: f64,
_colors: ThemeColors,
) -> Element<'static, Message> {
let val = value as f32;
let min_f = min as f32;
let max_f = max as f32;
let step_f = step as f32;
let key_owned = key.to_string();
let filter_owned = filter_id.to_string();
let label_owned = label.to_string();
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0));
let display = text(format!("{:.1}", value)).size(11).width(Length::Fixed(40.0));
let s = slider(min_f..=max_f, val, move |v| {
let rounded = (v as f64 / step).round() * step;
Message::FilterParamChanged(
filter_owned.clone(),
json!({ key_owned.clone(): rounded }),
)
})
.step(step_f)
.width(Length::Fill);
row![lbl, s, display].spacing(4).into()
}
/// Build a labeled integer slider row.
fn int_slider(
label: &str,
key: &str,
filter_id: &str,
value: u64,
min: u64,
max: u64,
_colors: ThemeColors,
) -> Element<'static, Message> {
let val = value as f32;
let min_f = min as f32;
let max_f = max as f32;
let key_owned = key.to_string();
let filter_owned = filter_id.to_string();
let label_owned = label.to_string();
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0));
let display = text(format!("{}", value)).size(11).width(Length::Fixed(40.0));
let s = slider(min_f..=max_f, val, move |v| {
Message::FilterParamChanged(
filter_owned.clone(),
json!({ key_owned.clone(): v.round() as u64 }),
)
})
.step(1.0)
.width(Length::Fill);
row![lbl, s, display].spacing(4).into()
}
/// Build a section header for a group of parameters.
fn section_header(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let label_owned = label.to_string();
container(text(label_owned).size(11).style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
}))
.padding(iced::Padding::default().top(4.0).bottom(2.0))
.into()
}
/// Build the parameter panel for a specific filter type.
///
/// Returns a column of slider/input widgets tailored to the filter's parameters.
/// The panel reads current values from `params` and emits
/// `Message::FilterParamChanged` on user interaction.
pub fn view(
filter: FilterType,
params: &serde_json::Value,
colors: ThemeColors,
) -> Element<'static, Message> {
let content: Element<'static, Message> = match filter {
// ── Blur ─────────────────────────────────────────────────────────
FilterType::BoxBlur | FilterType::BoxBlurGamma => {
let filter_id = if filter == FilterType::BoxBlur {
"box_blur"
} else {
"box_blur_gamma"
};
let radius = param_u64(params, "radius", 3);
column![
section_header("Box Blur", colors),
int_slider("Radius", "radius", filter_id, radius, 1, 100, colors),
]
.spacing(2)
.into()
}
FilterType::GaussianBlur | FilterType::GaussianBlurGamma => {
let filter_id = if filter == FilterType::GaussianBlur {
"gaussian_blur"
} else {
"gaussian_blur_gamma"
};
let radius = param_f64(params, "radius", 2.0);
column![
section_header("Gaussian Blur", colors),
float_slider("Radius", "radius", filter_id, radius, 0.1, 50.0, 0.1, colors),
]
.spacing(2)
.into()
}
FilterType::MotionBlur => {
let angle = param_f64(params, "angle", 0.0);
let distance = param_u64(params, "distance", 10);
column![
section_header("Motion Blur", colors),
float_slider("Angle", "angle", "motion_blur", angle, 0.0, 360.0, 1.0, colors),
int_slider("Distance", "distance", "motion_blur", distance, 1, 500, colors),
]
.spacing(2)
.into()
}
FilterType::MedianFilter => {
let radius = param_u64(params, "radius", 1);
column![
section_header("Median Filter", colors),
int_slider("Radius", "radius", "median_filter", radius, 1, 20, colors),
]
.spacing(2)
.into()
}
// ── Sharpen ──────────────────────────────────────────────────────
FilterType::UnsharpMask => {
let amount = param_f64(params, "amount", 1.0);
let radius = param_f64(params, "radius", 1.0);
column![
section_header("Unsharp Mask", colors),
float_slider("Amount", "amount", "sharpen", amount, 0.1, 5.0, 0.1, colors),
float_slider("Radius", "radius", "sharpen", radius, 0.1, 20.0, 0.1, colors),
]
.spacing(2)
.into()
}
// ── Pixelate ─────────────────────────────────────────────────────
FilterType::Mosaic => {
let size = param_u64(params, "size", 8);
column![
section_header("Mosaic", colors),
int_slider("Size", "size", "mosaic", size, 2, 128, colors),
]
.spacing(2)
.into()
}
FilterType::Crystallize => {
let size = param_u64(params, "size", 16);
column![
section_header("Crystallize", colors),
int_slider("Cell Size", "size", "crystallize", size, 4, 128, colors),
]
.spacing(2)
.into()
}
// ── Distort ──────────────────────────────────────────────────────
FilterType::Pinch => {
let amount = param_f64(params, "amount", 0.5);
column![
section_header("Pinch", colors),
float_slider("Amount", "amount", "pinch", amount, -1.0, 1.0, 0.01, colors),
]
.spacing(2)
.into()
}
FilterType::Twirl => {
let angle = param_f64(params, "angle", 45.0);
column![
section_header("Twirl", colors),
float_slider("Angle", "angle", "twirl", angle, -360.0, 360.0, 1.0, colors),
]
.spacing(2)
.into()
}
// ── Stylize ──────────────────────────────────────────────────────
FilterType::OilPaint => {
let radius = param_u64(params, "radius", 3);
column![
section_header("Oil Paint", colors),
int_slider("Radius", "radius", "oil_paint", radius, 1, 20, colors),
]
.spacing(2)
.into()
}
// ── Color & Light ────────────────────────────────────────────────
FilterType::Levels => {
let shadows = param_f64(params, "shadows", 0.0);
let midtones = param_f64(params, "midtones", 50.0);
let highlights = param_f64(params, "highlights", 100.0);
let out_min = param_f64(params, "output_min", 0.0);
let out_max = param_f64(params, "output_max", 100.0);
column![
section_header("Levels", colors),
float_slider("Shadows", "shadows", "levels", shadows, 0.0, 255.0, 1.0, colors),
float_slider("Midtones", "midtones", "levels", midtones, 0.0, 255.0, 1.0, colors),
float_slider("Highlights", "highlights", "levels", highlights, 0.0, 255.0, 1.0, colors),
float_slider("Out Min", "output_min", "levels", out_min, 0.0, 255.0, 1.0, colors),
float_slider("Out Max", "output_max", "levels", out_max, 0.0, 255.0, 1.0, colors),
]
.spacing(2)
.into()
}
FilterType::Vibrance => {
let vibrance = param_f64(params, "vibrance", 0.0);
column![
section_header("Vibrance", colors),
float_slider("Vibrance", "vibrance", "vibrance", vibrance, -100.0, 100.0, 1.0, colors),
]
.spacing(2)
.into()
}
FilterType::Exposure => {
let exposure = param_f64(params, "exposure", 0.0);
let offset = param_f64(params, "offset", 0.0);
let gamma = param_f64(params, "gamma_correction", 1.0);
column![
section_header("Exposure", colors),
float_slider("Exposure", "exposure", "exposure", exposure, -5.0, 5.0, 0.01, colors),
float_slider("Offset", "offset", "exposure", offset, -1.0, 1.0, 0.01, colors),
float_slider("Gamma", "gamma_correction", "exposure", gamma, 0.1, 5.0, 0.01, colors),
]
.spacing(2)
.into()
}
FilterType::Posterize => {
let levels = param_u64(params, "levels", 4);
column![
section_header("Posterize", colors),
int_slider("Levels", "levels", "posterize", levels, 2, 32, colors),
]
.spacing(2)
.into()
}
FilterType::Threshold => {
let min_lum = param_f64(params, "min_luminance", 50.0);
let max_lum = param_f64(params, "max_luminance", 100.0);
column![
section_header("Threshold", colors),
float_slider("Min Luminance", "min_luminance", "threshold", min_lum, 0.0, 100.0, 1.0, colors),
float_slider("Max Luminance", "max_luminance", "threshold", max_lum, 0.0, 100.0, 1.0, colors),
]
.spacing(2)
.into()
}
FilterType::ChannelMixer => {
let mono = param_bool(params, "monochrome", false);
let mono_r = param_f64(params, "monochrome_r", 40.0);
let mono_g = param_f64(params, "monochrome_g", 40.0);
let mono_b = param_f64(params, "monochrome_b", 20.0);
let mono_c = param_f64(params, "monochrome_constant", 0.0);
let mut col = column![
section_header("Channel Mixer", colors),
]
.spacing(2);
// Monochrome toggle
let filter_id = "channel_mixer";
{
let mono_val = mono;
let lbl = text("Monochrome").size(11).width(Length::Fixed(80.0));
let toggle_text = if mono_val { "ON" } else { "OFF" };
let toggle_btn = iced::widget::button(text(toggle_text).size(11))
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "monochrome": !mono_val }),
))
.padding([2, 8]);
col = col.push(row![lbl, toggle_btn].spacing(4));
}
if mono {
col = col.push(float_slider("Monochrome R", "monochrome_r", filter_id, mono_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Monochrome G", "monochrome_g", filter_id, mono_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Monochrome B", "monochrome_b", filter_id, mono_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Constant", "monochrome_constant", filter_id, mono_c, -200.0, 200.0, 1.0, colors));
} else {
// RGB channels
let red_r = param_f64(params, "red_r", 100.0);
let red_g = param_f64(params, "red_g", 0.0);
let red_b = param_f64(params, "red_b", 0.0);
let red_c = param_f64(params, "red_constant", 0.0);
let green_r = param_f64(params, "green_r", 0.0);
let green_g = param_f64(params, "green_g", 100.0);
let green_b = param_f64(params, "green_b", 0.0);
let green_c = param_f64(params, "green_constant", 0.0);
let blue_r = param_f64(params, "blue_r", 0.0);
let blue_g = param_f64(params, "blue_g", 0.0);
let blue_b = param_f64(params, "blue_b", 100.0);
let blue_c = param_f64(params, "blue_constant", 0.0);
col = col.push(section_header("Red Channel", colors));
col = col.push(float_slider("Red→R", "red_r", filter_id, red_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Red→B", "red_b", filter_id, red_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Red Const", "red_constant", filter_id, red_c, -200.0, 200.0, 1.0, colors));
col = col.push(section_header("Green Channel", colors));
col = col.push(float_slider("Green→R", "green_r", filter_id, green_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Green→G", "green_g", filter_id, green_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Green→B", "green_b", filter_id, green_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Green Const", "green_constant", filter_id, green_c, -200.0, 200.0, 1.0, colors));
col = col.push(section_header("Blue Channel", colors));
col = col.push(float_slider("Blue→R", "blue_r", filter_id, blue_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Blue→B", "blue_b", filter_id, blue_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Blue Const", "blue_constant", filter_id, blue_c, -200.0, 200.0, 1.0, colors));
}
col.into()
}
FilterType::BlackAndWhite => {
let reds = param_f64(params, "reds", 40.0);
let yellows = param_f64(params, "yellows", 60.0);
let greens = param_f64(params, "greens", 40.0);
let cyans = param_f64(params, "cyans", 60.0);
let blues = param_f64(params, "blues", 20.0);
let magentas = param_f64(params, "magentas", 80.0);
let tint_r = param_f64(params, "tint_r", 255.0);
let tint_g = param_f64(params, "tint_g", 255.0);
let tint_b = param_f64(params, "tint_b", 255.0);
column![
section_header("Black & White", colors),
float_slider("Reds", "reds", "black_and_white", reds, 0.0, 100.0, 1.0, colors),
float_slider("Yellows", "yellows", "black_and_white", yellows, 0.0, 100.0, 1.0, colors),
float_slider("Greens", "greens", "black_and_white", greens, 0.0, 100.0, 1.0, colors),
float_slider("Cyans", "cyans", "black_and_white", cyans, 0.0, 100.0, 1.0, colors),
float_slider("Blues", "blues", "black_and_white", blues, 0.0, 100.0, 1.0, colors),
float_slider("Magentas", "magentas", "black_and_white", magentas, 0.0, 100.0, 1.0, colors),
section_header("Tint Color", colors),
float_slider("Tint R", "tint_r", "black_and_white", tint_r, 0.0, 255.0, 1.0, colors),
float_slider("Tint G", "tint_g", "black_and_white", tint_g, 0.0, 255.0, 1.0, colors),
float_slider("Tint B", "tint_b", "black_and_white", tint_b, 0.0, 255.0, 1.0, colors),
]
.spacing(2)
.into()
}
FilterType::SelectiveColor => {
let filter_id = "selective_color";
// Mode toggle
let mode = param_str(params, "mode", "relative");
let is_absolute = mode == "absolute";
let mut col = column![
section_header("Selective Color", colors),
]
.spacing(2);
// Mode toggle
{
let lbl = text("Mode").size(11).width(Length::Fixed(80.0));
let toggle_btn = iced::widget::button(text(mode.to_string()).size(11))
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "mode": if is_absolute { "relative" } else { "absolute" } }),
))
.padding([2, 8]);
col = col.push(row![lbl, toggle_btn].spacing(4));
}
// Per-color-range CMYK sliders
let ranges = [
("Reds", "reds"),
("Yellows", "yellows"),
("Greens", "greens"),
("Cyans", "cyans"),
("Blues", "blues"),
("Magentas", "magentas"),
("Whites", "whites"),
("Neutrals", "neutrals"),
("Blacks", "blacks"),
];
let cmyk = [("Cyan", "cyan"), ("Magenta", "magenta"), ("Yellow", "yellow"), ("Black", "black")];
for (range_name, range_key) in &ranges {
col = col.push(section_header(range_name, colors));
for (cmyk_name, cmyk_key) in &cmyk {
let param_key = format!("{}_{}", range_key, cmyk_key);
let val = param_f64(params, &param_key, 0.0);
col = col.push(float_slider(
cmyk_name,
&param_key,
filter_id,
val,
-100.0,
100.0,
1.0,
colors,
));
}
}
col.into()
}
FilterType::GammaCorrection => {
let gamma = param_f64(params, "gamma", 2.2);
let inverse = param_bool(params, "inverse", false);
let mut col = column![
section_header("Gamma Correction", colors),
float_slider("Gamma", "gamma", "gamma_correction", gamma, 0.1, 5.0, 0.1, colors),
]
.spacing(2);
{
let lbl = text("Inverse").size(11).width(Length::Fixed(80.0));
let toggle_text = if inverse { "ON" } else { "OFF" };
let toggle_btn = iced::widget::button(text(toggle_text).size(11))
.on_press(Message::FilterParamChanged(
"gamma_correction".to_string(),
json!({ "inverse": !inverse }),
))
.padding([2, 8]);
col = col.push(row![lbl, toggle_btn].spacing(4));
}
col.into()
}
FilterType::ExtractChannel => {
let channel = param_str(params, "channel", "red");
let channels = ["red", "green", "blue", "alpha"];
let mut col = column![
section_header("Extract Channel", colors),
]
.spacing(2);
for ch in &channels {
let is_active = channel == *ch;
let filter_id = "extract_channel";
let ch_label = text(ch.to_string()).size(11).width(Length::Fixed(80.0));
let btn = iced::widget::button(text(if is_active { format!(">> {}", ch) } else { ch.to_string() }).size(11))
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "channel": ch }),
))
.padding([2, 8]);
col = col.push(row![ch_label, btn].spacing(4));
}
col.into()
}
FilterType::GradientMap => {
let reverse = param_bool(params, "reverse", false);
column![
section_header("Gradient Map", colors),
{
let lbl = text("Reverse").size(11).width(Length::Fixed(80.0));
let toggle_text = if reverse { "ON" } else { "OFF" };
let toggle_btn = iced::widget::button(text(toggle_text).size(11))
.on_press(Message::FilterParamChanged(
"gradient_map".to_string(),
json!({ "reverse": !reverse }),
))
.padding([2, 8]);
row![lbl, toggle_btn].spacing(4)
},
{
text("Gradient stops configured via color picker").size(10)
},
]
.spacing(2)
.into()
}
// ── Restore ──────────────────────────────────────────────────────
FilterType::Dehaze => {
let strength = param_f64(params, "strength", 50.0);
column![
section_header("Dehaze", colors),
float_slider("Strength", "strength", "dehaze", strength, 0.0, 100.0, 1.0, colors),
]
.spacing(2)
.into()
}
// ── Noise & Pattern ──────────────────────────────────────────────
FilterType::NoisePattern => {
let filter_id = "noise_pattern";
let scale = param_f64(params, "scale", 10.0);
let reverse = param_bool(params, "reverse", false);
let noise_type = param_str(params, "noise_type", "perlin");
let fractal_type = param_str(params, "fractal_type", "none");
let fractal_octaves = param_u64(params, "fractal_octaves", 3);
let fractal_lacunarity = param_f64(params, "fractal_lacunarity", 2.0);
let fractal_gain = param_f64(params, "fractal_gain", 0.5);
let domain_warp_type = param_str(params, "domain_warp_type", "none");
let domain_warp_amp = param_f64(params, "domain_warp_amplitude", 100.0);
let cellular_distance = param_str(params, "cellular_distance_function", "euclidean");
let cellular_return = param_str(params, "cellular_return_type", "nearest");
let cellular_jitter = param_f64(params, "cellular_jitter", 1.0);
let mut col = column![
section_header("Noise Pattern", colors),
float_slider("Scale", "scale", filter_id, scale, 0.1, 100.0, 0.1, colors),
]
.spacing(2);
// Reverse toggle
{
let lbl = text("Reverse").size(11).width(Length::Fixed(80.0));
let toggle_text = if reverse { "ON" } else { "OFF" };
let toggle_btn = iced::widget::button(text(toggle_text).size(11))
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "reverse": !reverse }),
))
.padding([2, 8]);
col = col.push(row![lbl, toggle_btn].spacing(4));
}
// Noise type selector
{
let noise_types = ["value", "perlin", "simplex", "cellular"];
col = col.push(section_header("Noise Type", colors));
for nt in &noise_types {
let is_active = noise_type == *nt;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", nt) } else { nt.to_string() }).size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "noise_type": nt }),
))
.padding([2, 8]);
col = col.push(btn);
}
}
// Fractal type selector
{
let fractal_types = ["none", "fbm", "ridged", "ping_pong", "domain_warp"];
col = col.push(section_header("Fractal Type", colors));
for ft in &fractal_types {
let is_active = fractal_type == *ft;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", ft) } else { ft.to_string() }).size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "fractal_type": ft }),
))
.padding([2, 8]);
col = col.push(btn);
}
}
if fractal_type != "none" {
col = col.push(int_slider("Octaves", "fractal_octaves", filter_id, fractal_octaves, 1, 10, colors));
col = col.push(float_slider("Lacunarity", "fractal_lacunarity", filter_id, fractal_lacunarity, 1.0, 4.0, 0.1, colors));
col = col.push(float_slider("Gain", "fractal_gain", filter_id, fractal_gain, 0.0, 2.0, 0.01, colors));
}
// Domain warp selector
{
let warp_types = ["none", "basic", "general"];
col = col.push(section_header("Domain Warp", colors));
for wt in &warp_types {
let is_active = domain_warp_type == *wt;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", wt) } else { wt.to_string() }).size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "domain_warp_type": wt }),
))
.padding([2, 8]);
col = col.push(btn);
}
}
if domain_warp_type != "none" {
col = col.push(float_slider("Warp Amp", "domain_warp_amplitude", filter_id, domain_warp_amp, 0.0, 500.0, 1.0, colors));
}
// Cellular settings (shown when noise_type is "cellular")
if noise_type == "cellular" {
col = col.push(section_header("Cellular", colors));
// Distance function
let dist_funcs = ["euclidean", "manhattan", "chebyshev", "euclidean_sq"];
for df in &dist_funcs {
let is_active = cellular_distance == *df;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", df) } else { df.to_string() }).size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "cellular_distance_function": df }),
))
.padding([2, 8]);
col = col.push(btn);
}
// Return type
let ret_types = ["nearest", "second", "distance", "difference", "sum"];
col = col.push(section_header("Return Type", colors));
for rt in &ret_types {
let is_active = cellular_return == *rt;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", rt) } else { rt.to_string() }).size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "cellular_return_type": rt }),
))
.padding([2, 8]);
col = col.push(btn);
}
col = col.push(float_slider("Jitter", "cellular_jitter", filter_id, cellular_jitter, 0.0, 1.0, 0.01, colors));
}
col.into()
}
};
container(content)
.width(Length::Fill)
.padding(4)
.into()
}
@@ -86,7 +86,15 @@ const FILTER_CATEGORIES: &[FilterCategory] = &[
];
/// Build the filters panel.
pub fn view(selected_filter: Option<FilterType>, colors: ThemeColors) -> Element<'static, Message> {
///
/// Shows the filter category list on top, and when a filter is selected,
/// displays its parameter editing panel below the list. The Apply/Reset
/// buttons are always at the bottom.
pub fn view(
selected_filter: Option<FilterType>,
filter_params: &serde_json::Value,
colors: ThemeColors,
) -> Element<'static, Message> {
let mut filter_list = column![].spacing(2);
for category in FILTER_CATEGORIES {
@@ -139,9 +147,20 @@ pub fn view(selected_filter: Option<FilterType>, colors: ThemeColors) -> Element
let buttons = row![apply_btn, reset_btn].spacing(8);
// When a filter is selected, show its parameter panel below the list.
let param_section = match selected_filter {
Some(filter) => {
let params_panel = super::filter_params::view(filter, filter_params, colors);
column![horizontal_rule(1), scrollable(params_panel).height(Length::Fill)]
.spacing(0)
}
None => column![],
};
// Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![
scrollable(filter_list).height(Length::Fill),
param_section,
horizontal_rule(1),
buttons,
]
@@ -3,6 +3,7 @@
pub mod ai_chat_panel;
pub mod ai_script_panel;
pub mod brushes;
pub mod filter_params;
pub mod filters;
pub mod geometry;
pub mod history;