Skip to main content

egui_cha_ds/atoms/visual/
layer_stack.rs

1//! LayerStack atom - VJ layer management with blend modes
2//!
3//! A component for managing visual layers with opacity, visibility,
4//! blend modes, and drag-to-reorder functionality.
5//!
6//! # Features
7//! - Layer visibility toggle
8//! - Opacity control per layer
9//! - Blend mode selection
10//! - Drag-to-reorder layers
11//! - Solo/Lock functionality
12//! - Theme-aware styling
13//!
14//! # Example
15//! ```ignore
16//! LayerStack::new(&layers)
17//!     .selected(model.selected_layer)
18//!     .show_with(ctx, |event| match event {
19//!         LayerEvent::Select(idx) => Msg::SelectLayer(idx),
20//!         LayerEvent::Reorder { from, to } => Msg::ReorderLayers(from, to),
21//!         LayerEvent::SetOpacity(idx, val) => Msg::SetOpacity(idx, val),
22//!         LayerEvent::ToggleVisible(idx) => Msg::ToggleVisible(idx),
23//!         LayerEvent::SetBlendMode(idx, mode) => Msg::SetBlendMode(idx, mode),
24//!     });
25//! ```
26
27use crate::Theme;
28use egui::{Color32, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2};
29use egui_cha::ViewCtx;
30
31/// Drag state for reordering layers
32#[derive(Clone, Debug, Default)]
33struct LayerDragState {
34    /// Index of layer being dragged
35    dragging: Option<usize>,
36    /// Current drop target index (where to insert)
37    drop_target: Option<usize>,
38}
39
40/// Blend modes for layer compositing
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum BlendMode {
43    #[default]
44    Normal,
45    Add,
46    Multiply,
47    Screen,
48    Overlay,
49    Difference,
50    Exclusion,
51    ColorDodge,
52    ColorBurn,
53}
54
55impl BlendMode {
56    /// Get short display name
57    pub fn short_name(&self) -> &'static str {
58        match self {
59            BlendMode::Normal => "Norm",
60            BlendMode::Add => "Add",
61            BlendMode::Multiply => "Mul",
62            BlendMode::Screen => "Scr",
63            BlendMode::Overlay => "Ovl",
64            BlendMode::Difference => "Diff",
65            BlendMode::Exclusion => "Excl",
66            BlendMode::ColorDodge => "Dodg",
67            BlendMode::ColorBurn => "Burn",
68        }
69    }
70
71    /// Get all blend modes
72    pub fn all() -> &'static [BlendMode] {
73        &[
74            BlendMode::Normal,
75            BlendMode::Add,
76            BlendMode::Multiply,
77            BlendMode::Screen,
78            BlendMode::Overlay,
79            BlendMode::Difference,
80            BlendMode::Exclusion,
81            BlendMode::ColorDodge,
82            BlendMode::ColorBurn,
83        ]
84    }
85}
86
87/// A single layer's data
88#[derive(Debug, Clone)]
89pub struct Layer {
90    pub name: String,
91    pub visible: bool,
92    pub locked: bool,
93    pub solo: bool,
94    pub opacity: f32,
95    pub blend_mode: BlendMode,
96    pub color: Option<Color32>,
97    pub thumbnail: Option<egui::TextureId>,
98}
99
100impl Layer {
101    pub fn new(name: impl Into<String>) -> Self {
102        Self {
103            name: name.into(),
104            visible: true,
105            locked: false,
106            solo: false,
107            opacity: 1.0,
108            blend_mode: BlendMode::Normal,
109            color: None,
110            thumbnail: None,
111        }
112    }
113
114    pub fn with_opacity(mut self, opacity: f32) -> Self {
115        self.opacity = opacity.clamp(0.0, 1.0);
116        self
117    }
118
119    pub fn with_blend_mode(mut self, mode: BlendMode) -> Self {
120        self.blend_mode = mode;
121        self
122    }
123
124    pub fn with_color(mut self, color: Color32) -> Self {
125        self.color = Some(color);
126        self
127    }
128
129    pub fn with_visible(mut self, visible: bool) -> Self {
130        self.visible = visible;
131        self
132    }
133}
134
135/// Events emitted by LayerStack
136#[derive(Debug, Clone)]
137pub enum LayerEvent {
138    /// Layer was selected
139    Select(usize),
140    /// Layer visibility toggled
141    ToggleVisible(usize),
142    /// Layer locked state toggled
143    ToggleLock(usize),
144    /// Layer solo state toggled
145    ToggleSolo(usize),
146    /// Layer opacity changed
147    SetOpacity(usize, f32),
148    /// Layer blend mode changed
149    SetBlendMode(usize, BlendMode),
150    /// Layers reordered (from, to)
151    Reorder { from: usize, to: usize },
152    /// Add new layer requested
153    AddLayer,
154    /// Delete layer requested
155    DeleteLayer(usize),
156    /// Duplicate layer requested
157    DuplicateLayer(usize),
158}
159
160/// VJ-style layer stack with blend modes and opacity
161pub struct LayerStack<'a> {
162    layers: &'a [Layer],
163    selected: Option<usize>,
164    row_height: f32,
165    show_thumbnails: bool,
166    show_blend_modes: bool,
167    show_controls: bool,
168    compact: bool,
169}
170
171impl<'a> LayerStack<'a> {
172    /// Create a new layer stack
173    pub fn new(layers: &'a [Layer]) -> Self {
174        Self {
175            layers,
176            selected: None,
177            row_height: 40.0,
178            show_thumbnails: true,
179            show_blend_modes: true,
180            show_controls: true,
181            compact: false,
182        }
183    }
184
185    /// Set selected layer index
186    pub fn selected(mut self, index: Option<usize>) -> Self {
187        self.selected = index;
188        self
189    }
190
191    /// Set row height
192    pub fn row_height(mut self, height: f32) -> Self {
193        self.row_height = height;
194        self
195    }
196
197    /// Show/hide layer thumbnails
198    pub fn show_thumbnails(mut self, show: bool) -> Self {
199        self.show_thumbnails = show;
200        self
201    }
202
203    /// Show/hide blend mode selector
204    pub fn show_blend_modes(mut self, show: bool) -> Self {
205        self.show_blend_modes = show;
206        self
207    }
208
209    /// Show/hide layer controls (add/delete buttons)
210    pub fn show_controls(mut self, show: bool) -> Self {
211        self.show_controls = show;
212        self
213    }
214
215    /// Use compact mode (smaller UI)
216    pub fn compact(mut self, compact: bool) -> Self {
217        self.compact = compact;
218        if compact {
219            self.row_height = 28.0;
220            self.show_thumbnails = false;
221        }
222        self
223    }
224
225    /// TEA-style: Show layer stack, emit events
226    pub fn show_with<Msg>(self, ctx: &mut ViewCtx<'_, Msg>, on_event: impl Fn(LayerEvent) -> Msg) {
227        let event = self.show_internal(ctx.ui);
228        if let Some(e) = event {
229            ctx.emit(on_event(e));
230        }
231    }
232
233    /// Show layer stack and return event
234    pub fn show(self, ui: &mut Ui) -> Option<LayerEvent> {
235        self.show_internal(ui)
236    }
237
238    fn show_internal(self, ui: &mut Ui) -> Option<LayerEvent> {
239        let theme = Theme::current(ui.ctx());
240        let mut event: Option<LayerEvent> = None;
241
242        let row_height = if self.compact {
243            theme.spacing_lg + theme.spacing_sm
244        } else {
245            self.row_height
246        };
247
248        // Calculate widths
249        let available_width = ui.available_width();
250        let _thumbnail_width = if self.show_thumbnails {
251            row_height
252        } else {
253            0.0
254        };
255        let visibility_width = theme.spacing_lg;
256        let lock_width = theme.spacing_lg;
257        let opacity_width = if self.compact { 40.0 } else { 60.0 };
258        let blend_width = if self.show_blend_modes { 45.0 } else { 0.0 };
259
260        // Drag state management
261        let drag_id = Id::new("layer_stack_drag");
262        let mut drag_state: LayerDragState = ui
263            .ctx()
264            .data_mut(|d| d.get_temp(drag_id).unwrap_or_default());
265
266        // Header with controls
267        if self.show_controls {
268            ui.horizontal(|ui| {
269                ui.label(
270                    egui::RichText::new("Layers")
271                        .size(theme.font_size_sm)
272                        .color(theme.text_secondary),
273                );
274                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
275                    let add_btn =
276                        ui.add(egui::Button::new("+").min_size(Vec2::splat(theme.spacing_lg)));
277                    if add_btn.clicked() {
278                        event = Some(LayerEvent::AddLayer);
279                    }
280                });
281            });
282            ui.add_space(theme.spacing_xs);
283        }
284
285        // Collect layer info in first pass
286        struct LayerInfo {
287            idx: usize,
288            rect: Rect,
289            row_hovered: bool,
290            row_clicked: bool,
291            row_drag_started: bool,
292            row_dragged: bool,
293            vis_rect: Rect,
294            vis_hovered: bool,
295            vis_clicked: bool,
296            lock_rect: Rect,
297            lock_hovered: bool,
298            lock_clicked: bool,
299            blend_rect: Option<Rect>,
300            blend_hovered: bool,
301            blend_clicked: bool,
302            opacity_rect: Rect,
303            opacity_hovered: bool,
304            opacity_dragged: bool,
305            opacity_drag_pos: Option<Pos2>,
306        }
307
308        let mut layer_infos: Vec<LayerInfo> = Vec::with_capacity(self.layers.len());
309
310        // First pass: allocate and collect interactions
311        for (idx, _layer) in self.layers.iter().enumerate() {
312            let (rect, response) = ui.allocate_exact_size(
313                Vec2::new(available_width, row_height),
314                Sense::click_and_drag(),
315            );
316
317            if !ui.is_rect_visible(rect) {
318                continue;
319            }
320
321            let mut x_offset = rect.min.x + theme.spacing_xs;
322
323            // Visibility rect
324            let vis_rect = Rect::from_min_size(
325                Pos2::new(x_offset, rect.min.y),
326                Vec2::new(visibility_width, row_height),
327            );
328            let vis_response = ui.allocate_rect(vis_rect, Sense::click());
329            x_offset += visibility_width;
330
331            // Lock rect
332            let lock_rect = Rect::from_min_size(
333                Pos2::new(x_offset, rect.min.y),
334                Vec2::new(lock_width, row_height),
335            );
336            let lock_response = ui.allocate_rect(lock_rect, Sense::click());
337            // x_offset no longer needed after this point
338
339            // Blend mode rect
340            let blend_rect = if self.show_blend_modes {
341                Some(Rect::from_min_size(
342                    Pos2::new(
343                        rect.max.x - opacity_width - blend_width - theme.spacing_xs,
344                        rect.min.y,
345                    ),
346                    Vec2::new(blend_width, row_height),
347                ))
348            } else {
349                None
350            };
351            let blend_response = blend_rect.map(|r| ui.allocate_rect(r, Sense::click()));
352
353            // Opacity rect
354            let opacity_rect = Rect::from_min_size(
355                Pos2::new(
356                    rect.max.x - opacity_width - theme.spacing_xs,
357                    rect.min.y + row_height * 0.3,
358                ),
359                Vec2::new(opacity_width, row_height * 0.4),
360            );
361            let opacity_response = ui.allocate_rect(opacity_rect, Sense::click_and_drag());
362
363            layer_infos.push(LayerInfo {
364                idx,
365                rect,
366                row_hovered: response.hovered(),
367                row_clicked: response.clicked(),
368                row_drag_started: response.drag_started(),
369                row_dragged: response.dragged(),
370                vis_rect,
371                vis_hovered: vis_response.hovered(),
372                vis_clicked: vis_response.clicked(),
373                lock_rect,
374                lock_hovered: lock_response.hovered(),
375                lock_clicked: lock_response.clicked(),
376                blend_rect,
377                blend_hovered: blend_response.as_ref().map_or(false, |r| r.hovered()),
378                blend_clicked: blend_response.as_ref().map_or(false, |r| r.clicked()),
379                opacity_rect,
380                opacity_hovered: opacity_response.hovered(),
381                opacity_dragged: opacity_response.dragged(),
382                opacity_drag_pos: opacity_response.interact_pointer_pos(),
383            });
384        }
385
386        // Handle drag-to-reorder
387        let pointer_pos = ui.input(|i| i.pointer.hover_pos());
388        for info in &layer_infos {
389            // Start drag
390            if info.row_drag_started && drag_state.dragging.is_none() {
391                let layer = &self.layers[info.idx];
392                if !layer.locked {
393                    drag_state.dragging = Some(info.idx);
394                }
395            }
396
397            // Update drop target during drag
398            if drag_state.dragging.is_some() && info.row_hovered {
399                if let Some(pos) = pointer_pos {
400                    // Determine if we're in the top or bottom half
401                    let mid_y = info.rect.center().y;
402                    if pos.y < mid_y {
403                        drag_state.drop_target = Some(info.idx);
404                    } else {
405                        drag_state.drop_target = Some(info.idx + 1);
406                    }
407                }
408            }
409        }
410
411        // Complete drag on release
412        if !ui.input(|i| i.pointer.any_down()) {
413            if let (Some(from), Some(to)) = (drag_state.dragging, drag_state.drop_target) {
414                // Only emit if actually moved
415                if from != to && from + 1 != to {
416                    event = Some(LayerEvent::Reorder { from, to });
417                }
418            }
419            drag_state.dragging = None;
420            drag_state.drop_target = None;
421        }
422
423        // Second pass: draw everything
424        let painter = ui.painter();
425
426        for (info, layer) in layer_infos.iter().zip(self.layers.iter()) {
427            let is_selected = self.selected == Some(info.idx);
428
429            // Background
430            let bg_color = if is_selected {
431                theme.primary.gamma_multiply(0.2)
432            } else if info.row_hovered {
433                theme.bg_secondary
434            } else {
435                theme.bg_primary
436            };
437            painter.rect_filled(info.rect, theme.radius_sm, bg_color);
438
439            // Border for selected
440            if is_selected {
441                painter.rect_stroke(
442                    info.rect,
443                    theme.radius_sm,
444                    Stroke::new(theme.border_width, theme.primary),
445                    egui::StrokeKind::Inside,
446                );
447            }
448
449            // Visibility icon
450            let vis_color = if layer.visible {
451                if info.vis_hovered {
452                    theme.primary
453                } else {
454                    theme.text_primary
455                }
456            } else {
457                theme.text_muted
458            };
459            painter.text(
460                info.vis_rect.center(),
461                egui::Align2::CENTER_CENTER,
462                if layer.visible { "๐Ÿ‘" } else { "โ—‹" },
463                egui::FontId::proportional(theme.font_size_sm),
464                vis_color,
465            );
466
467            // Lock icon
468            let lock_color = if layer.locked {
469                theme.state_warning
470            } else if info.lock_hovered {
471                theme.text_secondary
472            } else {
473                theme.text_muted
474            };
475            painter.text(
476                info.lock_rect.center(),
477                egui::Align2::CENTER_CENTER,
478                if layer.locked { "๐Ÿ”’" } else { "ยท" },
479                egui::FontId::proportional(theme.font_size_xs),
480                lock_color,
481            );
482
483            // Thumbnail
484            if self.show_thumbnails {
485                let thumb_rect = Rect::from_min_size(
486                    Pos2::new(
487                        info.lock_rect.max.x + theme.spacing_xs,
488                        info.rect.min.y + theme.spacing_xs,
489                    ),
490                    Vec2::splat(row_height - theme.spacing_sm),
491                );
492                let thumb_color = layer.color.unwrap_or(theme.primary).gamma_multiply(0.5);
493                painter.rect_filled(thumb_rect, theme.radius_sm, thumb_color);
494                painter.rect_stroke(
495                    thumb_rect,
496                    theme.radius_sm,
497                    Stroke::new(0.5, theme.border),
498                    egui::StrokeKind::Inside,
499                );
500            }
501
502            // Layer name
503            let name_x = if self.show_thumbnails {
504                info.lock_rect.max.x + row_height + theme.spacing_sm
505            } else {
506                info.lock_rect.max.x + theme.spacing_sm
507            };
508
509            let name_color = if layer.visible {
510                if is_selected {
511                    theme.text_primary
512                } else {
513                    theme.text_secondary
514                }
515            } else {
516                theme.text_muted
517            };
518
519            let name_text = if layer.name.len() > 12 && self.compact {
520                format!("{}โ€ฆ", &layer.name[..11])
521            } else {
522                layer.name.clone()
523            };
524
525            painter.text(
526                Pos2::new(name_x, info.rect.center().y),
527                egui::Align2::LEFT_CENTER,
528                &name_text,
529                egui::FontId::proportional(if self.compact {
530                    theme.font_size_xs
531                } else {
532                    theme.font_size_sm
533                }),
534                name_color,
535            );
536
537            // Blend mode
538            if let Some(blend_rect) = info.blend_rect {
539                let blend_color = if info.blend_hovered {
540                    theme.primary
541                } else {
542                    theme.text_muted
543                };
544                painter.text(
545                    blend_rect.center(),
546                    egui::Align2::CENTER_CENTER,
547                    layer.blend_mode.short_name(),
548                    egui::FontId::proportional(theme.font_size_xs),
549                    blend_color,
550                );
551            }
552
553            // Opacity bar
554            painter.rect_filled(info.opacity_rect, theme.radius_sm, theme.bg_tertiary);
555
556            let fill_width = info.opacity_rect.width() * layer.opacity;
557            let fill_rect = Rect::from_min_size(
558                info.opacity_rect.min,
559                Vec2::new(fill_width, info.opacity_rect.height()),
560            );
561            let fill_color = if info.opacity_hovered || info.opacity_dragged {
562                theme.primary
563            } else {
564                theme.primary.gamma_multiply(0.7)
565            };
566            painter.rect_filled(fill_rect, theme.radius_sm, fill_color);
567
568            painter.text(
569                info.opacity_rect.center(),
570                egui::Align2::CENTER_CENTER,
571                format!("{}%", (layer.opacity * 100.0) as u8),
572                egui::FontId::proportional(theme.font_size_xs * 0.9),
573                theme.text_primary,
574            );
575
576            // Separator line
577            if info.idx < self.layers.len() - 1 {
578                painter.line_segment(
579                    [
580                        Pos2::new(info.rect.min.x + theme.spacing_sm, info.rect.max.y),
581                        Pos2::new(info.rect.max.x - theme.spacing_sm, info.rect.max.y),
582                    ],
583                    Stroke::new(0.5, theme.border),
584                );
585            }
586
587            // Drag overlay for dragged layer
588            if drag_state.dragging == Some(info.idx) {
589                painter.rect_filled(
590                    info.rect,
591                    theme.radius_sm,
592                    Color32::from_rgba_unmultiplied(
593                        theme.primary.r(),
594                        theme.primary.g(),
595                        theme.primary.b(),
596                        60,
597                    ),
598                );
599                painter.rect_stroke(
600                    info.rect,
601                    theme.radius_sm,
602                    Stroke::new(2.0, theme.primary),
603                    egui::StrokeKind::Inside,
604                );
605            }
606
607            // Drop indicator line
608            if let Some(drop_idx) = drag_state.drop_target {
609                if drop_idx == info.idx {
610                    // Draw line above this row
611                    painter.line_segment(
612                        [
613                            Pos2::new(info.rect.min.x, info.rect.min.y),
614                            Pos2::new(info.rect.max.x, info.rect.min.y),
615                        ],
616                        Stroke::new(3.0, theme.primary),
617                    );
618                } else if drop_idx == info.idx + 1 && info.idx == self.layers.len() - 1 {
619                    // Draw line below last row
620                    painter.line_segment(
621                        [
622                            Pos2::new(info.rect.min.x, info.rect.max.y),
623                            Pos2::new(info.rect.max.x, info.rect.max.y),
624                        ],
625                        Stroke::new(3.0, theme.primary),
626                    );
627                }
628            }
629
630            // Handle events (only if not dragging)
631            if event.is_none() && drag_state.dragging.is_none() {
632                if info.row_clicked {
633                    event = Some(LayerEvent::Select(info.idx));
634                } else if info.vis_clicked {
635                    event = Some(LayerEvent::ToggleVisible(info.idx));
636                } else if info.lock_clicked {
637                    event = Some(LayerEvent::ToggleLock(info.idx));
638                } else if info.blend_clicked {
639                    let modes = BlendMode::all();
640                    let current_idx = modes
641                        .iter()
642                        .position(|&m| m == layer.blend_mode)
643                        .unwrap_or(0);
644                    let next_idx = (current_idx + 1) % modes.len();
645                    event = Some(LayerEvent::SetBlendMode(info.idx, modes[next_idx]));
646                } else if info.opacity_dragged {
647                    if let Some(pos) = info.opacity_drag_pos {
648                        let new_opacity = ((pos.x - info.opacity_rect.min.x)
649                            / info.opacity_rect.width())
650                        .clamp(0.0, 1.0);
651                        event = Some(LayerEvent::SetOpacity(info.idx, new_opacity));
652                    }
653                }
654            }
655        }
656
657        // Save drag state
658        ui.ctx().data_mut(|d| d.insert_temp(drag_id, drag_state));
659
660        event
661    }
662}