Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs

334 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<usize>,
) -> 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<Rect> = (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<usize> = 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);
}
}
}