egui_colorgradient/
lib.rs

1//! Gradient editor widget for [egui](https://www.egui.rs/).
2
3use cache::FrameCacheDyn;
4use egui::Popup;
5use egui::color_picker::{Alpha, color_picker_hsva_2d};
6use egui::ecolor::Hsva;
7use egui::style::WidgetVisuals;
8use egui::{
9    Button, Color32, ColorImage, ComboBox, Id, LayerId, Mesh, Order, Painter, PointerButton, Rect,
10    Sense, Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, Vec2, lerp, pos2, vec2,
11};
12pub use gradient::{ColorInterpolator, Gradient, InterpolationMethod};
13
14mod cache;
15mod gradient;
16const TICK_OFFSET: f32 = 8.;
17
18const TICK_SQUARE_SIZE: f32 = 12.;
19
20const CHECKER_SIZE: f32 = 15.0;
21
22fn background_checkers(painter: &Painter, rect: Rect) {
23    let tex_mgr = painter.ctx().tex_manager();
24    let texture = painter.ctx().memory_mut(|mem| {
25        const NAME: &str = "checker_background";
26
27        let cache = mem.caches.cache::<FrameCacheDyn<TextureHandle, 1>>();
28        cache.get_or_else_insert(NAME, || {
29            let dark_color = Color32::from_gray(32);
30            let bright_color = Color32::from_gray(128);
31            let data = [dark_color, bright_color, bright_color, dark_color]
32                .iter()
33                .flat_map(|c| c.to_array())
34                .collect::<Vec<_>>();
35            let img = ColorImage::from_rgba_premultiplied([2, 2], &data);
36            let tex_id =
37                tex_mgr
38                    .write()
39                    .alloc(NAME.to_string(), img.into(), TextureOptions::NEAREST_REPEAT);
40            TextureHandle::new(tex_mgr, tex_id)
41        })
42    });
43    let mut mesh = Mesh::with_texture(texture.id());
44    let rect = rect.shrink(0.5); // Small hack to avoid the checkers from peeking through the sides
45    let uv = Rect::from_min_max(rect.min / CHECKER_SIZE, rect.max / CHECKER_SIZE);
46    mesh.add_rect_with_uv(rect, uv, Color32::WHITE);
47
48    painter.add(Shape::mesh(mesh));
49}
50
51fn draw_gradient(ui: &mut Ui, gradient: &Gradient, rect: Rect, use_alpha: bool) {
52    const TEX_SIZE: usize = 256;
53
54    let texture_mgr = ui.ctx().tex_manager();
55    let texture_cache_id = ui.auto_id_with("gradient_texture").with(use_alpha);
56    let texture = ui.memory_mut(|mem| {
57        let cache = mem.caches.cache::<FrameCacheDyn<TextureHandle, 1>>();
58        let mut tex = cache.get_or_else_insert(texture_cache_id, || {
59            let img = ColorImage::filled([TEX_SIZE, 1], Color32::BLACK);
60            let tex_id = texture_mgr.write().alloc(
61                "gradient".to_string(),
62                img.into(),
63                TextureOptions::LINEAR,
64            );
65            TextureHandle::new(texture_mgr, tex_id)
66        });
67        tex.set(
68            ColorImage::from_rgba_premultiplied(
69                [TEX_SIZE, 1],
70                &gradient
71                    .linear_eval(TEX_SIZE, !use_alpha)
72                    .iter()
73                    .flat_map(|c| c.to_array())
74                    .collect::<Vec<_>>(),
75            ),
76            TextureOptions::LINEAR,
77        );
78        tex
79    });
80
81    // draw rect using texture
82    let mut mesh = Mesh::with_texture(texture.id());
83    mesh.add_rect_with_uv(
84        rect,
85        Rect::from_min_max(pos2(0., 0.), pos2(1., 1.)),
86        Color32::WHITE,
87    );
88    ui.painter().add(Shape::mesh(mesh));
89}
90
91fn gradient_box(
92    ui: &mut Ui,
93    gradient: &mut Gradient,
94    gradient_rect: Rect,
95    visuals: &WidgetVisuals,
96) -> Option<usize> {
97    const SOLID_HEIGHT: f32 = 8.;
98    background_checkers(
99        ui.painter(),
100        gradient_rect.with_max_y(gradient_rect.bottom()),
101    );
102
103    let mut new_stop = None;
104
105    let response = ui.allocate_rect(gradient_rect, Sense::click());
106    if response.double_clicked_by(PointerButton::Primary) {
107        let x = response.interact_pointer_pos().unwrap().x;
108        let t = ((x - gradient_rect.left()) / gradient_rect.width()).clamp(0., 1.);
109        let color = gradient.interpolator().sample_at(t).unwrap();
110        new_stop = Some(gradient.stops.len());
111        gradient.stops.push((t, color.into()));
112    }
113
114    for (y, use_alpha) in [
115        gradient_rect.top(),
116        gradient_rect.bottom() - SOLID_HEIGHT,
117        gradient_rect.bottom(),
118    ]
119    .windows(2)
120    .zip([true, false])
121    {
122        let (top, bottom) = (y[0], y[1]);
123        draw_gradient(
124            ui,
125            gradient,
126            gradient_rect.with_min_y(top).with_max_y(bottom),
127            use_alpha,
128        );
129    }
130    ui.painter()
131        .rect_stroke(gradient_rect, 0.0, visuals.bg_stroke, StrokeKind::Middle); // outline
132
133    new_stop
134}
135
136fn control_widgets(ui: &mut Ui, gradient: &mut Gradient, selected_stop: &mut Option<usize>) {
137    ui.horizontal(|ui| {
138        let add_button = Button::new("➕");
139        let add_button_response = ui.add(add_button).on_hover_text("Add stop");
140        if add_button_response.clicked() {
141            let t = if gradient.stops.len() <= 1 {
142                0.5
143            } else {
144                let sorted_stops = gradient.argsort();
145                *selected_stop = selected_stop.map(|idx| sorted_stops[idx]);
146                gradient.sort();
147
148                let insertion_idx = selected_stop.unwrap_or(gradient.stops.len() - 1).max(1);
149                let right_t = gradient.stops[insertion_idx].0;
150                let left_t = if insertion_idx > 0 {
151                    gradient.stops[insertion_idx - 1].0
152                } else {
153                    0.
154                };
155                0.5 * (left_t + right_t)
156            };
157            let col = gradient.interpolator().sample_at(t).unwrap();
158            gradient.stops.push((t, col.into()));
159            *selected_stop = Some(gradient.stops.len() - 1);
160        };
161        let remove_button = Button::new("➖");
162        let can_remove = selected_stop.is_some() && gradient.stops.len() > 1;
163        if can_remove {
164            let remove_button_response = ui.add(remove_button);
165            if remove_button_response.clicked() {
166                gradient.stops.remove(selected_stop.unwrap());
167                *selected_stop = None;
168            }
169            remove_button_response
170        } else {
171            ui.add_enabled(false, remove_button)
172        }
173        .on_hover_text("Remove stop");
174
175        ComboBox::from_id_salt(ui.auto_id_with(0))
176            .selected_text(gradient.interpolation_method.to_string())
177            .show_ui(ui, |ui| {
178                ui.selectable_value(
179                    &mut gradient.interpolation_method,
180                    InterpolationMethod::Linear,
181                    InterpolationMethod::Linear.to_string(),
182                );
183                ui.selectable_value(
184                    &mut gradient.interpolation_method,
185                    InterpolationMethod::Constant,
186                    InterpolationMethod::Constant.to_string(),
187                );
188            })
189            .response
190            .on_hover_text("Interpolation method");
191    });
192}
193
194fn gradient_stop(
195    ui: &mut Ui,
196    rect: Rect,
197    idx: usize,
198    (t, color): (&mut f32, &mut Hsva),
199    selected_stop: &mut Option<usize>,
200    did_interact: &mut bool,
201) {
202    let is_selected = matches!(selected_stop, Some(i) if *i == idx);
203    let popup_id = Id::new(ui.id()).with("popup").with(idx);
204    let x = lerp(rect.left()..=rect.right(), *t);
205
206    let tick_rect = Rect::from_center_size(
207        pos2(x, rect.bottom() + TICK_SQUARE_SIZE * 0.5 - TICK_OFFSET),
208        Vec2::splat(TICK_SQUARE_SIZE),
209    );
210    let mut tick_response = ui.allocate_rect(
211        tick_rect.expand(5.).with_min_y(rect.top()),
212        Sense::click_and_drag(),
213    );
214
215    if tick_response.dragged_by(PointerButton::Primary) {
216        *t = (*t + tick_response.drag_delta().x / rect.width()).clamp(0., 1.);
217        *selected_stop = Some(idx);
218        *did_interact = true;
219    }
220
221    Popup::menu(&tick_response).id(popup_id).show(|ui| {
222        ui.spacing_mut().slider_width = COLOR_SLIDER_WIDTH;
223        *selected_stop = Some(idx);
224        if color_picker_hsva_2d(ui, color, Alpha::BlendOrAdditive) {
225            tick_response.mark_changed();
226        }
227        *did_interact = true;
228    });
229
230    const COLOR_SLIDER_WIDTH: f32 = 200.;
231
232    let mut visuals = ui.style().interact_selectable(&tick_response, is_selected);
233    let mut painter = ui.painter().clone();
234
235    if is_selected {
236        visuals.fg_stroke.width = 3.;
237        visuals.bg_stroke = ui.style().visuals.widgets.hovered.bg_stroke;
238        visuals.bg_stroke.width = 3.;
239
240        // to draw the selected stop on top of the others, create a new layer
241        painter.set_layer_id(LayerId::new(
242            Order::Middle,
243            ui.auto_id_with("selected stop"),
244        ));
245    }
246    painter.add(Shape::vline(
247        x,
248        tick_rect.top()..=rect.top(),
249        Stroke::new(visuals.fg_stroke.width, Color32::WHITE),
250    ));
251    painter.add(Shape::dashed_line(
252        &[pos2(x, tick_rect.top()), pos2(x, rect.top())],
253        Stroke::new(visuals.fg_stroke.width, Color32::BLACK),
254        2.,
255        2. + visuals.fg_stroke.width / 2.,
256    ));
257    painter.rect_filled(tick_rect, 0.0, color.to_opaque());
258    painter.rect_stroke(tick_rect, 0.0, visuals.bg_stroke, StrokeKind::Outside);
259    painter.rect_stroke(tick_rect, 0.0, visuals.fg_stroke, StrokeKind::Inside);
260}
261
262/// A color gradient editor widget
263pub fn gradient_editor(ui: &mut Ui, gradient: &mut Gradient) {
264    ui.vertical(|ui| {
265        let selected_stop_id = ui.id().with("selected_stop");
266        let mut selected_stop: Option<usize> =
267            ui.memory_mut(|mem| mem.data.remove_temp(selected_stop_id));
268
269        control_widgets(ui, gradient, &mut selected_stop);
270        let minimum_size = vec2(
271            ui.spacing().slider_width,
272            ui.spacing().interact_size.y * 1.7,
273        );
274        ui.set_min_size(minimum_size);
275        let desired_size = minimum_size * vec2(4., 1.);
276        let requested_size = ui.available_size().max(minimum_size).min(desired_size);
277
278        let (rect, response) = ui.allocate_at_least(requested_size, Sense::hover());
279
280        let mut did_interact = false;
281
282        if ui.is_rect_visible(rect) {
283            let visuals = *ui.style().noninteractive();
284
285            let gradient_rect = rect
286                .with_max_y(rect.max.y - TICK_OFFSET)
287                .shrink2(vec2(TICK_SQUARE_SIZE * 0.5 + 2., 0.));
288
289            if let Some(new_stop) = gradient_box(ui, gradient, gradient_rect, &visuals) {
290                selected_stop = Some(new_stop);
291            };
292
293            for (idx, (t, color)) in gradient.stops.iter_mut().enumerate() {
294                gradient_stop(
295                    ui,
296                    gradient_rect,
297                    idx,
298                    (t, color),
299                    &mut selected_stop,
300                    &mut did_interact,
301                );
302            }
303        }
304
305        if response.clicked_elsewhere() && !did_interact {
306            selected_stop = None;
307        }
308
309        ui.memory_mut(|mem| {
310            if let Some(idx) = selected_stop {
311                mem.data.insert_temp(selected_stop_id, idx)
312            }
313        })
314    });
315}