use eframe::egui::{
self, pos2, vec2, Color32, Key, NumExt as _, Rect, Response, Sense, Stroke, TextStyle, Ui,
Widget, WidgetInfo, WidgetText, WidgetType,
};
use crate::themes::{
GorbieButtonStyle, GorbieChoiceToggleStyle, GorbieRadioStyle,
};
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct Button<'a> {
text: WidgetText,
on: Option<&'a mut bool>,
selected: bool,
fill: Option<Color32>,
light: Option<Color32>,
latched: bool,
min_height: Option<f32>,
gorbie_style: Option<GorbieButtonStyle>,
}
impl<'a> Button<'a> {
pub fn new(text: impl Into<WidgetText>) -> Self {
Self {
text: text.into(),
on: None,
selected: false,
fill: None,
light: None,
latched: false,
min_height: None,
gorbie_style: None,
}
}
pub fn on(mut self, on: &'a mut bool) -> Self {
self.on = Some(on);
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn fill(mut self, fill: Color32) -> Self {
self.fill = Some(fill);
self
}
pub fn light(mut self, color: Color32) -> Self {
self.light = Some(color);
self
}
pub fn latched(mut self, latched: bool) -> Self {
self.latched = latched;
self
}
pub fn modules(mut self, n: u32) -> Self {
self.min_height = Some(n as f32 * crate::card_ctx::GRID_ROW_MODULE);
self
}
}
impl Widget for Button<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let Self {
text,
on,
selected,
fill,
light,
latched,
min_height,
gorbie_style,
} = self;
let enabled = ui.is_enabled();
let gstyle = gorbie_style.unwrap_or_else(|| GorbieButtonStyle::from(ui.style().as_ref()));
let shadow_offset = gstyle.shadow_offset;
let shadow_inset = vec2(shadow_offset.x.max(0.0), shadow_offset.y.max(0.0));
let padding = ui.spacing().button_padding;
let label_text = text.text().to_string();
let max_text_width =
(ui.available_width() - padding.x * 2.0 - shadow_inset.x).at_least(0.0);
let galley = text.into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
max_text_width,
TextStyle::Button,
);
let mut body_size = galley.size() + padding * 2.0;
let target_h = min_height
.unwrap_or(2.0 * crate::card_ctx::GRID_ROW_MODULE)
.max(ui.spacing().interact_size.y);
body_size.y = body_size.y.at_least(target_h - shadow_inset.y);
body_size.x = body_size.x.max(ui.available_width() - shadow_inset.x);
let desired_size = body_size + shadow_inset;
let (outer_rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
let mut toggled_on = false;
if let Some(on) = on {
if response.clicked() && enabled {
*on = !*on;
response.mark_changed();
}
toggled_on = *on;
}
response.widget_info(move || {
WidgetInfo::labeled(WidgetType::Button, enabled, label_text.as_str())
});
if !ui.is_rect_visible(outer_rect) {
return response;
}
let outline = gstyle.outline;
let accent = gstyle.accent;
let shadow_color = gstyle.shadow;
let base_fill = fill.unwrap_or(gstyle.fill);
let keyboard_down = response.has_focus()
&& ui.input(|input| input.key_down(Key::Space) || input.key_down(Key::Enter));
let is_down = enabled
&& (response.is_pointer_button_down_on() || keyboard_down || latched || toggled_on);
let prepress =
enabled && !is_down && (response.hovered() || response.has_focus());
let stroke_color = if enabled && selected { accent } else { outline };
let body_rect_up =
Rect::from_min_max(outer_rect.min, outer_rect.max - shadow_inset).intersect(outer_rect);
let (body_rect, show_shadow) = if !enabled {
(body_rect_up, false)
} else if is_down {
(body_rect_up.translate(shadow_offset), false)
} else if prepress {
(body_rect_up.translate(shadow_offset * 0.5), true)
} else {
(body_rect_up, true)
};
let rounding = gstyle.rounding;
let painter = ui.painter();
if show_shadow {
let shadow_off = if prepress {
shadow_offset * 0.5
} else {
shadow_offset
};
painter.rect_filled(body_rect.translate(shadow_off), rounding, shadow_color);
}
painter.rect_filled(body_rect, rounding, base_fill);
painter.rect_stroke(
body_rect,
rounding,
Stroke::new(1.0, stroke_color),
egui::StrokeKind::Inside,
);
let text_color = crate::themes::ral(9011);
let text_pos = pos2(
body_rect.center().x - galley.size().x / 2.0,
body_rect.center().y - galley.size().y / 2.0,
);
painter.galley(text_pos, galley, text_color);
if light.is_some() || toggled_on {
let led_height = 4.0;
let led_inset_x = 2.0;
let led_inset_y = 2.0;
let led_rect = Rect::from_min_max(
pos2(body_rect.left() + led_inset_x, body_rect.top() + led_inset_y),
pos2(
body_rect.right() - led_inset_x,
(body_rect.top() + led_inset_y + led_height).min(body_rect.bottom()),
),
);
if led_rect.is_positive() {
let led_fill = light.unwrap_or_else(|| {
if toggled_on {
crate::themes::button_light_on()
} else {
gstyle.shadow }
});
painter.rect_filled(led_rect, 1.0, led_fill);
}
}
response
}
}
impl crate::themes::Styled for Button<'_> {
type Style = GorbieButtonStyle;
fn set_style(&mut self, style: Option<Self::Style>) {
self.gorbie_style = style;
}
}
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct RadioButton<'a, T> {
value: &'a mut T,
option: T,
text: WidgetText,
min_height: Option<f32>,
fill: Option<Color32>,
light: Option<Color32>,
gorbie_style: Option<GorbieRadioStyle>,
}
impl<'a, T> RadioButton<'a, T> {
pub fn new(value: &'a mut T, option: T, text: impl Into<WidgetText>) -> Self {
Self {
value,
option,
text: text.into(),
min_height: None,
fill: None,
light: None,
gorbie_style: None,
}
}
pub fn modules(mut self, n: u32) -> Self {
self.min_height = Some(n as f32 * crate::card_ctx::GRID_ROW_MODULE);
self
}
pub fn fill(mut self, fill: Color32) -> Self {
self.fill = Some(fill);
self
}
pub fn light(mut self, color: Color32) -> Self {
self.light = Some(color);
self
}
}
impl<T> Widget for RadioButton<'_, T>
where
T: Clone + PartialEq,
{
fn ui(self, ui: &mut Ui) -> Response {
let Self {
value,
option,
text,
min_height,
fill,
light,
gorbie_style,
} = self;
let selected = *value == option;
let enabled = ui.is_enabled();
let gstyle = gorbie_style.unwrap_or_else(|| GorbieRadioStyle::from(ui.style().as_ref()));
let shadow_offset = gstyle.shadow_offset;
let shadow_inset = vec2(shadow_offset.x.max(0.0), shadow_offset.y.max(0.0));
let padding = ui.spacing().button_padding;
let indicator_size = (ui.spacing().interact_size.y - 6.0).at_least(14.0);
let gap = (padding.x * 0.8).at_least(6.0);
let label_text = text.text().to_string();
let max_text_width =
(ui.available_width() - padding.x * 2.0 - indicator_size - gap - shadow_inset.x)
.at_least(0.0);
let galley = text.into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
max_text_width,
TextStyle::Button,
);
let content_height = galley.size().y.max(indicator_size);
let min_body_height = {
let target_h = min_height
.unwrap_or(2.0 * crate::card_ctx::GRID_ROW_MODULE)
.max(ui.spacing().interact_size.y);
target_h - shadow_inset.y
};
let body_height = (content_height + padding.y * 2.0).at_least(min_body_height);
let mut body_width = padding.x + indicator_size + gap + galley.size().x + padding.x;
body_width = body_width.max(ui.available_width() - shadow_inset.x);
let desired_size = vec2(body_width, body_height) + shadow_inset;
let (outer_rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
if response.clicked() && enabled && !selected {
*value = option;
response.mark_changed();
}
response.widget_info(move || {
WidgetInfo::labeled(WidgetType::RadioButton, enabled, label_text.as_str())
});
if !ui.is_rect_visible(outer_rect) {
return response;
}
let visuals = ui.visuals();
let outline = gstyle.outline;
let shadow_color = gstyle.shadow;
let base_fill = fill.unwrap_or(gstyle.fill);
let disabled_fill = crate::themes::blend(base_fill, visuals.window_fill, 0.65);
let keyboard_down = response.has_focus()
&& ui.input(|input| input.key_down(Key::Space) || input.key_down(Key::Enter));
let is_down = enabled && (response.is_pointer_button_down_on() || keyboard_down);
let prepress = enabled && !is_down && (response.hovered() || response.has_focus());
let fill = if enabled { base_fill } else { disabled_fill };
let stroke_color = if enabled && selected {
gstyle.accent
} else {
outline
};
let body_rect_up =
Rect::from_min_max(outer_rect.min, outer_rect.max - shadow_inset).intersect(outer_rect);
let press_offset = if is_down {
shadow_offset
} else if prepress || selected {
shadow_offset * 0.5
} else {
vec2(0.0, 0.0)
};
let indicator_rect_up = Rect::from_min_size(
pos2(
body_rect_up.left() + padding.x,
body_rect_up.center().y - indicator_size / 2.0,
),
vec2(indicator_size, indicator_size),
);
let indicator_rect = if press_offset != vec2(0.0, 0.0) {
indicator_rect_up.translate(press_offset)
} else {
indicator_rect_up
};
let shadow_drop = vec2(
(shadow_offset.x - press_offset.x).max(0.0),
(shadow_offset.y - press_offset.y).max(0.0),
);
let painter = ui.painter();
if enabled && shadow_drop != vec2(0.0, 0.0) {
painter.rect_filled(
indicator_rect.translate(shadow_drop),
gstyle.rounding,
shadow_color,
);
}
painter.rect_filled(indicator_rect, gstyle.rounding, fill);
painter.rect_stroke(
indicator_rect,
gstyle.rounding,
Stroke::new(1.0, stroke_color),
egui::StrokeKind::Inside,
);
let on_color = light.unwrap_or(gstyle.indicator_on);
let off_color =
crate::themes::blend(gstyle.rail_bg, fill, gstyle.indicator_off_towards_fill);
let mut dot_color = if selected { on_color } else { off_color };
if !enabled {
dot_color = crate::themes::blend(dot_color, visuals.window_fill, 0.6);
}
let dot_radius = (indicator_rect.height() * 0.22).at_least(2.0);
painter.circle_filled(indicator_rect.center(), dot_radius, dot_color);
let text_color = if enabled {
crate::themes::ral(9011)
} else {
crate::themes::blend(crate::themes::ral(9011), visuals.window_fill, 0.55)
};
let text_pos = pos2(
indicator_rect_up.right() + gap,
body_rect_up.center().y - galley.size().y / 2.0,
);
painter.galley(text_pos, galley, text_color);
response
}
}
impl<T> crate::themes::Styled for RadioButton<'_, T> {
type Style = GorbieRadioStyle;
fn set_style(&mut self, style: Option<Self::Style>) {
self.gorbie_style = style;
}
}
struct ChoiceToggleOption<T> {
value: T,
text: WidgetText,
}
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct ChoiceToggle<'a, T> {
value: &'a mut T,
options: Vec<ChoiceToggleOption<T>>,
min_height: Option<f32>,
fill: Option<Color32>,
light: Option<Color32>,
gorbie_style: Option<GorbieChoiceToggleStyle>,
}
impl<'a, T> ChoiceToggle<'a, T> {
pub fn new(value: &'a mut T) -> Self {
Self {
value,
options: Vec::new(),
min_height: None,
fill: None,
light: None,
gorbie_style: None,
}
}
pub fn choice(mut self, value: T, text: impl Into<WidgetText>) -> Self {
self.options.push(ChoiceToggleOption {
value,
text: text.into(),
});
self
}
pub fn modules(mut self, n: u32) -> Self {
self.min_height = Some(n as f32 * crate::card_ctx::GRID_ROW_MODULE);
self
}
pub fn fill(mut self, fill: Color32) -> Self {
self.fill = Some(fill);
self
}
pub fn light(mut self, color: Color32) -> Self {
self.light = Some(color);
self
}
}
impl<'a> ChoiceToggle<'a, bool> {
pub fn binary(
value: &'a mut bool,
off_text: impl Into<WidgetText>,
on_text: impl Into<WidgetText>,
) -> Self {
ChoiceToggle::new(value)
.choice(false, off_text)
.choice(true, on_text)
}
}
impl<T> Widget for ChoiceToggle<'_, T>
where
T: Clone + PartialEq,
{
fn ui(self, ui: &mut Ui) -> Response {
let Self {
value,
options,
min_height,
fill,
light,
gorbie_style,
} = self;
if options.is_empty() {
let (_rect, response) = ui.allocate_exact_size(vec2(0.0, 0.0), Sense::hover());
return response;
}
let enabled = ui.is_enabled();
let gstyle =
gorbie_style.unwrap_or_else(|| GorbieChoiceToggleStyle::from(ui.style().as_ref()));
let shadow_offset = gstyle.shadow_offset;
let shadow_inset = vec2(shadow_offset.x.max(0.0), shadow_offset.y.max(0.0));
let padding = ui.spacing().button_padding;
let text_style = TextStyle::Button;
let wrap_mode = Some(egui::TextWrapMode::Truncate);
let max_text_width = ui.available_width().at_least(0.0);
struct RenderedChoice<T> {
value: T,
galley: std::sync::Arc<egui::Galley>,
}
let mut label_text = String::new();
let mut choices: Vec<RenderedChoice<T>> = Vec::with_capacity(options.len());
for (idx, option) in options.into_iter().enumerate() {
if idx > 0 {
label_text.push('/');
}
label_text.push_str(option.text.text().as_ref());
let galley = option
.text
.into_galley(ui, wrap_mode, max_text_width, text_style.clone());
choices.push(RenderedChoice {
value: option.value,
galley,
});
}
let segment_count = choices.len();
let segment_gap = gstyle.segment_gap;
let mut segment_size = vec2(0.0, 0.0);
for choice in &choices {
segment_size = segment_size.max(choice.galley.size());
}
segment_size += padding * 2.0;
let target_h = min_height
.unwrap_or(2.0 * crate::card_ctx::GRID_ROW_MODULE)
.max(ui.spacing().interact_size.y);
segment_size.y = segment_size.y.at_least(target_h - shadow_inset.y);
let body_width = segment_size.x * segment_count as f32
+ segment_gap * (segment_count.saturating_sub(1) as f32);
let body_size = vec2(body_width, segment_size.y);
let desired_size = body_size + shadow_inset;
let (outer_rect, outer_response) = ui.allocate_exact_size(desired_size, Sense::hover());
let mut response = outer_response;
response.widget_info(move || {
WidgetInfo::labeled(WidgetType::Button, enabled, label_text.as_str())
});
if !ui.is_rect_visible(outer_rect) {
return response;
}
let visuals = ui.visuals();
let outline = gstyle.outline;
let accent = gstyle.accent;
let shadow_color = gstyle.shadow;
let base_fill = fill.unwrap_or(gstyle.fill);
let disabled_fill = crate::themes::blend(base_fill, visuals.window_fill, 0.65);
let disabled_slot_fill = crate::themes::blend(gstyle.rail_bg, visuals.window_fill, 0.65);
let slot_rect =
Rect::from_min_max(outer_rect.min, outer_rect.max - shadow_inset).intersect(outer_rect);
let slot_fill = if enabled {
gstyle.rail_bg
} else {
disabled_slot_fill
};
let mut segment_slots = Vec::with_capacity(segment_count);
let mut segment_responses = Vec::with_capacity(segment_count);
for idx in 0..segment_count {
let x0 = slot_rect.left() + idx as f32 * (segment_size.x + segment_gap);
let x1 = (x0 + segment_size.x).min(slot_rect.right());
let segment_slot =
Rect::from_min_max(pos2(x0, slot_rect.top()), pos2(x1, slot_rect.bottom()));
segment_slots.push(segment_slot);
let id = ui.make_persistent_id((response.id, "choice-toggle", idx));
segment_responses.push(ui.interact(segment_slot, id, Sense::click()));
}
let pointer_pressed = enabled && ui.input(|i| i.pointer.any_pressed());
let keyboard_down =
enabled && ui.input(|input| input.key_down(Key::Space) || input.key_down(Key::Enter));
let mut changed = false;
if pointer_pressed {
for (idx, segment_response) in segment_responses.iter().enumerate() {
if segment_response.is_pointer_button_down_on() {
let next_value = &choices[idx].value;
if next_value != &*value {
*value = next_value.clone();
changed = true;
}
break;
}
}
}
if enabled && !changed {
for (idx, segment_response) in segment_responses.iter().enumerate() {
if segment_response.clicked() {
let next_value = &choices[idx].value;
if next_value != &*value {
*value = next_value.clone();
changed = true;
}
break;
}
}
}
let slot_rounding = gstyle.slot_rounding;
let segment_rounding = gstyle.segment_rounding;
let painter = ui.painter();
painter.rect_filled(slot_rect, slot_rounding, slot_fill);
painter.rect_stroke(
slot_rect,
slot_rounding,
Stroke::new(1.0, outline),
egui::StrokeKind::Inside,
);
let segment_margin = shadow_offset.x.max(shadow_offset.y).max(2.0);
#[derive(Clone, Copy)]
struct MaskStroke {
left: bool,
right: bool,
}
let draw_segment = |face_up: Rect,
rounding: egui::CornerRadius,
mask_stroke: MaskStroke,
galley: std::sync::Arc<egui::Galley>,
hovered: bool,
is_down: bool,
is_active: bool| {
let painter = ui.painter();
let fill = if enabled { base_fill } else { disabled_fill };
let is_pressed = is_down || is_active;
let prepress = enabled && !is_pressed && hovered;
let pressed_offset = vec2(0.0, shadow_offset.y.max(0.0));
let face_rect = if is_pressed {
face_up.translate(pressed_offset)
} else if prepress {
face_up.translate(pressed_offset * 0.5)
} else {
face_up
};
if enabled && !is_pressed {
let shadow_offset = if prepress {
pressed_offset * 0.5
} else {
shadow_offset
};
painter.rect_filled(face_rect.translate(shadow_offset), rounding, shadow_color);
}
let stroke_color = if enabled && is_active {
accent
} else {
outline
};
painter.rect_filled(face_rect, rounding, fill);
painter.rect_stroke(
face_rect,
rounding,
Stroke::new(1.0, stroke_color),
egui::StrokeKind::Inside,
);
let stroke_width = 1.0;
if mask_stroke.left {
let mask_rect = Rect::from_min_max(
pos2(face_rect.left(), face_rect.top()),
pos2(
(face_rect.left() + stroke_width).min(face_rect.right()),
face_rect.bottom(),
),
);
if mask_rect.is_positive() {
painter.rect_filled(mask_rect, 0, fill);
}
}
if mask_stroke.right {
let mask_rect = Rect::from_min_max(
pos2(
(face_rect.right() - stroke_width).max(face_rect.left()),
face_rect.top(),
),
pos2(face_rect.right(), face_rect.bottom()),
);
if mask_rect.is_positive() {
painter.rect_filled(mask_rect, 0, fill);
}
}
let text_color = if enabled {
crate::themes::ral(9011)
} else {
crate::themes::blend(crate::themes::ral(9011), fill, 0.55)
};
let text_pos = pos2(
face_rect.center().x - galley.size().x / 2.0,
face_rect.center().y - galley.size().y / 2.0,
);
painter.galley(text_pos, galley, text_color);
let led_height = 4.0;
let led_inset_x = 2.0;
let led_inset_y = 2.0;
let led_rect = Rect::from_min_max(
pos2(
face_rect.left() + led_inset_x,
face_rect.top() + led_inset_y,
),
pos2(
face_rect.right() - led_inset_x,
(face_rect.top() + led_inset_y + led_height).min(face_rect.bottom()),
),
);
if led_rect.is_positive() {
let on_color = light.unwrap_or(gstyle.led_on);
let off_color =
crate::themes::blend(gstyle.rail_bg, fill, gstyle.led_off_towards_fill);
let mut led_fill = if is_active { on_color } else { off_color };
if !enabled {
led_fill = crate::themes::blend(led_fill, ui.visuals().window_fill, 0.6);
}
painter.rect_filled(led_rect, 1, led_fill);
}
};
let active_index = choices.iter().position(|choice| choice.value == *value);
let mut draw_order: Vec<usize> = if shadow_offset.x >= 0.0 {
(0..segment_count).collect()
} else {
(0..segment_count).rev().collect()
};
if let Some(active_index) = active_index {
if let Some(pos) = draw_order.iter().position(|&idx| idx == active_index) {
let active = draw_order.remove(pos);
draw_order.push(active);
}
}
for idx in draw_order {
let slot = segment_slots[idx];
let left_inset = if idx == 0 { segment_margin } else { 0.0 };
let right_inset = if idx + 1 == segment_count {
segment_margin
} else {
0.0
};
let face_up = Rect::from_min_max(
pos2(slot.left() + left_inset, slot.top() + segment_margin),
pos2(
(slot.right() - right_inset).max(slot.left() + left_inset),
(slot.bottom() - segment_margin).max(slot.top() + segment_margin),
),
);
let rounding = egui::CornerRadius {
nw: if idx == 0 { segment_rounding } else { 0 },
ne: if idx + 1 == segment_count {
segment_rounding
} else {
0
},
sw: if idx == 0 { segment_rounding } else { 0 },
se: if idx + 1 == segment_count {
segment_rounding
} else {
0
},
};
let hovered = segment_responses[idx].hovered() || segment_responses[idx].has_focus();
let is_down = enabled
&& (segment_responses[idx].is_pointer_button_down_on()
|| (segment_responses[idx].has_focus() && keyboard_down));
let is_active = active_index == Some(idx);
let mask_stroke = MaskStroke {
left: idx > 0,
right: idx + 1 < segment_count,
};
draw_segment(
face_up,
rounding,
mask_stroke,
choices[idx].galley.clone(),
hovered,
is_down,
is_active,
);
}
for segment_response in segment_responses {
response |= segment_response;
}
if changed {
response.mark_changed();
}
response
}
}
impl<T> crate::themes::Styled for ChoiceToggle<'_, T> {
type Style = GorbieChoiceToggleStyle;
fn set_style(&mut self, style: Option<Self::Style>) {
self.gorbie_style = style;
}
}