diff --git a/TODO.md b/TODO.md index a35c9975..c2d4bb62 100644 --- a/TODO.md +++ b/TODO.md @@ -10,9 +10,12 @@ TODO-list for the Egui project. If you looking for something to do, look here. * [ ] Clipboard copy/paste * [ ] Move focus with tab * [ ] Horizontal slider - * [ ] Color picker + * [/] Color picker * [x] linear rgb <-> sRGB -* Containers: + * [x] HSV + * [x] Color edit button with popup color picker + * [ ] Easily edit users own (s)RGBA quadruplets (`&mut [u8;4]`/`[f32;4]`) +* Containers * [ ] Scroll areas * [x] Vertical scrolling * [x] Scroll-wheel input diff --git a/egui/src/cache.rs b/egui/src/cache.rs new file mode 100644 index 00000000..ec653b12 --- /dev/null +++ b/egui/src/cache.rs @@ -0,0 +1,47 @@ +use std::hash::{Hash, Hasher}; + +const SIZE: usize = 8 * 1024; + +/// Very stupid/simple key-value cache. TODO: improve +#[derive(Clone)] +pub struct Cache([Option<(K, V)>; SIZE]); + +impl Default for Cache +where + K: Copy, + V: Copy, +{ + fn default() -> Self { + Self([None; SIZE]) + } +} + +impl std::fmt::Debug for Cache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Cache") + } +} + +impl Cache +where + K: Hash + PartialEq, +{ + pub fn get(&self, key: &K) -> Option<&V> { + let bucket = (hash(key) % (SIZE as u64)) as usize; + match &self.0[bucket] { + Some((k, v)) if k == key => Some(v), + _ => None, + } + } + + pub fn set(&mut self, key: K, value: V) { + let bucket = (hash(&key) % (SIZE as u64)) as usize; + self.0[bucket] = Some((key, value)); + } +} + +fn hash(value: impl Hash) -> u64 { + let mut hasher = ahash::AHasher::default(); + value.hash(&mut hasher); + hasher.finish() +} diff --git a/egui/src/containers/popup.rs b/egui/src/containers/popup.rs index a5aa4392..ec50d179 100644 --- a/egui/src/containers/popup.rs +++ b/egui/src/containers/popup.rs @@ -12,8 +12,15 @@ pub fn show_tooltip(ctx: &Arc, add_contents: impl FnOnce(&mut Ui)) { } } +/// Show a tooltip at the current mouse position (if any). +pub fn show_tooltip_text(ctx: &Arc, text: impl Into) { + show_tooltip(ctx, |ui| { + ui.add(crate::widgets::Label::new(text)); + }) +} + /// Show a pop-over window. -pub fn show_popup( +fn show_popup( ctx: &Arc, id: Id, window_pos: Pos2, @@ -21,7 +28,7 @@ pub fn show_popup( ) -> Response { use containers::*; Area::new(id) - .order(Order::Foreground) + .order(Order::Tooltip) .fixed_pos(window_pos) .interactable(false) .show(ctx, |ui| Frame::popup(&ctx.style()).show(ui, add_contents)) diff --git a/egui/src/demos/app.rs b/egui/src/demos/app.rs index b4940e0d..cb524857 100644 --- a/egui/src/demos/app.rs +++ b/egui/src/demos/app.rs @@ -452,6 +452,7 @@ struct Widgets { radio: usize, slider_value: f32, angle: f32, + color: Srgba, single_line_text_input: String, multiline_text_input: String, } @@ -464,6 +465,7 @@ impl Default for Widgets { count: 0, slider_value: 3.4, angle: TAU / 8.0, + color: (Rgba::new(0.0, 1.0, 0.5, 1.0) * 0.75).into(), single_line_text_input: "Hello World!".to_owned(), multiline_text_input: "Text can both be so wide that it needs a line break, but you can also add manual line break by pressing enter, creating new paragraphs.\nThis is the start of the next paragraph.\n\nClick me to edit me!".to_owned(), } @@ -527,6 +529,13 @@ impl Widgets { } ui.separator(); + ui.horizontal_centered(|ui| { + ui.add(Label::new("Click to select a different text color: ").text_color(self.color)); + ui.color_edit_button(&mut self.color); + }); + + ui.separator(); + ui.horizontal(|ui| { ui.add(label!("Single line text input:")); ui.add( diff --git a/egui/src/layers.rs b/egui/src/layers.rs index 07339bf5..967b9212 100644 --- a/egui/src/layers.rs +++ b/egui/src/layers.rs @@ -12,6 +12,8 @@ pub enum Order { Middle, /// Popups, menus etc that should always be painted on top of windows Foreground, + /// Foreground objects can also have tooltips + Tooltip, /// Debug layer, always painted last / on top Debug, } diff --git a/egui/src/lib.rs b/egui/src/lib.rs index 78aceded..0a224e34 100644 --- a/egui/src/lib.rs +++ b/egui/src/lib.rs @@ -46,6 +46,7 @@ mod animation_manager; pub mod app; +pub(crate) mod cache; pub mod containers; mod context; pub mod demos; diff --git a/egui/src/memory.rs b/egui/src/memory.rs index ad7f3029..6f955e4e 100644 --- a/egui/src/memory.rs +++ b/egui/src/memory.rs @@ -1,8 +1,13 @@ use std::collections::{HashMap, HashSet}; use crate::{ - area, collapsing_header, menu, resize, scroll_area, widgets::text_edit, window, Id, Layer, - Pos2, Rect, + area, + cache::Cache, + collapsing_header, menu, + paint::color::{Hsva, Srgba}, + resize, scroll_area, + widgets::text_edit, + window, Id, Layer, Pos2, Rect, }; /// The data that Egui persists between frames. @@ -34,6 +39,14 @@ pub struct Memory { pub(crate) temp_edit_string: Option, pub(crate) areas: Areas, + + /// Used by color picker + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) color_cache: Cache, + + /// Which popup-window is open (if any)? + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) popup: Option, } /// Say there is a button in a scroll area. diff --git a/egui/src/paint/color.rs b/egui/src/paint/color.rs index c662c636..e60a603d 100644 --- a/egui/src/paint/color.rs +++ b/egui/src/paint/color.rs @@ -1,9 +1,13 @@ use crate::math::clamp; +/// This format is used for space-efficient color representation. +/// +/// Instead of manipulating this directly it is often better +/// to first convert it to either `Rgba` or `Hsva`. +/// /// 0-255 gamma space `sRGBA` color with premultiplied alpha. /// Alpha channel is in linear space. -/// This format is used for space-efficient color representation. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Srgba(pub [u8; 4]); @@ -21,10 +25,14 @@ impl std::ops::IndexMut for Srgba { } pub const fn srgba(r: u8, g: u8, b: u8, a: u8) -> Srgba { - Srgba([r, g, b, a]) + Srgba::new(r, g, b, a) } impl Srgba { + pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self { + Self([r, g, b, a]) + } + pub const fn gray(l: u8) -> Self { Self([l, l, l, 255]) } @@ -36,6 +44,11 @@ impl Srgba { pub const fn additive_luminance(l: u8) -> Self { Self([l, l, l, 0]) } + + /// Returns an opaque version of self + pub fn to_opaque(self) -> Self { + Rgba::from(self).to_opaque().into() + } } // ---------------------------------------------------------------------------- @@ -93,6 +106,12 @@ impl Rgba { Self([l * a, l * a, l * a, a]) } + /// Transparent black + pub fn black_alpha(a: f32) -> Self { + debug_assert!(0.0 <= a && a <= 1.0); + Self([0.0, 0.0, 0.0, a]) + } + /// Transparent white pub fn white_alpha(a: f32) -> Self { debug_assert!(0.0 <= a && a <= 1.0); @@ -108,6 +127,40 @@ impl Rgba { alpha * self[3], ]) } + + pub fn r(&self) -> f32 { + self.0[0] + } + pub fn g(&self) -> f32 { + self.0[1] + } + pub fn b(&self) -> f32 { + self.0[2] + } + pub fn a(&self) -> f32 { + self.0[3] + } + + /// How perceptually intense (bright) is the color? + pub fn intensity(&self) -> f32 { + 0.3 * self.r() + 0.59 * self.g() + 0.11 * self.b() + } + + /// Returns an opaque version of self + pub fn to_opaque(&self) -> Self { + if self.a() == 0.0 { + // additive or fully transparent + Self::new(self.r(), self.g(), self.b(), 1.0) + } else { + // un-multiply alpha + Self::new( + self.r() / self.a(), + self.g() / self.a(), + self.b() / self.a(), + 1.0, + ) + } + } } impl std::ops::Add for Rgba { diff --git a/egui/src/paint/tessellator.rs b/egui/src/paint/tessellator.rs index e2008351..2312e406 100644 --- a/egui/src/paint/tessellator.rs +++ b/egui/src/paint/tessellator.rs @@ -117,6 +117,22 @@ impl Triangles { self.vertices.push(bottom_right); } + /// Uniformly colored rectangle. + pub fn add_colored_rect(&mut self, rect: Rect, color: Srgba) { + self.add_rect( + Vertex { + pos: rect.min, + uv: WHITE_UV, + color, + }, + Vertex { + pos: rect.max, + uv: WHITE_UV, + color, + }, + ) + } + /// This is for platforms that only support 16-bit index buffers. /// /// Splits this mesh into many smaller meshes (if needed). diff --git a/egui/src/style.rs b/egui/src/style.rs index 8229595b..0b8827e6 100644 --- a/egui/src/style.rs +++ b/egui/src/style.rs @@ -435,7 +435,7 @@ impl Stroke { ui.label(format!("{}: ", text)); ui.add(DragValue::f32(width).speed(0.1).range(0.0..=5.0)) .tooltip_text("Width"); - ui_color(ui, color, "Color"); + ui.color_edit_button(color); }); } } @@ -449,13 +449,9 @@ fn ui_slider_vec2(ui: &mut Ui, value: &mut Vec2, range: std::ops::RangeInclusive }); } -// TODO: improve color picker fn ui_color(ui: &mut Ui, srgba: &mut Srgba, text: &str) { ui.horizontal_centered(|ui| { - ui.label(format!("{} sRGBA: ", text)); - ui.add(DragValue::u8(&mut srgba[0])).tooltip_text("r"); - ui.add(DragValue::u8(&mut srgba[1])).tooltip_text("g"); - ui.add(DragValue::u8(&mut srgba[2])).tooltip_text("b"); - ui.add(DragValue::u8(&mut srgba[3])).tooltip_text("a"); + ui.label(format!("{}: ", text)); + ui.color_edit_button(srgba); }); } diff --git a/egui/src/types.rs b/egui/src/types.rs index 80de6052..4301c00b 100644 --- a/egui/src/types.rs +++ b/egui/src/types.rs @@ -80,9 +80,22 @@ pub struct Response { pub has_kb_focus: bool, } +impl std::fmt::Debug for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Response") + .field("rect", &self.rect) + .field("sense", &self.sense) + .field("hovered", &self.hovered) + .field("clicked", &self.clicked) + .field("double_clicked", &self.double_clicked) + .field("active", &self.active) + .field("has_kb_focus", &self.has_kb_focus) + .finish() + } +} + impl Response { - /// Show some stuff if the item was hovered - pub fn tooltip(&mut self, add_contents: impl FnOnce(&mut Ui)) -> &mut Self { + pub fn tooltip(self, add_contents: impl FnOnce(&mut Ui)) -> Self { if self.hovered { crate::containers::show_tooltip(&self.ctx, add_contents); } @@ -90,9 +103,9 @@ impl Response { } /// Show this text if the item was hovered - pub fn tooltip_text(&mut self, text: impl Into) -> &mut Self { - self.tooltip(|popup| { - popup.add(crate::widgets::Label::new(text)); + pub fn tooltip_text(self, text: impl Into) -> Self { + self.tooltip(|ui| { + ui.add(crate::widgets::Label::new(text)); }) } } diff --git a/egui/src/ui.rs b/egui/src/ui.rs index 6eaf8f85..58c5bed0 100644 --- a/egui/src/ui.rs +++ b/egui/src/ui.rs @@ -481,6 +481,12 @@ impl Ui { response } + /// Shows a button with the given color. + /// If the user clicks the button, a full color picker is shown. + pub fn color_edit_button(&mut self, srgba: &mut Srgba) { + widgets::color_picker::color_edit_button(self, srgba) + } + /// Ask to allocate a certain amount of space and return a Painter for that region. /// /// You may get back a `Painter` with a smaller or larger size than what you desired, @@ -603,6 +609,11 @@ impl Ui { self.inner_layout(Layout::vertical(Align::Min), initial_size, add_contents) } + pub fn vertical_centered(&mut self, add_contents: impl FnOnce(&mut Ui) -> R) -> (R, Rect) { + let initial_size = vec2(0.0, self.available().height()); + self.inner_layout(Layout::vertical(Align::Center), initial_size, add_contents) + } + pub fn inner_layout( &mut self, layout: Layout, diff --git a/egui/src/widgets.rs b/egui/src/widgets.rs index c79702a3..0e4f905e 100644 --- a/egui/src/widgets.rs +++ b/egui/src/widgets.rs @@ -8,6 +8,7 @@ use crate::{layout::Direction, *}; +pub mod color_picker; mod slider; pub(crate) mod text_edit; diff --git a/egui/src/widgets/color_picker.rs b/egui/src/widgets/color_picker.rs new file mode 100644 index 00000000..27e399b7 --- /dev/null +++ b/egui/src/widgets/color_picker.rs @@ -0,0 +1,256 @@ +use crate::{ + paint::{color::*, *}, + *, +}; + +fn contrast_color(color: impl Into) -> Srgba { + if color.into().intensity() < 0.5 { + color::WHITE + } else { + color::BLACK + } +} + +/// Number of vertices per dimension in the color sliders. +/// We need at least 6 for hues, and more for smooth 2D areas. +/// Should always be a multiple of 6 to hit the peak hues in HSV/HSL (every 60°). +const N: u32 = 6 * 3; + +fn background_checkers(painter: &Painter, rect: Rect) { + let mut top_color = Srgba::gray(128); + let mut bottom_color = Srgba::gray(32); + let checker_size = Vec2::splat(rect.height() / 2.0); + let n = (rect.width() / checker_size.x).round() as u32; + + let mut triangles = Triangles::default(); + for i in 0..n { + let x = lerp(rect.left()..=rect.right(), i as f32 / (n as f32)); + triangles.add_colored_rect( + Rect::from_min_size(pos2(x, rect.top()), checker_size), + top_color, + ); + triangles.add_colored_rect( + Rect::from_min_size(pos2(x, rect.center().y), checker_size), + bottom_color, + ); + std::mem::swap(&mut top_color, &mut bottom_color); + } + painter.add(PaintCmd::Triangles(triangles)); +} + +fn show_color(ui: &mut Ui, color: Srgba, desired_size: Vec2) -> Rect { + let rect = ui.allocate_space(desired_size); + background_checkers(ui.painter(), rect); + ui.painter().add(PaintCmd::Rect { + rect, + corner_radius: 2.0, + fill: color, + stroke: Stroke::new(3.0, color.to_opaque()), + }); + rect +} + +fn color_button(ui: &mut Ui, color: Srgba) -> Response { + let desired_size = Vec2::splat(ui.style().spacing.clickable_diameter); + let rect = ui.allocate_space(desired_size); + let rect = rect.expand2(ui.style().spacing.button_expand); + let id = ui.make_position_id(); + let response = ui.interact(rect, id, Sense::click()); + let visuals = ui.style().interact(&response); + background_checkers(ui.painter(), rect); + ui.painter().add(PaintCmd::Rect { + rect, + corner_radius: visuals.corner_radius.min(2.0), + fill: color, + stroke: visuals.fg_stroke, + }); + response +} + +fn color_slider_1d(ui: &mut Ui, value: &mut f32, color_at: impl Fn(f32) -> Srgba) -> Response { + #![allow(clippy::identity_op)] + + let desired_size = vec2( + ui.style().spacing.slider_width, + ui.style().spacing.clickable_diameter * 2.0, + ); + let rect = ui.allocate_space(desired_size); + + let id = ui.make_position_id(); + let response = ui.interact(rect, id, Sense::click_and_drag()); + if response.active { + if let Some(mpos) = ui.input().mouse.pos { + *value = remap_clamp(mpos.x, rect.left()..=rect.right(), 0.0..=1.0); + } + } + + let visuals = ui.style().interact(&response); + + background_checkers(ui.painter(), rect); // for alpha: + + { + // fill color: + let mut triangles = Triangles::default(); + for i in 0..=N { + let t = i as f32 / (N as f32); + let color = color_at(t); + let x = lerp(rect.left()..=rect.right(), t); + triangles.colored_vertex(pos2(x, rect.top()), color); + triangles.colored_vertex(pos2(x, rect.bottom()), color); + if i < N { + triangles.add_triangle(2 * i + 0, 2 * i + 1, 2 * i + 2); + triangles.add_triangle(2 * i + 1, 2 * i + 2, 2 * i + 3); + } + } + ui.painter().add(PaintCmd::Triangles(triangles)); + } + + ui.painter().rect_stroke(rect, 0.0, visuals.bg_stroke); // outline + + { + // Show where the slider is at: + let x = lerp(rect.left()..=rect.right(), *value); + let r = rect.height() / 4.0; + let picked_color = color_at(*value); + ui.painter().add(PaintCmd::Path { + points: vec![ + pos2(x - r, rect.bottom()), + pos2(x + r, rect.bottom()), + pos2(x, rect.center().y), + ], + closed: true, + fill: picked_color, + stroke: Stroke::new(visuals.fg_stroke.width, contrast_color(picked_color)), + }); + } + + response +} + +fn color_slider_2d( + ui: &mut Ui, + x_value: &mut f32, + y_value: &mut f32, + color_at: impl Fn(f32, f32) -> Srgba, +) -> Response { + let desired_size = Vec2::splat(ui.style().spacing.slider_width); + let rect = ui.allocate_space(desired_size); + + let id = ui.make_position_id(); + let response = ui.interact(rect, id, Sense::click_and_drag()); + if response.active { + if let Some(mpos) = ui.input().mouse.pos { + *x_value = remap_clamp(mpos.x, rect.left()..=rect.right(), 0.0..=1.0); + *y_value = remap_clamp(mpos.y, rect.bottom()..=rect.top(), 0.0..=1.0); + } + } + + let visuals = ui.style().interact(&response); + let mut triangles = Triangles::default(); + + for xi in 0..=N { + for yi in 0..=N { + let xt = xi as f32 / (N as f32); + let yt = yi as f32 / (N as f32); + let color = color_at(xt, yt); + let x = lerp(rect.left()..=rect.right(), xt); + let y = lerp(rect.bottom()..=rect.top(), yt); + triangles.colored_vertex(pos2(x, y), color); + + if xi < N && yi < N { + let x_offset = 1; + let y_offset = N + 1; + let tl = yi * y_offset + xi; + triangles.add_triangle(tl, tl + x_offset, tl + y_offset); + triangles.add_triangle(tl + x_offset, tl + y_offset, tl + y_offset + x_offset); + } + } + } + ui.painter().add(PaintCmd::Triangles(triangles)); // fill + + ui.painter().rect_stroke(rect, 0.0, visuals.bg_stroke); // outline + + // Show where the slider is at: + let x = lerp(rect.left()..=rect.right(), *x_value); + let y = lerp(rect.bottom()..=rect.top(), *y_value); + let picked_color = color_at(*x_value, *y_value); + ui.painter().add(PaintCmd::Circle { + center: pos2(x, y), + radius: rect.width() / 12.0, + fill: picked_color, + stroke: Stroke::new(visuals.fg_stroke.width, contrast_color(picked_color)), + }); + + response +} + +fn color_picker_hsva_2d(ui: &mut Ui, hsva: &mut Hsva) { + ui.vertical_centered(|ui| { + let current_color_size = vec2( + ui.style().spacing.slider_width, + ui.style().spacing.clickable_diameter * 2.0, + ); + let current_color_rect = show_color(ui, (*hsva).into(), current_color_size); + if ui.hovered(current_color_rect) { + show_tooltip_text(ui.ctx(), "Current color"); + } + + let opaque = Hsva { a: 1.0, ..*hsva }; + let Hsva { h, s, v, a } = hsva; + color_slider_2d(ui, h, s, |h, s| Hsva::new(h, s, 1.0, 1.0).into()) + .tooltip_text("Hue - Saturation"); + color_slider_2d(ui, v, s, |v, s| Hsva { v, s, ..opaque }.into()) + .tooltip_text("Value - Saturation"); + ui.label("Alpha:"); + color_slider_1d(ui, a, |a| Hsva { a, ..opaque }.into()).tooltip_text("Alpha"); + }); +} + +fn color_picker_hsva(ui: &mut Ui, hsva: &mut Hsva) { + let id = ui.make_position_id().with("foo"); + let button_response = color_button(ui, (*hsva).into()).tooltip_text("Click to edit color"); + + if button_response.clicked { + ui.memory().popup = Some(id); + } + // TODO: make it easier to show a temporary popup that closes when you click outside it + if ui.memory().popup == Some(id) { + let area_response = Area::new(id) + .order(Order::Foreground) + .default_pos(button_response.rect.max) + .show(ui.ctx(), |ui| { + Frame::popup(ui.style()).show(ui, |ui| { + color_picker_hsva_2d(ui, hsva); + }) + }); + + if !button_response.clicked { + let clicked_outside = ui.input().mouse.click && !area_response.hovered; + if clicked_outside || ui.input().key_pressed(Key::Escape) { + ui.memory().popup = None; + } + } + } +} + +// TODO: return Response so user can show a tooltip +/// Shows a button with the given color. +/// If the user clicks the button, a full color picker is shown. +pub fn color_edit_button(ui: &mut Ui, srgba: &mut Srgba) { + // To ensure we keep hue slider when `srgba` is grey we store the + // full `Hsva` in a cache: + + let mut hsva = ui + .ctx() + .memory() + .color_cache + .get(srgba) + .cloned() + .unwrap_or_else(|| Hsva::from(*srgba)); + + color_picker_hsva(ui, &mut hsva); + + *srgba = Srgba::from(hsva); + + ui.ctx().memory().color_cache.set(*srgba, hsva); +} diff --git a/egui/src/widgets/slider.rs b/egui/src/widgets/slider.rs index b3480b69..8c4c653d 100644 --- a/egui/src/widgets/slider.rs +++ b/egui/src/widgets/slider.rs @@ -235,13 +235,13 @@ impl<'a> Slider<'a> { ui.memory().temp_edit_string = Some(value_text); } } else { - let mut response = ui.add( + let response = ui.add( Label::new(value_text) .multiline(false) .text_color(text_color) .text_style(TextStyle::Monospace), ); - response.tooltip_text("Click to enter a value"); + let response = response.tooltip_text("Click to enter a value"); let response = ui.interact(response.rect, kb_edit_id, Sense::click()); if response.clicked { ui.memory().request_kb_focus(kb_edit_id);