Skip to main content

egui_cha_ds/atoms/visual/
gradient_editor.rs

1//! GradientEditor atom - Gradient color stop editor for VJ applications
2//!
3//! A component for creating and editing color gradients with draggable stops.
4//! Used for color mapping, LUTs, and visual effects in VJ software.
5//!
6//! # Features
7//! - Add/remove color stops
8//! - Drag stops to reposition
9//! - Double-click to edit stop color
10//! - Gradient preview
11//! - Linear/Radial mode indicators
12//! - Theme-aware styling
13//!
14//! # Example
15//! ```ignore
16//! GradientEditor::new(&gradient)
17//!     .show_with(ctx, |event| match event {
18//!         GradientEvent::AddStop(pos, color) => Msg::AddStop(pos, color),
19//!         GradientEvent::MoveStop(idx, pos) => Msg::MoveStop(idx, pos),
20//!         GradientEvent::RemoveStop(idx) => Msg::RemoveStop(idx),
21//!         GradientEvent::SetStopColor(idx, color) => Msg::SetColor(idx, color),
22//!     });
23//! ```
24
25use crate::Theme;
26use egui::{Color32, Pos2, Rect, Sense, Stroke, Ui, Vec2};
27use egui_cha::ViewCtx;
28
29/// A color stop in the gradient
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct GradientStop {
32    pub position: f32,
33    pub color: Color32,
34}
35
36impl GradientStop {
37    pub fn new(position: f32, color: Color32) -> Self {
38        Self {
39            position: position.clamp(0.0, 1.0),
40            color,
41        }
42    }
43}
44
45/// Gradient data
46#[derive(Debug, Clone, PartialEq)]
47pub struct Gradient {
48    pub stops: Vec<GradientStop>,
49}
50
51impl Gradient {
52    pub fn new() -> Self {
53        Self {
54            stops: vec![
55                GradientStop::new(0.0, Color32::BLACK),
56                GradientStop::new(1.0, Color32::WHITE),
57            ],
58        }
59    }
60
61    pub fn from_stops(mut stops: Vec<GradientStop>) -> Self {
62        stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
63        Self { stops }
64    }
65
66    pub fn sample(&self, t: f32) -> Color32 {
67        let t = t.clamp(0.0, 1.0);
68
69        if self.stops.is_empty() {
70            return Color32::BLACK;
71        }
72        if self.stops.len() == 1 {
73            return self.stops[0].color;
74        }
75
76        let mut left = &self.stops[0];
77        let mut right = &self.stops[self.stops.len() - 1];
78
79        for i in 0..self.stops.len() - 1 {
80            if self.stops[i].position <= t && self.stops[i + 1].position >= t {
81                left = &self.stops[i];
82                right = &self.stops[i + 1];
83                break;
84            }
85        }
86
87        let range = right.position - left.position;
88        if range < 0.0001 {
89            return left.color;
90        }
91
92        let factor = (t - left.position) / range;
93        Color32::from_rgba_unmultiplied(
94            lerp_u8(left.color.r(), right.color.r(), factor),
95            lerp_u8(left.color.g(), right.color.g(), factor),
96            lerp_u8(left.color.b(), right.color.b(), factor),
97            lerp_u8(left.color.a(), right.color.a(), factor),
98        )
99    }
100
101    pub fn add_stop(&mut self, position: f32) {
102        let color = self.sample(position);
103        self.stops.push(GradientStop::new(position, color));
104        self.stops
105            .sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
106    }
107
108    pub fn add_stop_with_color(&mut self, position: f32, color: Color32) {
109        self.stops.push(GradientStop::new(position, color));
110        self.stops
111            .sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
112    }
113
114    pub fn remove_stop(&mut self, index: usize) {
115        if self.stops.len() > 2 && index < self.stops.len() {
116            self.stops.remove(index);
117        }
118    }
119
120    pub fn move_stop(&mut self, index: usize, new_position: f32) {
121        if let Some(stop) = self.stops.get_mut(index) {
122            stop.position = new_position.clamp(0.0, 1.0);
123        }
124        self.stops
125            .sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
126    }
127}
128
129impl Default for Gradient {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
136    ((a as f32) * (1.0 - t) + (b as f32) * t) as u8
137}
138
139/// Events emitted by GradientEditor
140#[derive(Debug, Clone)]
141pub enum GradientEvent {
142    AddStop(f32),
143    MoveStop { index: usize, position: f32 },
144    RemoveStop(usize),
145    SetStopColor { index: usize, color: Color32 },
146    SelectStop(Option<usize>),
147}
148
149/// Gradient direction
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum GradientDirection {
152    #[default]
153    Horizontal,
154    Vertical,
155}
156
157/// Gradient editor widget
158pub struct GradientEditor<'a> {
159    gradient: &'a Gradient,
160    width: f32,
161    height: f32,
162    direction: GradientDirection,
163    selected_stop: Option<usize>,
164    show_stop_values: bool,
165    editable: bool,
166}
167
168impl<'a> GradientEditor<'a> {
169    pub fn new(gradient: &'a Gradient) -> Self {
170        Self {
171            gradient,
172            width: 300.0,
173            height: 40.0,
174            direction: GradientDirection::Horizontal,
175            selected_stop: None,
176            show_stop_values: true,
177            editable: true,
178        }
179    }
180
181    pub fn width(mut self, width: f32) -> Self {
182        self.width = width;
183        self
184    }
185
186    pub fn height(mut self, height: f32) -> Self {
187        self.height = height;
188        self
189    }
190
191    pub fn direction(mut self, direction: GradientDirection) -> Self {
192        self.direction = direction;
193        self
194    }
195
196    pub fn selected(mut self, index: Option<usize>) -> Self {
197        self.selected_stop = index;
198        self
199    }
200
201    pub fn show_values(mut self, show: bool) -> Self {
202        self.show_stop_values = show;
203        self
204    }
205
206    pub fn editable(mut self, editable: bool) -> Self {
207        self.editable = editable;
208        self
209    }
210
211    pub fn show_with<Msg>(
212        self,
213        ctx: &mut ViewCtx<'_, Msg>,
214        on_event: impl Fn(GradientEvent) -> Msg,
215    ) {
216        let event = self.show_internal(ctx.ui);
217        if let Some(e) = event {
218            ctx.emit(on_event(e));
219        }
220    }
221
222    pub fn show(self, ui: &mut Ui) -> Option<GradientEvent> {
223        self.show_internal(ui)
224    }
225
226    fn show_internal(self, ui: &mut Ui) -> Option<GradientEvent> {
227        let theme = Theme::current(ui.ctx());
228        let mut event: Option<GradientEvent> = None;
229
230        let stop_handle_size = theme.spacing_md;
231        let stop_area_height = stop_handle_size + theme.spacing_xs;
232        let values_height = if self.show_stop_values {
233            theme.font_size_xs + theme.spacing_xs
234        } else {
235            0.0
236        };
237
238        let total_height = self.height + stop_area_height + values_height + theme.spacing_xs;
239
240        let (rect, _response) =
241            ui.allocate_exact_size(Vec2::new(self.width, total_height), Sense::hover());
242
243        if !ui.is_rect_visible(rect) {
244            return None;
245        }
246
247        // Gradient bar area
248        let bar_rect = Rect::from_min_size(
249            Pos2::new(rect.min.x, rect.min.y),
250            Vec2::new(self.width, self.height),
251        );
252
253        // First pass: collect interactions
254        let bar_response = if self.editable {
255            let resp = ui.allocate_rect(bar_rect, Sense::click());
256            Some((resp.double_clicked(), resp.interact_pointer_pos()))
257        } else {
258            None
259        };
260
261        // Stop handle interactions
262        struct StopInfo {
263            idx: usize,
264            stop_x: f32,
265            hovered: bool,
266            dragged: bool,
267            drag_pos: Option<Pos2>,
268            clicked: bool,
269            secondary_clicked: bool,
270        }
271
272        let stop_y = bar_rect.max.y + theme.spacing_xs;
273        let mut stop_infos: Vec<StopInfo> = Vec::with_capacity(self.gradient.stops.len());
274
275        for (idx, stop) in self.gradient.stops.iter().enumerate() {
276            let stop_x = match self.direction {
277                GradientDirection::Horizontal => bar_rect.min.x + stop.position * bar_rect.width(),
278                GradientDirection::Vertical => bar_rect.center().x,
279            };
280
281            let handle_pos = Pos2::new(stop_x, stop_y);
282            let handle_rect =
283                Rect::from_center_size(handle_pos, Vec2::new(stop_handle_size, stop_handle_size));
284
285            if self.editable {
286                let resp = ui.allocate_rect(handle_rect.expand(4.0), Sense::click_and_drag());
287                stop_infos.push(StopInfo {
288                    idx,
289                    stop_x,
290                    hovered: resp.hovered(),
291                    dragged: resp.dragged(),
292                    drag_pos: resp.interact_pointer_pos(),
293                    clicked: resp.clicked(),
294                    secondary_clicked: resp.secondary_clicked(),
295                });
296            } else {
297                stop_infos.push(StopInfo {
298                    idx,
299                    stop_x,
300                    hovered: false,
301                    dragged: false,
302                    drag_pos: None,
303                    clicked: false,
304                    secondary_clicked: false,
305                });
306            }
307        }
308
309        // Second pass: draw everything
310        let painter = ui.painter();
311
312        // Draw checkerboard for transparency
313        let checker_size = 8.0;
314        let cols = (bar_rect.width() / checker_size) as usize + 1;
315        let rows = (bar_rect.height() / checker_size) as usize + 1;
316        for row in 0..rows {
317            for col in 0..cols {
318                let is_dark = (row + col) % 2 == 0;
319                let color = if is_dark {
320                    Color32::from_gray(60)
321                } else {
322                    Color32::from_gray(100)
323                };
324                let check_rect = Rect::from_min_size(
325                    Pos2::new(
326                        bar_rect.min.x + col as f32 * checker_size,
327                        bar_rect.min.y + row as f32 * checker_size,
328                    ),
329                    Vec2::splat(checker_size),
330                )
331                .intersect(bar_rect);
332                painter.rect_filled(check_rect, 0.0, color);
333            }
334        }
335
336        // Draw gradient
337        let steps = 64;
338        for i in 0..steps {
339            let t1 = i as f32 / steps as f32;
340            let t2 = (i + 1) as f32 / steps as f32;
341
342            let (x1, x2) = match self.direction {
343                GradientDirection::Horizontal => (
344                    bar_rect.min.x + t1 * bar_rect.width(),
345                    bar_rect.min.x + t2 * bar_rect.width(),
346                ),
347                GradientDirection::Vertical => (bar_rect.min.x, bar_rect.max.x),
348            };
349
350            let (y1, y2) = match self.direction {
351                GradientDirection::Horizontal => (bar_rect.min.y, bar_rect.max.y),
352                GradientDirection::Vertical => (
353                    bar_rect.min.y + t1 * bar_rect.height(),
354                    bar_rect.min.y + t2 * bar_rect.height(),
355                ),
356            };
357
358            let color = self.gradient.sample(t1);
359            painter.rect_filled(
360                Rect::from_min_max(Pos2::new(x1, y1), Pos2::new(x2 + 1.0, y2)),
361                0.0,
362                color,
363            );
364        }
365
366        // Border
367        painter.rect_stroke(
368            bar_rect,
369            theme.radius_sm,
370            Stroke::new(theme.border_width, theme.border),
371            egui::StrokeKind::Inside,
372        );
373
374        // Handle bar click to add stop
375        if let Some((double_clicked, pos)) = bar_response {
376            if double_clicked {
377                if let Some(pos) = pos {
378                    let t = match self.direction {
379                        GradientDirection::Horizontal => {
380                            (pos.x - bar_rect.min.x) / bar_rect.width()
381                        }
382                        GradientDirection::Vertical => (pos.y - bar_rect.min.y) / bar_rect.height(),
383                    };
384                    event = Some(GradientEvent::AddStop(t.clamp(0.0, 1.0)));
385                }
386            }
387        }
388
389        // Draw stop handles
390        for (info, stop) in stop_infos.iter().zip(self.gradient.stops.iter()) {
391            let is_selected = self.selected_stop == Some(info.idx);
392
393            // Draw triangle pointing up
394            let tri_height = stop_handle_size * 0.6;
395            let tri_half_width = stop_handle_size * 0.5;
396            let tri_top = Pos2::new(info.stop_x, bar_rect.max.y);
397            let tri_left = Pos2::new(info.stop_x - tri_half_width, bar_rect.max.y + tri_height);
398            let tri_right = Pos2::new(info.stop_x + tri_half_width, bar_rect.max.y + tri_height);
399
400            let is_hovered = info.hovered || info.dragged;
401
402            painter.add(egui::Shape::convex_polygon(
403                vec![tri_top, tri_left, tri_right],
404                stop.color,
405                Stroke::NONE,
406            ));
407
408            let outline_color = if is_selected {
409                theme.primary
410            } else if is_hovered {
411                theme.text_primary
412            } else {
413                theme.border
414            };
415            painter.add(egui::Shape::closed_line(
416                vec![tri_top, tri_left, tri_right],
417                Stroke::new(if is_selected { 2.0 } else { 1.0 }, outline_color),
418            ));
419
420            // Color swatch below triangle
421            let swatch_rect = Rect::from_min_size(
422                Pos2::new(info.stop_x - stop_handle_size / 2.0, tri_right.y + 2.0),
423                Vec2::new(stop_handle_size, stop_handle_size / 2.0),
424            );
425            painter.rect_filled(swatch_rect, theme.radius_sm, stop.color);
426            painter.rect_stroke(
427                swatch_rect,
428                theme.radius_sm,
429                Stroke::new(1.0, outline_color),
430                egui::StrokeKind::Outside,
431            );
432
433            // Position value on hover
434            if self.show_stop_values && is_hovered {
435                let value_text = format!("{:.0}%", stop.position * 100.0);
436                painter.text(
437                    Pos2::new(info.stop_x, swatch_rect.max.y + theme.spacing_xs),
438                    egui::Align2::CENTER_TOP,
439                    &value_text,
440                    egui::FontId::proportional(theme.font_size_xs),
441                    theme.text_muted,
442                );
443            }
444        }
445
446        // Connection lines between stops
447        if self.gradient.stops.len() > 1 {
448            let line_y = stop_y + stop_handle_size * 0.3;
449            for i in 0..self.gradient.stops.len() - 1 {
450                let x1 = bar_rect.min.x + self.gradient.stops[i].position * bar_rect.width();
451                let x2 = bar_rect.min.x + self.gradient.stops[i + 1].position * bar_rect.width();
452                painter.line_segment(
453                    [
454                        Pos2::new(x1 + stop_handle_size / 2.0, line_y),
455                        Pos2::new(x2 - stop_handle_size / 2.0, line_y),
456                    ],
457                    Stroke::new(1.0, theme.border),
458                );
459            }
460        }
461
462        // Info text
463        let info_text = format!("{} stops", self.gradient.stops.len());
464        painter.text(
465            Pos2::new(rect.max.x - theme.spacing_sm, rect.min.y + theme.spacing_xs),
466            egui::Align2::RIGHT_TOP,
467            &info_text,
468            egui::FontId::proportional(theme.font_size_xs),
469            theme.text_muted,
470        );
471
472        // Process stop handle events
473        for info in stop_infos.iter() {
474            if event.is_some() {
475                break;
476            }
477
478            if info.clicked {
479                event = Some(GradientEvent::SelectStop(Some(info.idx)));
480            } else if info.dragged {
481                if let Some(pos) = info.drag_pos {
482                    let new_pos = match self.direction {
483                        GradientDirection::Horizontal => {
484                            (pos.x - bar_rect.min.x) / bar_rect.width()
485                        }
486                        GradientDirection::Vertical => (pos.y - bar_rect.min.y) / bar_rect.height(),
487                    };
488                    event = Some(GradientEvent::MoveStop {
489                        index: info.idx,
490                        position: new_pos.clamp(0.0, 1.0),
491                    });
492                }
493            } else if info.secondary_clicked && self.gradient.stops.len() > 2 {
494                event = Some(GradientEvent::RemoveStop(info.idx));
495            }
496        }
497
498        event
499    }
500}