//! Gradient stop editor widget. //! //! Call [`gradient_stop_editor`] inside any egui layout; it returns `true` when //! `gradient` was modified. use eframe::egui::{self, Color32, DragValue, Painter, Rect, Sense, Stroke, Vec2}; use lightningbeam_core::gradient::{GradientExtend, GradientStop, GradientType, ShapeGradient}; use lightningbeam_core::shape::ShapeColor; // ── Public entry point ─────────────────────────────────────────────────────── /// Render an inline gradient editor. /// /// * `gradient` – the gradient being edited (mutated in place). /// * `selected_stop` – index of the currently selected stop (persisted by caller). /// /// Returns `true` if anything changed. pub fn gradient_stop_editor( ui: &mut egui::Ui, gradient: &mut ShapeGradient, selected_stop: &mut Option, ) -> bool { let mut changed = false; // ── Row 1: Kind + angle ─────────────────────────────────────────────── ui.horizontal(|ui| { let was_linear = gradient.kind == GradientType::Linear; if ui.selectable_label(was_linear, "Linear").clicked() && !was_linear { gradient.kind = GradientType::Linear; changed = true; } if ui.selectable_label(!was_linear, "Radial").clicked() && was_linear { gradient.kind = GradientType::Radial; changed = true; } if gradient.kind == GradientType::Linear { ui.add_space(8.0); ui.label("Angle:"); if ui.add( DragValue::new(&mut gradient.angle) .speed(1.0) .range(-360.0..=360.0) .suffix("°"), ).changed() { changed = true; } } }); // ── Gradient bar + handles ──────────────────────────────────────────── let bar_height = 22.0_f32; let peak_h = 7.0_f32; // triangular roof height let body_h = 12.0_f32; // rectangular body height let handle_h = peak_h + body_h; let body_half_w = 6.0_f32; let right_pad = 10.0_f32; // keep rightmost stop clear of infopanel scrollbar let total_height = bar_height + handle_h + 4.0; let avail_w = ui.available_width() - right_pad; let (bar_rect, bar_resp) = ui.allocate_exact_size( Vec2::new(avail_w, total_height), Sense::click(), ); let painter = ui.painter_at(bar_rect); let bar = Rect::from_min_size(bar_rect.min, Vec2::new(avail_w, bar_height)); let track = Rect::from_min_size( egui::pos2(bar_rect.min.x, bar_rect.min.y + bar_height + 2.0), Vec2::new(avail_w, handle_h), ); // Draw checkerboard background (transparent indicator). draw_checker(&painter, bar); // Draw gradient bar as a mesh: one quad per stop-pair with vertex colours // so the GPU interpolates linearly — no segmentation artefacts. { use egui::epaint::{Mesh, Vertex}; let mut mesh = Mesh::default(); let stops = &gradient.stops; let color_at = |t: f32| -> Color32 { let [r, g, b, a] = gradient.eval(t); Color32::from_rgba_unmultiplied(r, g, b, a) }; // One quad for each consecutive stop pair. for pair in stops.windows(2) { let t0 = pair[0].position; let t1 = pair[1].position; let c0 = color_at(t0); let c1 = color_at(t1); let x0 = bar.min.x + t0 * bar.width(); let x1 = bar.min.x + t1 * bar.width(); let base = mesh.vertices.len() as u32; mesh.vertices.extend_from_slice(&[ Vertex { pos: egui::pos2(x0, bar.min.y), uv: egui::Pos2::ZERO, color: c0 }, Vertex { pos: egui::pos2(x1, bar.min.y), uv: egui::Pos2::ZERO, color: c1 }, Vertex { pos: egui::pos2(x1, bar.max.y), uv: egui::Pos2::ZERO, color: c1 }, Vertex { pos: egui::pos2(x0, bar.max.y), uv: egui::Pos2::ZERO, color: c0 }, ]); mesh.indices.extend_from_slice(&[base, base+1, base+2, base, base+2, base+3]); } painter.add(egui::Shape::mesh(mesh)); } // Outline. painter.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_gray(60)), eframe::egui::StrokeKind::Middle); // Click on bar → add stop. if bar_resp.clicked() { if let Some(pos) = bar_resp.interact_pointer_pos() { if bar.contains(pos) { let t = ((pos.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); let [r, g, b, a] = gradient.eval(t); gradient.stops.push(GradientStop { position: t, color: ShapeColor::rgba(r, g, b, a), }); gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); *selected_stop = gradient.stops.iter().position(|s| (s.position - t).abs() < 1e-5); changed = true; } } } // ── Stop handles: interact + popup ─────────────────────────────────── let n_stops = gradient.stops.len(); // Top-anchored hit rects (peak touches track.min.y). let handle_rects: Vec = (0..n_stops).map(|i| { let cx = track.min.x + gradient.stops[i].position * track.width(); Rect::from_min_size( egui::pos2(cx - body_half_w, track.min.y), Vec2::new(body_half_w * 2.0, handle_h), ) }).collect(); let mut drag_delta : f32 = 0.0; let mut drag_active: bool = false; let mut drag_ended : bool = false; let mut delete_idx : Option = None; for (i, &h_rect) in handle_rects.iter().enumerate() { let resp = ui.interact(h_rect, ui.id().with(("grad_handle", i)), Sense::click_and_drag()); // Anchor the dragged stop at drag-start time, before any sort can change indices. if resp.drag_started() { *selected_stop = Some(i); } if resp.dragged() { drag_delta = resp.drag_delta().x / track.width(); drag_active = true; } if resp.drag_stopped() { drag_ended = true; } if resp.clicked() { *selected_stop = Some(i); } // Right-click on an interior stop (not the first or last) deletes it. if resp.secondary_clicked() && i > 0 && i < n_stops - 1 { delete_idx = Some(i); } // Color picker popup — opens on click, closes on click-outside. egui::containers::Popup::from_toggle_button_response(&resp) .show(|ui| { ui.spacing_mut().slider_width = 200.0; let stop = &mut gradient.stops[i]; let mut c32 = Color32::from_rgba_unmultiplied( stop.color.r, stop.color.g, stop.color.b, stop.color.a, ); if egui::color_picker::color_picker_color32( ui, &mut c32, egui::color_picker::Alpha::OnlyBlend, ) { // Color32 stores premultiplied RGB; unmultiply before storing // as straight-alpha ShapeColor to avoid darkening on round-trip. let [pr, pg, pb, a] = c32.to_array(); let unpm = |c: u8| -> u8 { if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } }; stop.color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); changed = true; } }); } // Apply drag to whichever stop selected_stop points at. // Using selected_stop (anchored at drag_started) instead of the widget index // means sorting never causes a different stop to be dragged when the dragged // stop passes over a neighbour. if drag_active { if let Some(cur) = *selected_stop { if drag_delta != 0.0 { let new_pos = (gradient.stops[cur].position + drag_delta).clamp(0.0, 1.0); gradient.stops[cur].position = new_pos; gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); // Re-find the moved stop by its new position so selected_stop stays correct. *selected_stop = gradient.stops.iter() .position(|s| (s.position - new_pos).abs() < 1e-5); changed = true; } } } // Merge-on-drop: if the dragged stop was released within one handle-width of // another stop, delete that other stop (provided ≥ 3 stops remain). if drag_ended { if let Some(cur) = *selected_stop { if gradient.stops.len() > 2 { let my_pos = gradient.stops[cur].position; let merge_thresh = body_half_w / track.width(); if let Some(victim) = gradient.stops.iter().enumerate() .find(|&(j, s)| j != cur && (s.position - my_pos).abs() < merge_thresh) .map(|(j, _)| j) { gradient.stops.remove(victim); if victim < cur { *selected_stop = Some(cur - 1); } changed = true; } } } } // Apply right-click delete (after loop to avoid borrow conflicts). if let Some(i) = delete_idx { gradient.stops.remove(i); if *selected_stop == Some(i) { *selected_stop = None; } else if let Some(sel) = *selected_stop { if sel > i { *selected_stop = Some(sel - 1); } } changed = true; } // ── Paint handles ───────────────────────────────────────────────────── // handle_rects was built before any deletions this frame; guard against OOB. for (i, h_rect) in handle_rects.iter().enumerate().take(gradient.stops.len()) { let col = shape_color_to_color32(gradient.stops[i].color); let is_selected = *selected_stop == Some(i); let stroke = Stroke::new( if is_selected { 2.0 } else { 1.0 }, if is_selected { Color32::WHITE } else { Color32::from_gray(80) }, ); let cx = h_rect.center().x; let apex = egui::pos2(cx, track.min.y); let shoulder_y = track.min.y + peak_h; let bottom_y = track.min.y + handle_h; // Convex pentagon: apex → upper-right → lower-right → lower-left → upper-left painter.add(egui::Shape::convex_polygon( vec![ apex, egui::pos2(cx + body_half_w, shoulder_y), egui::pos2(cx + body_half_w, bottom_y), egui::pos2(cx - body_half_w, bottom_y), egui::pos2(cx - body_half_w, shoulder_y), ], col, stroke, )); } // ── Selected stop detail (position + remove) ────────────────────────── if let Some(i) = *selected_stop { if i < gradient.stops.len() { ui.separator(); ui.horizontal(|ui| { let stop = &mut gradient.stops[i]; ui.label("Position:"); if ui.add( DragValue::new(&mut stop.position) .speed(0.005) .range(0.0..=1.0), ).changed() { gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); changed = true; } let can_remove = gradient.stops.len() > 2; if ui.add_enabled(can_remove, egui::Button::new("− Remove")).clicked() { gradient.stops.remove(i); *selected_stop = None; changed = true; } }); } else { *selected_stop = None; } } // ── Extend mode ─────────────────────────────────────────────────────── ui.horizontal(|ui| { ui.label("Extend:"); if ui.selectable_label(gradient.extend == GradientExtend::Pad, "Pad").clicked() { gradient.extend = GradientExtend::Pad; changed = true; } if ui.selectable_label(gradient.extend == GradientExtend::Reflect, "Reflect").clicked() { gradient.extend = GradientExtend::Reflect; changed = true; } if ui.selectable_label(gradient.extend == GradientExtend::Repeat, "Repeat").clicked() { gradient.extend = GradientExtend::Repeat; changed = true; } }); changed } // ── Helpers ────────────────────────────────────────────────────────────────── fn shape_color_to_color32(c: ShapeColor) -> Color32 { Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a) } /// Draw a small grey/white checkerboard inside `rect`. fn draw_checker(painter: &Painter, rect: Rect) { let cell = 6.0_f32; let cols = ((rect.width() / cell).ceil() as u32).max(1); let rows = ((rect.height() / cell).ceil() as u32).max(1); for row in 0..rows { for col in 0..cols { let light = (row + col) % 2 == 0; let col32 = if light { Color32::from_gray(200) } else { Color32::from_gray(140) }; let x = rect.min.x + col as f32 * cell; let y = rect.min.y + row as f32 * cell; let r = Rect::from_min_size( egui::pos2(x, y), Vec2::splat(cell), ).intersect(rect); painter.rect_filled(r, 0.0, col32); } } }