use std::{ fmt::{Display, UpperHex}, ops::{Bound, RangeBounds}, str::FromStr, }; use atoi::FromRadix16; use egui::{ ecolor::HexColor, Align, Color32, CursorIcon, Event, Frame, Key, Layout, Margin, Rect, Response, RichText, Rounding, Sense, Shape, Stroke, TextEdit, Ui, UiBuilder, Vec2, Widget, WidgetText, }; use num_traits::{CheckedAdd, CheckedSub, One}; pub trait UiExt { fn section(&mut self, title: impl Into, add_contents: impl FnOnce(&mut Ui)); fn selectable_button(&mut self, selected: bool, text: impl Into) -> Response; fn selectable_option( &mut self, current_value: &mut T, selected_value: T, text: impl Into, ) -> Response { let response = self.selectable_button(*current_value == selected_value, text); if response.clicked() { *current_value = selected_value; } response } fn color_pair_button(&mut self, left: Color32, right: Color32) -> Response; fn color_picker(&mut self, color: &mut Color32, hex: &mut String) -> Response; } impl UiExt for Ui { fn section(&mut self, title: impl Into, add_contents: impl FnOnce(&mut Ui)) { let title: String = title.into(); let mut frame = Frame::group(self.style()); frame.outer_margin.top += 10.0; frame.inner_margin.top += 2.0; let res = self.push_id(&title, |ui| { frame.show(ui, |ui| { ui.set_min_width(ui.available_width()); add_contents(ui); }) }); let text = RichText::new(title).background_color(self.style().visuals.panel_fill); let old_rect = res.response.rect; let mut text_rect = old_rect; text_rect.min.x += 6.0; let new_rect = self .allocate_new_ui(UiBuilder::new().max_rect(text_rect), |ui| ui.label(text)) .response .rect; self.allocate_space((old_rect.max - new_rect.max) - (old_rect.min - new_rect.min)); } fn selectable_button(&mut self, selected: bool, text: impl Into) -> Response { self.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT; self.style_mut().visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT; self.style_mut().visuals.widgets.active.bg_fill = Color32::TRANSPARENT; let mut selected = selected; self.checkbox(&mut selected, text) } fn color_pair_button(&mut self, left: Color32, right: Color32) -> Response { let button_size = Vec2::new(60.0, 20.0); let (rect, response) = self.allocate_at_least(button_size, Sense::click()); let center_x = rect.center().x; let left_rect = rect.with_max_x(center_x); self.painter().rect_filled(left_rect, 0.0, left); let right_rect = rect.with_min_x(center_x); self.painter().rect_filled(right_rect, 0.0, right); let style = self.style().interact(&response); self.painter().rect_stroke(rect, 0.0, style.fg_stroke); response } fn color_picker(&mut self, color: &mut Color32, hex: &mut String) -> Response { self.allocate_ui_with_layout( Vec2::new(100.0, 130.0), Layout::top_down_justified(egui::Align::Center), |ui| { let (rect, _) = ui.allocate_at_least(Vec2::new(100.0, 100.0), Sense::hover()); ui.painter().rect_filled(rect, 0.0, *color); let resp = ui.text_edit_singleline(hex); if resp.changed() { if let Ok(new_color) = HexColor::from_str_without_hash(hex) { *color = new_color.color(); } } resp }, ) .inner } } enum Direction { Up, Down, } pub trait Number: Copy + One + CheckedAdd + CheckedSub + Eq + Ord + Display + FromStr + FromRadix16 + UpperHex + Send + Sync + 'static { } impl< T: Copy + One + CheckedAdd + CheckedSub + Eq + Ord + Display + FromStr + FromRadix16 + UpperHex + Send + Sync + 'static, > Number for T { } pub struct NumberEdit<'a, T: Number> { value: &'a mut T, increment: T, precision: usize, min: Option, max: Option, arrows: bool, hex: bool, } impl<'a, T: Number> NumberEdit<'a, T> { pub fn new(value: &'a mut T) -> Self { Self { value, increment: T::one(), precision: 3, min: None, max: None, arrows: true, hex: false, } } pub fn precision(self, precision: usize) -> Self { Self { precision, ..self } } pub fn range(self, range: impl RangeBounds) -> Self { let min = match range.start_bound() { Bound::Unbounded => None, Bound::Included(t) => Some(*t), Bound::Excluded(t) => t.checked_add(&self.increment), }; let max = match range.end_bound() { Bound::Unbounded => None, Bound::Included(t) => Some(*t), Bound::Excluded(t) => t.checked_sub(&self.increment), }; Self { min, max, ..self } } pub fn arrows(self, arrows: bool) -> Self { Self { arrows, ..self } } pub fn hex(self, hex: bool) -> Self { Self { hex, ..self } } } impl Widget for NumberEdit<'_, T> { fn ui(self, ui: &mut Ui) -> Response { let id = ui.id(); let to_string = |val: &T| { if self.hex { format!("{val:.0$X}", self.precision) } else { format!("{val:.0$}", self.precision) } }; let from_string = |val: &str| { if self.hex { let bytes = val.as_bytes(); let (result, consumed) = T::from_radix_16(bytes); (consumed == bytes.len()).then_some(result) } else { val.parse::().ok() } }; let (last_value, mut str, focus) = ui.memory(|m| { let (lv, s) = m .data .get_temp(id) .unwrap_or((*self.value, to_string(self.value))); let focus = m.has_focus(id); (lv, s, focus) }); let mut stale = false; if *self.value != last_value { str = to_string(self.value); stale = true; } let valid = from_string(&str).is_some_and(|v: T| v == *self.value); let mut up_pressed = false; let mut down_pressed = false; if focus { ui.input_mut(|i| { i.events.retain(|e| match e { Event::Key { key: Key::ArrowUp, pressed: true, .. } => { up_pressed = true; false } Event::Key { key: Key::ArrowDown, pressed: true, .. } => { down_pressed = true; false } _ => true, }) }); } let text = TextEdit::singleline(&mut str) .horizontal_align(Align::Max) .id(id) .margin(Margin { left: 4.0, right: if self.arrows { 20.0 } else { 4.0 }, top: 2.0, bottom: 2.0, }); let mut res = if valid { ui.add(text) } else { let message = match (self.min, self.max) { (Some(min), Some(max)) => format!( "Please enter a number between {} and {}.", to_string(&min), to_string(&max) ), (Some(min), None) => { format!("Please enter a number greater than {}.", to_string(&min)) } (None, Some(max)) => { format!("Please enter a number less than {}.", to_string(&max)) } (None, None) => "Please enter a number.".to_string(), }; ui.scope(|ui| { let style = ui.style_mut(); style.visuals.selection.stroke.color = style.visuals.error_fg_color; style.visuals.widgets.hovered.bg_stroke.color = style.visuals.error_fg_color.gamma_multiply(0.60); ui.add(text) }) .inner .on_hover_text(message) }; let mut delta = None; if self.arrows { let arrow_left = res.rect.max.x + 4.0; let arrow_right = res.rect.max.x + 20.0; let arrow_top = res.rect.min.y - 2.0; let arrow_middle = (res.rect.min.y + res.rect.max.y) / 2.0; let arrow_bottom = res.rect.max.y + 2.0; let top_arrow_rect = Rect { min: (arrow_left, arrow_top).into(), max: (arrow_right, arrow_middle).into(), }; if draw_arrow(ui, top_arrow_rect, true).clicked_or_dragged() || up_pressed { delta = Some(Direction::Up); } let bottom_arrow_rect = Rect { min: (arrow_left, arrow_middle).into(), max: (arrow_right, arrow_bottom).into(), }; if draw_arrow(ui, bottom_arrow_rect, false).clicked_or_dragged() || down_pressed { delta = Some(Direction::Down); } } let in_range = |val: &T| self.min.is_none_or(|m| &m <= val) && self.max.is_none_or(|m| &m >= val); if let Some(dir) = delta { let value = match dir { Direction::Up => self.value.checked_add(&self.increment), Direction::Down => self.value.checked_sub(&self.increment), }; if let Some(new_value) = value.filter(in_range) { if *self.value != new_value { res.mark_changed(); } *self.value = new_value; } str = to_string(self.value); stale = true; } else if res.changed { if let Some(new_value) = from_string(&str).filter(in_range) { if *self.value != new_value { res.mark_changed(); } *self.value = new_value; } stale = true; } if stale { ui.memory_mut(|m| m.data.insert_temp(id, (*self.value, str))); } res } } fn draw_arrow(ui: &mut Ui, rect: Rect, up: bool) -> Response { let arrow_res = ui .allocate_rect( rect, Sense { click: true, drag: true, focusable: false, }, ) .on_hover_cursor(CursorIcon::Default); let visuals = ui.style().visuals.widgets.style(&arrow_res); let painter = ui.painter_at(arrow_res.rect); let rounding = if up { Rounding { ne: 2.0, ..Rounding::ZERO } } else { Rounding { se: 2.0, ..Rounding::ZERO } }; painter.rect_filled(arrow_res.rect, rounding, visuals.bg_fill); let left = rect.left() + 4.0; let center = (rect.left() + rect.right()) / 2.0; let right = rect.right() - 4.0; let top = rect.top() + 3.0; let bottom = rect.bottom() - 3.0; let points = if up { vec![ (left, bottom).into(), (center, top).into(), (right, bottom).into(), ] } else { vec![ (right, top).into(), (center, bottom).into(), (left, top).into(), ] }; painter.add(Shape::convex_polygon( points, visuals.fg_stroke.color, Stroke::NONE, )); arrow_res } trait ResponseExt { fn clicked_or_dragged(&self) -> bool; } impl ResponseExt for Response { fn clicked_or_dragged(&self) -> bool { self.clicked() || self.dragged() } }