403 lines
12 KiB
Rust
403 lines
12 KiB
Rust
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<String>, add_contents: impl FnOnce(&mut Ui));
|
|
fn selectable_button(&mut self, selected: bool, text: impl Into<WidgetText>) -> Response;
|
|
fn selectable_option<T: Eq>(
|
|
&mut self,
|
|
current_value: &mut T,
|
|
selected_value: T,
|
|
text: impl Into<WidgetText>,
|
|
) -> 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<String>, 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<WidgetText>) -> 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<T>,
|
|
max: Option<T>,
|
|
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<T>) -> 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<T: Number> 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::<T>().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()
|
|
}
|
|
}
|