Skip to main content

lutgen_studio/ui/
left.rs

1use std::ops::RangeInclusive;
2use std::rc::Rc;
3
4use strum::VariantArray;
5
6use crate::palette::{lutgen_dir, DynamicPalette};
7use crate::state::LutAlgorithm;
8use crate::App;
9
10/// Helper to add a labeled slider with dynamic DragValue sizing.
11/// The DragValue is measured first, then the slider fills remaining space.
12fn labeled_slider<Num: egui::emath::Numeric>(
13    ui: &mut egui::Ui,
14    label: &str,
15    value: &mut Num,
16    range: RangeInclusive<Num>,
17) -> egui::Response {
18    ui.label(label);
19    ui.horizontal(|ui| {
20        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
21            let drag = ui.add(egui::DragValue::new(value).range(range.clone()));
22            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
23                ui.style_mut().spacing.slider_width =
24                    ui.available_width() - ui.spacing().item_spacing.x;
25                let slider = ui.add(egui::Slider::new(value, range).show_value(false));
26                drag | slider
27            })
28            .inner
29        })
30        .inner
31    })
32    .inner
33}
34
35pub struct PaletteFilterBox {
36    items: Vec<Rc<DynamicPalette>>,
37    idx: usize,
38    filter: String,
39    filtered: Vec<Rc<DynamicPalette>>,
40}
41
42impl PaletteFilterBox {
43    pub fn new(current: &DynamicPalette) -> Self {
44        let items: Vec<_> = DynamicPalette::get_all()
45            .unwrap()
46            .into_iter()
47            .map(Rc::new)
48            .collect();
49
50        Self {
51            filter: String::new(),
52            filtered: items.clone(),
53            idx: items
54                .iter()
55                .position(|v| **v == *current)
56                .unwrap_or_default(),
57            items,
58        }
59    }
60
61    pub fn reindex(&mut self, current: &DynamicPalette) {
62        self.items = DynamicPalette::get_all()
63            .unwrap()
64            .into_iter()
65            .map(Rc::new)
66            .collect();
67        self.filter();
68        self.idx = self
69            .filtered
70            .iter()
71            .position(|v| **v == *current)
72            .unwrap_or_default();
73    }
74
75    fn filter(&mut self) {
76        if self.filter.is_empty() {
77            self.filtered = self.items.clone();
78        } else {
79            self.filtered = self
80                .items
81                .iter()
82                .filter(|palette| palette.as_str().contains(&self.filter.to_lowercase()))
83                .cloned()
84                .collect();
85        }
86    }
87
88    pub fn show(&mut self, ui: &mut egui::Ui, current: &mut DynamicPalette) -> egui::Response {
89        let mut apply = false;
90        let mut res = ui
91            .group(|ui| {
92                egui::Resize::default()
93                    .resizable([true, false])
94                    .min_width(ui.available_width())
95                    .max_width(ui.available_width())
96                    .with_stroke(false)
97                    .show(ui, |ui| {
98                        ui.horizontal(|ui| {
99                            if egui::TextEdit::singleline(&mut self.filter)
100                                .hint_text("Search Palettes ...")
101                                .desired_width(ui.available_width() - 55.)
102                                .show(ui)
103                                .response
104                                .changed()
105                            {
106                                // update filtered items
107                                self.filter();
108                                if !self.filtered.is_empty() {
109                                    self.idx = 0;
110                                    *current = (*self.filtered[self.idx]).clone();
111                                }
112                            }
113
114                            if ui.button("<").clicked() && !self.filtered.is_empty() {
115                                if self.idx == 0 {
116                                    self.idx = self.filtered.len() - 1;
117                                } else {
118                                    self.idx -= 1;
119                                }
120                                *current = (*self.filtered[self.idx]).clone();
121                                apply = true;
122                            }
123                            if ui.button(">").clicked() && !self.filtered.is_empty() {
124                                if self.idx >= self.filtered.len() - 1 {
125                                    self.idx = 0;
126                                } else {
127                                    self.idx += 1;
128                                }
129                                *current = (*self.filtered[self.idx]).clone();
130                                apply = true;
131                            }
132                        });
133
134                        ui.separator();
135
136                        egui::ScrollArea::new([true, true])
137                            .auto_shrink([false, false])
138                            .show(ui, |ui| {
139                                for (i, palette) in self.filtered.iter().enumerate() {
140                                    let selected = (**palette).clone();
141                                    let res = ui.add(
142                                        egui::Button::selectable(
143                                            *current == selected,
144                                            palette.as_str(),
145                                        )
146                                        .min_size(egui::Vec2::new(ui.available_width() - 1., 16.)),
147                                    );
148                                    // scroll when item is focused
149                                    res.gained_focus()
150                                        .then(|| ui.scroll_to_cursor(Some(egui::Align::Center)));
151                                    // scroll when we applied above
152                                    if apply && *current == **palette {
153                                        res.request_focus();
154                                        ui.scroll_to_cursor(Some(egui::Align::Center));
155                                    }
156                                    if res.clicked() {
157                                        *current = selected;
158                                        self.idx = i;
159                                        apply = true;
160                                    }
161                                }
162                            });
163                    })
164            })
165            .response;
166
167        // If we need to apply a new palette, mark the response as changed
168        if apply {
169            res.mark_changed();
170        }
171
172        res
173    }
174}
175
176pub struct PaletteEditor {
177    name: String,
178}
179
180impl PaletteEditor {
181    pub fn new(current: &DynamicPalette) -> Self {
182        Self {
183            name: current.to_string(),
184        }
185    }
186
187    pub fn show(
188        &mut self,
189        ui: &mut egui::Ui,
190        palette: &mut Vec<[u8; 3]>,
191        current: &mut DynamicPalette,
192    ) -> [bool; 2] {
193        // color palette
194        let mut apply = false;
195        let mut saved = false;
196        ui.group(|ui| {
197            ui.horizontal(|ui| {
198                let enabled = matches!(current, DynamicPalette::Custom(_));
199                ui.add_enabled(
200                    enabled,
201                    egui::TextEdit::singleline(&mut self.name)
202                        .desired_width(ui.available_width() - 49.),
203                );
204
205                if ui.add_enabled(enabled, egui::Button::new("save")).clicked() {
206                    *current = DynamicPalette::Custom(self.name.clone());
207                    current.save(palette).unwrap();
208                    saved = true;
209                }
210            });
211            ui.separator();
212            ui.horizontal_wrapped(|ui| {
213                ui.spacing_mut().interact_size.x =
214                    calculate_width(ui.available_width() + 7., 40., ui.spacing().item_spacing.x);
215
216                let mut res = Vec::new();
217                for color in palette.iter_mut() {
218                    res.push(egui::widgets::color_picker::color_edit_button_srgb(
219                        ui, color,
220                    ));
221                }
222                for (i, res) in res.iter().enumerate() {
223                    if res.changed() {
224                        if matches!(current, DynamicPalette::Builtin(_)) {
225                            let name = current.to_string() + "-custom";
226                            self.name = name.clone();
227                            *current = DynamicPalette::Custom(name);
228                        }
229                        apply = true;
230                    }
231                    if res.secondary_clicked() {
232                        if matches!(current, DynamicPalette::Builtin(_)) {
233                            let name = current.to_string() + "-custom";
234                            self.name = name.clone();
235                            *current = DynamicPalette::Custom(name);
236                        }
237                        palette.remove(i);
238                        apply = true;
239                    }
240                }
241
242                if ui
243                    .add(egui::Button::new("+").min_size(ui.spacing().interact_size))
244                    .clicked()
245                {
246                    if matches!(current, DynamicPalette::Builtin(_)) {
247                        let name = current.to_string() + "-custom";
248                        self.name = name.clone();
249                        *current = DynamicPalette::Custom(name);
250                    }
251                    palette.push([0u8; 3]);
252                    apply = true;
253                };
254            });
255        });
256        [apply, saved]
257    }
258}
259
260/// Calculates optimal button width for a wrapped grid with padding.
261pub fn calculate_width(width: f32, target: f32, padding: f32) -> f32 {
262    if width <= 0.0 || target <= 0.0 {
263        return 0.0;
264    }
265
266    let target_with_padding = target + padding;
267    if width < target_with_padding {
268        return (width - padding).max(0.0);
269    }
270
271    let buttons_that_fit = (width / target_with_padding).round();
272    if buttons_that_fit <= 0.0 {
273        return (width - padding).max(0.0);
274    }
275
276    (width / buttons_that_fit) - padding
277}
278
279impl App {
280    fn show_settings(&mut self, ui: &mut egui::Ui) -> bool {
281        let mut apply = false;
282        ui.group(|ui| {
283            // Algorithm dropdown
284            ui.horizontal(|ui| {
285                ui.with_layout(
286                    egui::Layout::right_to_left(egui::Align::Center),
287                    |ui| {
288                        if ui.button("Reset").clicked() {
289                            self.state.reset_current_args();
290                            self.apply();
291                        }
292                        egui::ComboBox::from_id_salt("algorithm")
293                            .selected_text(format!("{:?}", self.state.current_alg))
294                            .width(ui.available_width())
295                            .show_ui(ui, |ui| {
296                                for alg in LutAlgorithm::VARIANTS {
297                                    let val = ui.selectable_value(
298                                        &mut self.state.current_alg,
299                                        *alg,
300                                        alg.to_string(),
301                                    );
302                                    apply |= val.clicked();
303                                    val.gained_focus().then(|| {
304                                        ui.scroll_to_cursor(Some(egui::Align::Center))
305                                    });
306                                }
307                            });
308                    },
309                );
310            });
311            ui.separator();
312
313            // common args
314            ui.heading("Common Arguments");
315            ui.add_space(5.);
316
317            let res = labeled_slider(ui, "Hald-Clut Level", &mut self.state.common.level, 4..=16);
318            apply |= res.drag_stopped() | res.lost_focus();
319            res.on_hover_text("\
320                Hald clut level to generate. Heavy impact on performance for high levels. \n\
321                A level of 16 computes a value for the entire sRGB color space.\n\n\
322                Range: 4-16",
323            );
324
325            let res = labeled_slider(ui, "Luminosity Factor", self.state.common.lum_factor.as_mut(), 0.001..=2.);
326            apply |= res.drag_stopped() | res.lost_focus();
327            res.on_hover_text("\
328                Factor to multiply luminocity values by. \
329                Effectively weights the interpolation to prefer more \
330                colorful or more greyscale/unsaturated matches.\n\n\
331                Tip: Use values below 1.0 for more colorful results, \
332                above 1.0 for less colorful results. \
333                Extreme values usually are paired with 'Preserve Luminosity'.\n\n\
334                Default: 0.7");
335
336            let res = ui
337                .checkbox(&mut self.state.common.preserve, "Preserve Luminosity");
338            apply |= res.changed();
339            res.on_hover_text("\
340                Preserve the original image's luminocity values after interpolation. \
341                This effectively retains the image's contrast and generally improves gradients.\n\n\
342                Default: true");
343
344            // unique algorithm args
345            match self.state.current_alg {
346                LutAlgorithm::GaussianRbf => {
347                    ui.separator();
348                    ui.heading("Gaussian Arguments");
349                    ui.add_space(5.);
350
351                    let res = labeled_slider(ui, "Shape", self.state.guassian_rbf.shape.as_mut(), 0.001..=512.);
352                    apply |= res.drag_stopped() | res.lost_focus();
353                    res.on_hover_text("\
354                        Shape parameter for the default Gaussian RBF interpolation. \
355                        Effectively creates more or less blending between colors in the palette.\n\n\
356                        Bigger numbers = less blending (closer to original colors)\n\
357                        Smaller numbers = more blending (smoother results)\n\n\
358                        Default: 128.0");
359                },
360                LutAlgorithm::ShepardsMethod => {
361                    ui.separator();
362                    ui.heading("Shepard's Method Arguments");
363                    ui.add_space(10.);
364
365                    let res = labeled_slider(ui, "Power", self.state.shepards_method.power.as_mut(), 0.001..=64.);
366                    apply |= res.drag_stopped() | res.lost_focus();
367                    res.on_hover_text("\
368                        Power parameter for Shepard's method (Inverse Distance RBF).\n\
369                        Higher values give more weight to closer palette colors.\n\n\
370                        Default: 4.0");
371                },
372                LutAlgorithm::GaussianSampling => {
373                    ui.separator();
374                    ui.heading("Guassian Sampling Arguments");
375                    ui.add_space(10.);
376
377                    let res = labeled_slider(ui, "Mean", self.state.guassian_sampling.mean.as_mut(), -127.0..=127.);
378                    apply |= res.drag_stopped() | res.lost_focus();
379                    res.on_hover_text("\
380                        Average amount of noise to apply in each iteration. \
381                        Controls the bias of the random sampling process, and can lighten \
382                        or darken the image overall.\n\n\
383                        Default: 0.0\nRange: -127.0 to 127.0");
384
385                    let res = labeled_slider(ui, "Standard Deviation", self.state.guassian_sampling.std_dev.as_mut(), 1.0..=128.);
386                    apply |= res.drag_stopped() | res.lost_focus();
387                    res.on_hover_text("\
388                        Standard deviation parameter for the noise applied in each iteration. \
389                        Controls how much variation is applied during sampling.\n\n\
390                        Default: 20.0");
391
392                    let res = labeled_slider(ui, "Iterations", &mut self.state.guassian_sampling.iterations, 1..=1024);
393                    apply |= res.drag_stopped() | res.lost_focus();
394                    res.on_hover_text("\
395                        Number of iterations of noise to apply to each pixel.\n\
396                        More iterations = better blending but slower processing.\n\n\
397                        Default: 512");
398
399                    ui.label("RNG Seed");
400                    let res = ui.add(
401                        egui::DragValue::new(&mut self.state.guassian_sampling.seed)
402                            .speed(2i32.pow(20)),
403                    );
404                    apply |= res.drag_stopped() | res.lost_focus();
405                    res.on_hover_text("\
406                        Seed for the random number generator used in noise generation.\n\n\
407                        Default: 42080085");
408                },
409                LutAlgorithm::GaussianBlur => {
410                    ui.separator();
411                    ui.heading("Gaussian Blur Arguments");
412                    ui.add_space(10.);
413
414                    let res = labeled_slider(ui, "Radius", self.state.gaussian_blur.radius.as_mut(), 1.0..=64.0);
415                    apply |= res.drag_stopped() | res.lost_focus();
416                    res.on_hover_text("\
417                        Gaussian blur radius (sigma) applied in OKLab color space.\n\n\
418                        Higher values = larger blur kernel = more color blending\n\
419                        Lower values = smaller kernel = sharper boundaries\n\n\
420                        Default: 8.0");
421                },
422                _ => {},
423            }
424
425            // shared rbf args
426            match self.state.current_alg {
427                LutAlgorithm::GaussianRbf | LutAlgorithm::ShepardsMethod => {
428                    let res = labeled_slider(ui, "Nearest Colors", &mut self.state.common_rbf.nearest, 0..=32);
429                    apply |= res.drag_stopped() | res.lost_focus();
430                    res.on_hover_text("\
431                        Number of nearest colors to consider when interpolating.\n\n\
432                        0 = uses all available colors ( O(n) )\n\
433                        Lower values = faster processing\n\
434                        Higher values = more blending\n\n\
435                        Default: 16");
436                },
437                _ => {},
438            }
439        });
440
441        ui.horizontal(|ui| {
442            let res = ui.add(
443                egui::Button::new("Copy CLI Arguments")
444                    .min_size(egui::Vec2::new(ui.available_width(), 16.)),
445            );
446            if res.clicked() {
447                let args = self.state.cli_args();
448                ui.ctx()
449                    .copy_text("lutgen apply ".to_string() + &args.join(" "));
450            }
451        });
452
453        apply
454    }
455
456    pub fn show_sidebar_inner(&mut self, ui: &mut egui::Ui) {
457        let mut apply = false;
458        ui.add_space(4.);
459
460        // palette menu
461        if self
462            .palette_box
463            .show(ui, &mut self.state.palette_selection)
464            .changed()
465        {
466            self.state.palette = self.state.palette_selection.get().to_vec();
467            self.palette_edit.name = self.state.palette_selection.to_string();
468            apply = true;
469        }
470
471        // palette editor
472        let [changed, saved] = self.palette_edit.show(
473            ui,
474            &mut self.state.palette,
475            &mut self.state.palette_selection,
476        );
477        apply |= changed;
478        if saved {
479            self.palette_box.reindex(&self.state.palette_selection);
480            self.state.last_event = format!(
481                "Saved custom palette to {}",
482                lutgen_dir().join(&self.palette_edit.name).display()
483            );
484        }
485
486        // settings panel
487        apply |= self.show_settings(ui);
488
489        if apply {
490            self.apply();
491        }
492    }
493
494    /// side panel for lut args
495    pub fn show_sidebar(&mut self, ctx: &egui::Context) {
496        if !self.inline_layout {
497            egui::SidePanel::left("args")
498                .resizable(true)
499                .min_width(214.)
500                .show(ctx, |ui| {
501                    ui.take_available_width();
502                    egui::ScrollArea::vertical().show(ui, |ui| {
503                        self.show_sidebar_inner(ui);
504                    });
505                });
506        }
507    }
508}