use std::hash::Hash;
use std::sync::Arc;
use egui::{
pos2, Color32, CornerRadius, FontId, FontSelection, Galley, Rect, Response, Sense, Stroke,
StrokeKind, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
};
use crate::theme::{placeholder_galley, with_alpha, Theme};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SegmentedSize {
Small,
#[default]
Medium,
Large,
}
impl SegmentedSize {
fn font_size(self, theme: &Theme) -> f32 {
let t = &theme.typography;
match self {
Self::Small => t.small,
Self::Medium => t.label,
Self::Large => t.button,
}
}
fn icon_size(self, theme: &Theme) -> f32 {
self.font_size(theme)
}
fn pad_x(self) -> f32 {
match self {
Self::Small => 10.0,
Self::Medium => 12.0,
Self::Large => 16.0,
}
}
fn pad_y(self) -> f32 {
match self {
Self::Small => 3.0,
Self::Medium => 5.0,
Self::Large => 7.0,
}
}
fn track_pad(self) -> f32 {
match self {
Self::Small => 2.0,
Self::Medium => 3.0,
Self::Large => 4.0,
}
}
fn track_radius(self) -> u8 {
match self {
Self::Small => 6,
Self::Medium => 7,
Self::Large => 8,
}
}
fn segment_radius(self) -> u8 {
match self {
Self::Small => 4,
Self::Medium => 5,
Self::Large => 6,
}
}
fn count_height(self) -> f32 {
match self {
Self::Small => 16.0,
Self::Medium => 18.0,
Self::Large => 20.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SegmentDot {
Neutral,
Sky,
Amber,
Red,
Green,
}
#[must_use = "Use with SegmentedControl::from_segments(...)"]
pub struct Segment {
label: Option<WidgetText>,
icon: Option<WidgetText>,
count: Option<WidgetText>,
dot: Option<SegmentDot>,
enabled: bool,
}
impl std::fmt::Debug for Segment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Segment")
.field("label", &self.label.as_ref().map(|l| l.text().to_string()))
.field("icon", &self.icon.as_ref().map(|i| i.text().to_string()))
.field("count", &self.count.as_ref().map(|c| c.text().to_string()))
.field("dot", &self.dot)
.field("enabled", &self.enabled)
.finish()
}
}
impl Default for Segment {
fn default() -> Self {
Self {
label: None,
icon: None,
count: None,
dot: None,
enabled: true,
}
}
}
impl Segment {
pub fn text(label: impl Into<WidgetText>) -> Self {
Self {
label: Some(label.into()),
..Self::default()
}
}
pub fn icon(icon: impl Into<WidgetText>) -> Self {
Self {
icon: Some(icon.into()),
..Self::default()
}
}
pub fn icon_text(icon: impl Into<WidgetText>, label: impl Into<WidgetText>) -> Self {
Self {
icon: Some(icon.into()),
label: Some(label.into()),
..Self::default()
}
}
#[inline]
pub fn count(mut self, count: impl Into<WidgetText>) -> Self {
self.count = Some(count.into());
self
}
#[inline]
pub fn dot(mut self, dot: SegmentDot) -> Self {
self.dot = Some(dot);
self
}
#[inline]
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
fn debug_label(&self) -> String {
if let Some(l) = &self.label {
l.text().to_string()
} else if let Some(i) = &self.icon {
i.text().to_string()
} else {
String::new()
}
}
}
#[must_use = "Add with `ui.add(...)`."]
pub struct SegmentedControl<'a> {
selection: Selection<'a>,
segments: Vec<Segment>,
size: SegmentedSize,
fill: bool,
}
enum Selection<'a> {
Single(&'a mut usize),
Multi(&'a mut [bool]),
}
impl<'a> Selection<'a> {
fn is_active(&self, i: usize) -> bool {
match self {
Selection::Single(idx) => **idx == i,
Selection::Multi(states) => states.get(i).copied().unwrap_or(false),
}
}
fn click(&mut self, i: usize) {
match self {
Selection::Single(idx) => {
if **idx != i {
**idx = i;
}
}
Selection::Multi(states) => {
if let Some(s) = states.get_mut(i) {
*s = !*s;
}
}
}
}
}
impl<'a> std::fmt::Debug for SegmentedControl<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("SegmentedControl");
match &self.selection {
Selection::Single(idx) => {
d.field("mode", &"single");
d.field("selected", &**idx);
}
Selection::Multi(states) => {
d.field("mode", &"multi");
d.field("states", states);
}
}
d.field("segments", &self.segments)
.field("size", &self.size)
.field("fill", &self.fill)
.finish()
}
}
impl<'a> SegmentedControl<'a> {
pub fn new<I, S>(selected: &'a mut usize, items: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<WidgetText>,
{
Self {
selection: Selection::Single(selected),
segments: items.into_iter().map(Segment::text).collect(),
size: SegmentedSize::default(),
fill: false,
}
}
pub fn from_segments(
selected: &'a mut usize,
segments: impl IntoIterator<Item = Segment>,
) -> Self {
Self {
selection: Selection::Single(selected),
segments: segments.into_iter().collect(),
size: SegmentedSize::default(),
fill: false,
}
}
pub fn toggles<I, S>(states: &'a mut [bool], items: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<WidgetText>,
{
Self {
selection: Selection::Multi(states),
segments: items.into_iter().map(Segment::text).collect(),
size: SegmentedSize::default(),
fill: false,
}
}
#[inline]
pub fn size(mut self, size: SegmentedSize) -> Self {
self.size = size;
self
}
#[inline]
pub fn fill(mut self) -> Self {
self.fill = true;
self
}
}
struct Prepared {
icon: Option<Arc<Galley>>,
label: Option<Arc<Galley>>,
count: Option<Arc<Galley>>,
dot: Option<SegmentDot>,
enabled: bool,
a11y: String,
natural_w: f32,
natural_h: f32,
}
const INNER_GAP: f32 = 6.0;
const DOT_SIZE: f32 = 6.0;
fn count_galley(ui: &Ui, text: &str, size: f32) -> Arc<Galley> {
let rt = egui::RichText::new(text)
.color(Color32::PLACEHOLDER)
.size(size)
.strong();
egui::WidgetText::from(rt).into_galley(
ui,
Some(TextWrapMode::Extend),
f32::INFINITY,
FontSelection::FontId(FontId::monospace(size)),
)
}
fn dot_color(dot: SegmentDot, theme: &Theme, active: bool) -> Color32 {
let p = &theme.palette;
match dot {
SegmentDot::Neutral => {
if active {
p.sky
} else {
p.text_faint
}
}
SegmentDot::Sky => p.sky,
SegmentDot::Amber => p.warning,
SegmentDot::Red => p.danger,
SegmentDot::Green => p.success,
}
}
impl<'a> Widget for SegmentedControl<'a> {
fn ui(mut self, ui: &mut Ui) -> Response {
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let size = self.size;
let track_pad = size.track_pad();
let pad_x = size.pad_x();
let pad_y = size.pad_y();
let font_size = size.font_size(&theme);
let icon_size = size.icon_size(&theme);
let count_size = (font_size - 1.5).max(10.0);
let count_h = size.count_height();
let mut prepared: Vec<Prepared> = Vec::with_capacity(self.segments.len());
for seg in &self.segments {
let icon = seg
.icon
.as_ref()
.map(|t| placeholder_galley(ui, t.text(), icon_size, false, f32::INFINITY));
let label = seg
.label
.as_ref()
.map(|t| placeholder_galley(ui, t.text(), font_size, true, f32::INFINITY));
let count = seg
.count
.as_ref()
.map(|t| count_galley(ui, t.text(), count_size));
let mut content_w = 0.0_f32;
let mut content_h = font_size;
if seg.dot.is_some() {
content_w += DOT_SIZE;
content_h = content_h.max(DOT_SIZE);
}
if let Some(g) = &icon {
if content_w > 0.0 {
content_w += INNER_GAP;
}
content_w += g.size().x;
content_h = content_h.max(g.size().y);
}
if let Some(g) = &label {
if content_w > 0.0 {
content_w += INNER_GAP;
}
content_w += g.size().x;
content_h = content_h.max(g.size().y);
}
if let Some(g) = &count {
if content_w > 0.0 {
content_w += INNER_GAP;
}
let pill_w = (g.size().x + 10.0).max(count_h);
content_w += pill_w;
content_h = content_h.max(count_h);
}
prepared.push(Prepared {
icon,
label,
count,
dot: seg.dot,
enabled: seg.enabled,
a11y: seg.debug_label(),
natural_w: pad_x * 2.0 + content_w,
natural_h: pad_y * 2.0 + content_h,
});
}
let segment_h = prepared
.iter()
.map(|s| s.natural_h)
.fold(font_size + pad_y * 2.0, f32::max);
let cell_widths: Vec<f32> = if self.fill && !prepared.is_empty() {
let avail = (ui.available_width() - track_pad * 2.0).max(0.0);
let max_natural = prepared.iter().map(|s| s.natural_w).fold(0.0_f32, f32::max);
let cell_w = (avail / prepared.len() as f32).max(max_natural);
prepared.iter().map(|_| cell_w).collect()
} else {
prepared.iter().map(|s| s.natural_w).collect()
};
let total_w = track_pad * 2.0 + cell_widths.iter().sum::<f32>();
let total_h = track_pad * 2.0 + segment_h;
let (track_rect, response) =
ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
let base_id = response.id;
let mut x = track_rect.min.x + track_pad;
let segment_y = track_rect.min.y + track_pad;
let mut cell_rects: Vec<Rect> = Vec::with_capacity(prepared.len());
let mut cell_responses: Vec<Response> = Vec::with_capacity(prepared.len());
for (i, prep) in prepared.iter().enumerate() {
let cell_rect =
Rect::from_min_size(pos2(x, segment_y), Vec2::new(cell_widths[i], segment_h));
x += cell_widths[i];
let sense = if prep.enabled {
Sense::click()
} else {
Sense::hover()
};
let cell_resp = ui.interact(cell_rect, base_id.with(("seg", i)), sense);
if prep.enabled && cell_resp.clicked() {
self.selection.click(i);
}
cell_rects.push(cell_rect);
cell_responses.push(cell_resp);
}
let is_active = |i: usize| -> bool {
i < prepared.len() && prepared[i].enabled && self.selection.is_active(i)
};
let hovered_idx = cell_responses
.iter()
.zip(prepared.iter())
.position(|(r, prep)| prep.enabled && r.hovered());
if ui.is_rect_visible(track_rect) {
let track_radius = CornerRadius::same(size.track_radius());
ui.painter().rect(
track_rect,
track_radius,
p.input_bg,
Stroke::new(1.0, p.border),
StrokeKind::Inside,
);
for (i, cell) in cell_rects.iter().enumerate().skip(1) {
let left_busy = is_active(i - 1) || hovered_idx == Some(i - 1);
let right_busy = is_active(i) || hovered_idx == Some(i);
if left_busy || right_busy {
continue;
}
let div_x = cell.min.x.round() - 0.5;
let dy = (segment_h * 0.30).min(8.0);
ui.painter().line_segment(
[pos2(div_x, cell.min.y + dy), pos2(div_x, cell.max.y - dy)],
Stroke::new(1.0, with_alpha(p.border, 200)),
);
}
let segment_radius = CornerRadius::same(size.segment_radius());
if let Some(h) = hovered_idx {
if !is_active(h) {
let hover_fill = with_alpha(p.text, if p.is_dark { 14 } else { 18 });
ui.painter().rect(
cell_rects[h].shrink(0.5),
segment_radius,
hover_fill,
Stroke::NONE,
StrokeKind::Inside,
);
}
}
for (i, cell_rect) in cell_rects.iter().enumerate().take(prepared.len()) {
if !is_active(i) {
continue;
}
let cell = cell_rect.shrink(0.5);
let shadow = cell.translate(Vec2::new(0.0, 1.0));
ui.painter().rect(
shadow,
segment_radius,
with_alpha(Color32::BLACK, if p.is_dark { 70 } else { 28 }),
Stroke::NONE,
StrokeKind::Inside,
);
ui.painter().rect(
cell,
segment_radius,
p.card,
Stroke::new(1.0, p.border),
StrokeKind::Inside,
);
}
for (i, prep) in prepared.iter().enumerate() {
let cell_rect = cell_rects[i];
let active = is_active(i);
let hovered = hovered_idx == Some(i) && !active;
let text_color = if !prep.enabled {
with_alpha(p.text_faint, 160)
} else if active || hovered {
p.text
} else {
p.text_muted
};
let count_pill_w = prep
.count
.as_ref()
.map(|g| (g.size().x + 10.0).max(count_h));
let mut content_w = 0.0_f32;
if prep.dot.is_some() {
content_w += DOT_SIZE;
}
if let Some(g) = &prep.icon {
if content_w > 0.0 {
content_w += INNER_GAP;
}
content_w += g.size().x;
}
if let Some(g) = &prep.label {
if content_w > 0.0 {
content_w += INNER_GAP;
}
content_w += g.size().x;
}
if let Some(w) = count_pill_w {
if content_w > 0.0 {
content_w += INNER_GAP;
}
content_w += w;
}
let mut cx = cell_rect.center().x - content_w * 0.5;
let cy = cell_rect.center().y;
if let Some(dot) = prep.dot {
let mut col = dot_color(dot, &theme, active);
if !prep.enabled {
col = with_alpha(col, 120);
}
ui.painter()
.circle_filled(pos2(cx + DOT_SIZE * 0.5, cy), DOT_SIZE * 0.5, col);
cx += DOT_SIZE;
}
if let Some(icon) = &prep.icon {
if cx > cell_rect.center().x - content_w * 0.5 {
cx += INNER_GAP;
}
let pos = pos2(cx, cy - icon.size().y * 0.5);
ui.painter().galley(pos, icon.clone(), text_color);
cx += icon.size().x;
}
if let Some(label) = &prep.label {
if cx > cell_rect.center().x - content_w * 0.5 {
cx += INNER_GAP;
}
let pos = pos2(cx, cy - label.size().y * 0.5);
ui.painter().galley(pos, label.clone(), text_color);
cx += label.size().x;
}
if let (Some(g), Some(pill_w)) = (&prep.count, count_pill_w) {
if cx > cell_rect.center().x - content_w * 0.5 {
cx += INNER_GAP;
}
let pill_rect = Rect::from_min_size(
pos2(cx, cy - count_h * 0.5),
Vec2::new(pill_w, count_h),
);
let (pill_bg, pill_fg) = if active {
(with_alpha(p.sky, 50), p.sky)
} else if !prep.enabled {
(with_alpha(p.text_faint, 35), with_alpha(p.text_faint, 200))
} else {
(with_alpha(p.text_muted, 45), p.text_muted)
};
ui.painter().rect(
pill_rect,
CornerRadius::same(99),
pill_bg,
Stroke::NONE,
StrokeKind::Inside,
);
let text_pos = pos2(
pill_rect.center().x - g.size().x * 0.5,
pill_rect.center().y - g.size().y * 0.5,
);
ui.painter().galley(text_pos, g.clone(), pill_fg);
}
}
}
let multi = matches!(self.selection, Selection::Multi(_));
let segment_role = if multi {
WidgetType::Checkbox
} else {
WidgetType::RadioButton
};
let group_role = if multi {
WidgetType::Other
} else {
WidgetType::RadioGroup
};
for (i, (cell_resp, prep)) in cell_responses.iter().zip(prepared.iter()).enumerate() {
let label = prep.a11y.clone();
let enabled = prep.enabled;
let selected = is_active(i);
cell_resp.widget_info(|| WidgetInfo::selected(segment_role, enabled, selected, &label));
}
response.widget_info(|| WidgetInfo::labeled(group_role, true, "segmented control"));
response
}
}