use crate::compat::Cow;
use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_string, expect_usize};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, role_font, ControlMetrics};
use crate::widget::text_utils::elide_to_width;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const SEGMENT_ICON_SIDE: u32 = 14;
const SEGMENT_ICON_GAP: u32 = 4;
type SegmentedGroupEntry = (u64, u64, Option<usize>);
type SegmentedGroupTable =
std::sync::Mutex<std::collections::BTreeMap<String, SegmentedGroupEntry>>;
fn segmented_groups() -> &'static SegmentedGroupTable {
static GROUPS: std::sync::OnceLock<SegmentedGroupTable> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new()))
}
#[derive(Debug, Clone)]
pub struct Segment {
pub id: String,
pub text: String,
pub icon: Option<String>,
pub enabled: bool,
pub tooltip: Option<String>,
}
impl Segment {
pub fn new(text: &str) -> Self {
Self {
id: text.to_string(),
text: text.to_string(),
icon: None,
enabled: true,
tooltip: None,
}
}
pub fn with_id(mut self, id: &str) -> Self {
self.id = id.to_string();
self
}
pub fn with_icon(mut self, icon: &str) -> Self {
self.icon = Some(icon.to_string());
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_tooltip(mut self, tooltip: impl Into<String>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn tooltip(&self) -> Option<&str> {
self.tooltip.as_deref()
}
}
pub struct SegmentedButton {
base: BaseWidget,
segments: Vec<Segment>,
selected: Vec<usize>,
allows_multiple: bool,
group_id: Option<String>,
group_generation: u64,
pub current_changed: Signal1<usize>,
hovered_segment: Option<usize>,
focus_index: Option<usize>,
segment_labels: Vec<Option<SegmentLabelCache>>,
tooltips: crate::widget::companions::TooltipManager,
tooltip_source: Option<crate::core::ObjectId>,
tooltip_now_ms: u64,
}
struct SegmentLabelCache {
key: u64,
text: String,
width: u32,
height: u32,
}
impl SegmentedButton {
fn refresh_segment_labels(&mut self, font: &crate::core::Font, context: &RenderContext) {
if self.segment_labels.len() != self.segments.len() {
self.segment_labels.resize_with(self.segments.len(), || None);
}
for (index, segment) in self.segments.iter().enumerate() {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |value: u64| {
hash ^= value;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
};
for byte in segment.text.as_bytes() {
mix(u64::from(*byte));
}
mix(0xFF);
if let Some(icon) = segment.icon.as_deref() {
let drawable = crate::widget::icon_token_is_drawable(icon);
mix(u64::from(drawable));
for byte in icon.as_bytes() {
mix(u64::from(*byte));
}
}
for byte in font.family().as_bytes() {
mix(u64::from(*byte));
}
mix(u64::from(font.size().to_bits()));
mix(u64::from(font.weight()));
if self.segment_labels[index].as_ref().is_some_and(|entry| entry.key == hash) {
continue;
}
let mut text = String::new();
if let Some(icon) = segment.icon.as_deref() {
if !crate::widget::icon_token_is_drawable(icon) && !icon.is_empty() {
let _ = core::fmt::Write::write_fmt(
&mut text,
format_args!("{icon} {}", segment.text),
);
}
}
if text.is_empty() {
text.push_str(&segment.text);
}
let metrics = context.measure_text(&text, font);
self.segment_labels[index] = Some(SegmentLabelCache {
key: hash,
text,
width: metrics.width,
height: metrics.height,
});
}
}
fn sync_hover_tooltip(&mut self) {
let Some(index) = self.hovered_segment else {
if self.tooltip_source.take().is_some() {
self.tooltips.note_leave();
}
return;
};
let text = self
.segments
.get(index)
.and_then(Segment::tooltip)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
match text {
Some(text) => {
let id = hover_id(self.base.id(), index);
self.tooltips.register(id, text);
self.tooltips.note_hover(id);
self.tooltip_source = Some(id);
}
None => {
if self.tooltip_source.take().is_some() {
self.tooltips.note_leave();
}
}
}
}
fn tooltip_owes_frames(&self) -> bool {
self.tooltip_source.is_some() || self.tooltips.tooltip().is_some()
}
fn move_focus(&mut self, delta: i32) {
let count = self.segments.len();
if count == 0 {
return;
}
let current = self.focus_index.unwrap_or(0) as i32;
let next = (current + delta).rem_euclid(count as i32) as usize;
if self.focus_index != Some(next) {
self.focus_index = Some(next);
self.base.request_redraw();
}
}
fn activate_segment(&mut self, index: usize) {
let Some(segment) = self.segments.get(index) else {
return;
};
if !segment.enabled {
return;
}
if self.allows_multiple {
if let Some(at) = self.selected.iter().position(|sel| *sel == index) {
self.selected.remove(at);
} else {
self.selected.push(index);
self.selected.sort_unstable();
}
} else {
self.selected.clear();
self.selected.push(index);
}
self.current_changed.emit(index);
self.base.request_redraw();
}
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::SegmentedButton, geometry, "SegmentedButton"),
segments: Vec::new(),
selected: Vec::new(),
allows_multiple: false,
group_id: None,
group_generation: 0,
current_changed: Signal1::new(),
hovered_segment: None,
focus_index: None,
segment_labels: Vec::new(),
tooltips: crate::widget::companions::TooltipManager::new(geometry),
tooltip_source: None,
tooltip_now_ms: 0,
}
}
pub fn selected_changed(&self) -> &Signal1<usize> {
&self.current_changed
}
pub fn selected_index(&self) -> Option<usize> {
if self.group_owned_elsewhere() {
return None;
}
self.selected.first().copied()
}
pub fn selected_indices(&self) -> &[usize] {
if self.group_owned_elsewhere() {
return &[];
}
&self.selected
}
pub fn is_selected(&self, index: usize) -> bool {
!self.group_owned_elsewhere() && self.selected.contains(&index)
}
pub fn group_id(&self) -> Option<&str> {
self.group_id.as_deref()
}
pub fn set_group_id(&mut self, group: Option<String>) {
if self.group_id == group {
return;
}
self.group_id = group.filter(|name| !name.is_empty());
self.group_generation = 0;
self.base.request_redraw();
}
fn group_owned_elsewhere(&self) -> bool {
let Some(group) = self.group_id.as_deref() else {
return false;
};
let Ok(groups) = segmented_groups().lock() else {
return false;
};
match groups.get(group) {
Some((generation, owner, _)) => {
*generation > self.group_generation && *owner != self.base.id()
}
None => false,
}
}
fn publish_group_selection(&mut self) {
let Some(group) = self.group_id.clone() else {
return;
};
let Ok(mut groups) = segmented_groups().lock() else {
return;
};
let entry = groups.entry(group).or_insert((0, self.base.id(), None));
entry.0 = entry.0.wrapping_add(1);
entry.1 = self.base.id();
entry.2 = self.selected.first().copied();
self.group_generation = entry.0;
crate::widget::runtime::arm_global_repaint();
}
pub fn set_selected_index(&mut self, index: Option<usize>) {
let next: Vec<usize> = index.filter(|i| *i < self.segments.len()).into_iter().collect();
if next != self.selected {
self.selected = next;
if let Some(idx) = self.selected.first().copied() {
self.current_changed.emit(idx);
}
self.publish_group_selection();
self.base.request_redraw();
}
}
pub fn set_selected_indices(&mut self, indices: impl IntoIterator<Item = usize>) {
let mut next: Vec<usize> =
indices.into_iter().filter(|i| *i < self.segments.len()).collect();
next.sort_unstable();
next.dedup();
if next != self.selected {
self.selected = next;
if let Some(idx) = self.selected.first().copied() {
self.current_changed.emit(idx);
}
self.publish_group_selection();
self.base.request_redraw();
}
}
pub fn clear_selection(&mut self) {
self.set_selected_index(None);
}
pub fn allows_multiple(&self) -> bool {
self.allows_multiple
}
pub fn set_allows_multiple(&mut self, allows: bool) {
if self.allows_multiple == allows {
return;
}
self.allows_multiple = allows;
if !allows && self.selected.len() > 1 {
self.selected.truncate(1);
}
self.base.request_redraw();
}
pub fn segments(&self) -> &[Segment] {
&self.segments
}
pub fn segments_mut(&mut self) -> &mut Vec<Segment> {
&mut self.segments
}
pub fn add_segment(&mut self, segment: Segment) {
self.segments.push(segment);
self.base.request_redraw();
}
pub fn remove_segment(&mut self, index: usize) -> Option<Segment> {
if index < self.segments.len() {
let removed = self.segments.remove(index);
self.selected.retain(|sel| *sel != index);
for sel in &mut self.selected {
if *sel > index {
*sel -= 1;
}
}
self.base.request_redraw();
Some(removed)
} else {
None
}
}
pub fn segment_count(&self) -> usize {
self.segments.len()
}
fn hit_segment(&self, pos: Point) -> Option<usize> {
if self.segments.is_empty() {
return None;
}
let band = self.track_band();
if pos.y < band.y || pos.y >= band.y + band.height as i32 {
return None;
}
if pos.x < band.x || pos.x >= band.x + band.width as i32 {
return None;
}
let seg_width = band.width / self.segments.len() as u32;
if seg_width == 0 {
return None;
}
let local_x = (pos.x - band.x) as u32;
let index = (local_x / seg_width) as usize;
if index < self.segments.len() {
Some(index)
} else {
None
}
}
fn track_band(&self) -> Rect {
ControlMetrics::full_width_band(self.geometry(), dimensions::SEGMENTED_CONTROL_HEIGHT)
}
}
impl Widget for SegmentedButton {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(200, 32)
}
fn is_focusable(&self) -> bool {
true
}
fn tick(&mut self, delta_ms: u32) -> bool {
self.tooltip_now_ms = self.tooltip_now_ms.saturating_add(u64::from(delta_ms));
let changed = self.tooltips.tick(u64::from(delta_ms), self.tooltip_now_ms);
changed || self.tooltip_owes_frames()
}
fn is_animating(&self) -> bool {
self.tooltip_owes_frames()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"current_changed" => {
Some(EventSignalRef::mapped("current_changed", &self.current_changed, |v| {
CapabilityValue::UInt(*v as u64)
}))
}
_ => None,
}
}
}
impl WidgetProperties for SegmentedButton {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"selected_index" => match self.selected_index() {
Some(index) => Ok(CapabilityValue::UInt(index as u64)),
None => Ok(CapabilityValue::Null),
},
"selected_indices" => Ok(CapabilityValue::String(
self.selected_indices()
.iter()
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join(","),
)),
"allows_multiple" => Ok(CapabilityValue::Bool(self.allows_multiple())),
"segment_count" => Ok(CapabilityValue::UInt(self.segment_count() as u64)),
"group_id" => Ok(match self.group_id() {
Some(group) => CapabilityValue::String(group.to_string()),
None => CapabilityValue::Null,
}),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"selected_index" => match value {
CapabilityValue::Null => {
self.set_selected_index(None);
Ok(())
}
other => {
self.set_selected_index(Some(expect_usize(other)?));
Ok(())
}
},
"selected_indices" => {
let text = expect_string(value)?;
let indices = text
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| {
part.parse::<usize>().map_err(|_| CapabilityAccessError::TypeMismatch)
})
.collect::<Result<Vec<_>, _>>()?;
self.set_selected_indices(indices);
Ok(())
}
"allows_multiple" => {
self.set_allows_multiple(expect_bool(value)?);
Ok(())
}
"segment_count" => Err(CapabilityAccessError::ReadOnlyProperty),
"group_id" => {
match value {
CapabilityValue::Null => self.set_group_id(None),
other => self.set_group_id(Some(expect_string(other)?)),
}
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"selected_index",
"selected_indices",
"allows_multiple",
"segment_count",
"group_id",
BASE_PROPERTY_NAMES
]
}
}
fn hover_id(owner: crate::core::ObjectId, index: usize) -> crate::core::ObjectId {
owner.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (index as u64 + 1)
}
fn fitted_caption<'a>(
caption: &'a str,
max_width: u32,
measure: &mut dyn FnMut(&str) -> u32,
) -> Cow<'a, str> {
elide_to_width(caption, max_width, measure)
}
impl Draw for SegmentedButton {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let label_font = role_font(self.base.style(), 13.0);
self.refresh_segment_labels(&label_font, context);
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("segmented_button");
let (window_fill, foreground, secondary, primary) = match crate::style::palette_shared() {
Some(colors) => {
(colors.background, colors.foreground, colors.secondary, colors.primary)
}
None => (
Color::rgb(240, 240, 240),
Color::BLACK,
Color::rgb(158, 158, 158),
Color::rgb(33, 150, 243),
),
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(foreground);
let track = match style.background_color {
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.10),
};
let border_color = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != track)
.unwrap_or_else(|| track.blend(&secondary, 0.55));
let is_enabled = self.base.is_enabled();
let seg_count = self.segments.len();
let track_rect = self.track_band();
let corner_radius = (track_rect.height / 2).max(4);
let font = role_font(style, 13.0);
context.fill_rounded_rect(track_rect, corner_radius, track);
let border_color = if is_enabled { border_color } else { border_color.with_alpha(100) };
context.draw_rounded_rect_stroke(track_rect, corner_radius, border_color, 1);
if seg_count == 0 {
return;
}
let seg_width = track_rect.width / seg_count as u32;
for (i, segment) in self.segments.iter().enumerate() {
let seg_rect = Rect::new(
track_rect.x + (i as u32 * seg_width) as i32,
track_rect.y,
seg_width,
track_rect.height,
);
let is_selected = self.is_selected(i);
let seg_enabled = is_enabled && segment.enabled;
let bg_color = if !seg_enabled {
track
} else if is_selected {
primary
} else {
track
};
if is_selected {
if seg_count == 1 {
context.fill_rounded_rect(seg_rect, corner_radius, bg_color);
} else if i == 0 {
context.fill_rounded_rect(seg_rect, corner_radius, bg_color);
let right_half = Rect::new(
seg_rect.x + corner_radius as i32,
seg_rect.y,
seg_width - corner_radius,
track_rect.height,
);
context.fill_rect(right_half, bg_color);
} else if i == seg_count - 1 {
context.fill_rounded_rect(seg_rect, corner_radius, bg_color);
let left_half = Rect::new(
seg_rect.x,
seg_rect.y,
seg_width - corner_radius,
track_rect.height,
);
context.fill_rect(left_half, bg_color);
} else {
context.fill_rect(seg_rect, bg_color);
}
}
if i > 0 {
let divider_x = seg_rect.x;
let divider_color =
if is_enabled { border_color } else { border_color.with_alpha(80) };
context.draw_line(
Point::new(divider_x, seg_rect.y + 4),
Point::new(divider_x, seg_rect.y + track_rect.height as i32 - 4),
divider_color,
);
}
let text_color = if !seg_enabled {
ink.blend(&track, 0.60)
} else if is_selected {
primary.contrast_color()
} else {
ink
};
let icon_side = SEGMENT_ICON_SIDE.min(track_rect.height.saturating_sub(8)).max(1);
let drawable_icon =
segment.icon.as_deref().filter(|name| crate::widget::icon_token_is_drawable(name));
let icon_width = if drawable_icon.is_some() { icon_side } else { 0 };
let icon_gap = if icon_width > 0 { SEGMENT_ICON_GAP } else { 0 };
let cached = self.segment_labels.get(i).and_then(Option::as_ref);
let label = cached.map(|entry| entry.text.as_str()).unwrap_or(segment.text.as_str());
let label_width = cached.map(|entry| entry.width).unwrap_or(0);
let label_height = cached.map(|entry| entry.height).unwrap_or(0);
let group_width = icon_width as i32 + icon_gap as i32 + label_width as i32;
let start_x = seg_rect.x + (seg_width as i32 - group_width) / 2;
let text_y = seg_rect.y + (track_rect.height as i32 - label_height as i32) / 2;
if icon_width > 0 {
if let Some(name) = drawable_icon {
let icon_rect = Rect::new(
start_x.max(seg_rect.x),
seg_rect.y + (track_rect.height as i32 - icon_width as i32) / 2,
icon_width,
icon_width,
);
if !crate::widget::draw_icon_named(context, icon_rect, text_color, name) {
log::warn!("segment icon '{name}' names no icon and was not drawn");
}
}
}
if !label.is_empty() {
let label_x = (start_x + icon_width as i32 + icon_gap as i32).max(seg_rect.x);
let label_y = text_y.max(seg_rect.y);
let label_right = seg_rect.x + seg_width as i32 - 2;
let caption = fitted_caption(
label,
(label_right - label_x).max(0) as u32,
&mut |text: &str| context.measure_text(text, &font).width,
);
context.draw_text(
Point::new(label_x, label_y),
&caption,
&font,
text_color,
HorizontalAlignment::Left,
);
}
}
}
}
impl EventHandler for SegmentedButton {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1, .. }
if self.hit_segment(*pos).is_some_and(|index| {
self.segments.get(index).is_some_and(|segment| segment.enabled)
}) =>
{
if let Some(index) = self.hit_segment(*pos) {
self.focus_index = Some(index);
self.activate_segment(index);
}
}
Event::MouseMove { pos } => {
let hovered = self.hit_segment(*pos);
if hovered != self.hovered_segment {
self.hovered_segment = hovered;
self.sync_hover_tooltip();
self.base.request_redraw();
}
self.tooltips.note_pointer(*pos);
}
Event::MouseLeave { .. } => {
self.hovered_segment = None;
self.tooltip_source = None;
self.tooltips.note_leave();
}
Event::KeyPress { key, .. } => match *key {
37 => {
if self.allows_multiple {
self.move_focus(-1);
} else {
let count = self.segments.len();
if count > 0 {
let next = match self.selected.first() {
Some(current) => (*current + count - 1) % count,
None => count - 1,
};
self.focus_index = Some(next);
self.activate_segment(next);
}
}
}
39 => {
if self.allows_multiple {
self.move_focus(1);
} else {
let count = self.segments.len();
if count > 0 {
let next = match self.selected.first() {
Some(current) => (current + 1) % count,
None => 0,
};
self.focus_index = Some(next);
self.activate_segment(next);
}
}
}
32 | 13 if self.allows_multiple => {
if let Some(index) = self.focus_index {
self.activate_segment(index);
}
}
_ => {}
},
_ => { }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn segmented_button_default_creation() {
let btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
assert_eq!(btn.kind(), WidgetKind::SegmentedButton);
assert_eq!(btn.selected_index(), None);
assert!(!btn.allows_multiple());
assert_eq!(btn.segment_count(), 0);
}
#[test]
fn segmented_button_add_and_select_segment() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
btn.add_segment(Segment::new("Day"));
btn.add_segment(Segment::new("Week"));
btn.add_segment(Segment::new("Month"));
assert_eq!(btn.segment_count(), 3);
btn.set_selected_index(Some(1));
assert_eq!(btn.selected_index(), Some(1));
let removed = btn.remove_segment(1);
assert!(removed.is_some());
assert_eq!(removed.unwrap().text, "Week");
assert_eq!(btn.segment_count(), 2);
}
#[test]
fn multi_select_holds_more_than_one_segment() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
for label in ["A", "B", "C"] {
btn.add_segment(Segment::new(label));
}
btn.set_allows_multiple(true);
let segment_width = 300 / 3;
btn.handle_event(&Event::MousePress { pos: Point::new(10, 18), button: 1, modifiers: 0 });
assert_eq!(btn.selected_indices(), &[0]);
btn.handle_event(&Event::MousePress {
pos: Point::new(segment_width + 10, 18),
button: 1,
modifiers: 0,
});
assert_eq!(
btn.selected_indices(),
&[0, 1],
"multi-select must be able to hold two segments, not one"
);
assert_eq!(btn.selected_index(), Some(0), "the primary selection is the lowest");
btn.handle_event(&Event::MousePress { pos: Point::new(10, 18), button: 1, modifiers: 0 });
assert_eq!(btn.selected_indices(), &[1]);
btn.set_allows_multiple(false);
assert_eq!(btn.selected_indices(), &[1]);
assert!(!btn.allows_multiple());
}
#[test]
fn the_selection_set_round_trips_through_the_property_api() {
use crate::widget::capability::WidgetProperties;
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
for label in ["A", "B", "C"] {
btn.add_segment(Segment::new(label));
}
WidgetProperties::set(&mut btn, "allows_multiple", CapabilityValue::Bool(true)).unwrap();
WidgetProperties::set(&mut btn, "selected_indices", CapabilityValue::String("2,0".into()))
.unwrap();
let read = WidgetProperties::get(&btn, "selected_indices").unwrap();
assert_eq!(read, CapabilityValue::String("0,2".into()), "stored sorted and deduplicated");
assert_eq!(btn.selected_indices(), &[0, 2]);
WidgetProperties::set(&mut btn, "selected_indices", CapabilityValue::String("0,9".into()))
.unwrap();
assert_eq!(btn.selected_indices(), &[0]);
}
#[test]
fn segmented_button_current_changed_signal() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
btn.add_segment(Segment::new("A"));
btn.add_segment(Segment::new("B"));
let captured = Arc::new(Mutex::new(None));
btn.current_changed.connect({
let captured = Arc::clone(&captured);
move |val: Arc<usize>| {
*captured.lock().unwrap() = Some(*val);
}
});
btn.handle_event(&Event::mouse_press(200, 18, 1));
assert_eq!(btn.selected_index(), Some(1));
assert_eq!(*captured.lock().unwrap(), Some(1));
}
#[test]
fn segmented_button_disabled_segment_ignores_clicks() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
btn.add_segment(Segment::new("One"));
let mut seg = Segment::new("Two");
seg.enabled = false;
btn.add_segment(seg);
btn.handle_event(&Event::mouse_press(200, 18, 1));
assert_eq!(btn.selected_index(), None);
}
#[test]
fn segmented_button_svg_output() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
btn.add_segment(Segment::new("Left"));
btn.add_segment(Segment::new("Center"));
btn.add_segment(Segment::new("Right"));
btn.set_selected_index(Some(1));
let svg = crate::widget::svg::render_to_svg(&mut btn);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn a_segment_icon_is_actually_drawn() {
let mut plain = SegmentedButton::new(Rect::new(0, 0, 300, 36));
plain.add_segment(Segment::new("Left"));
let without = crate::widget::svg::render_to_svg(&mut plain);
let mut with_icon = SegmentedButton::new(Rect::new(0, 0, 300, 36));
with_icon.add_segment(Segment::new("Left").with_icon("info"));
let with = crate::widget::svg::render_to_svg(&mut with_icon);
assert_eq!(
with.matches("<path").count(),
without.matches("<path").count() + 1,
"a segment icon must add exactly one drawn glyph"
);
}
#[test]
fn an_unknown_segment_icon_string_is_shown_as_text() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
btn.add_segment(Segment::new("Left").with_icon("\u{2605}"));
let svg = crate::widget::svg::render_to_svg(&mut btn);
assert!(svg.contains("<path") || svg.contains("<text"), "the segment must still paint");
let mut plain = SegmentedButton::new(Rect::new(0, 0, 300, 36));
plain.add_segment(Segment::new("Left"));
let without = crate::widget::svg::render_to_svg(&mut plain);
assert_ne!(svg, without, "an unknown icon string must change the drawn caption");
}
#[test]
fn segmented_button_remove_adjusts_selection() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
btn.add_segment(Segment::new("X"));
btn.add_segment(Segment::new("Y"));
btn.add_segment(Segment::new("Z"));
btn.set_selected_index(Some(2));
btn.remove_segment(2);
assert_eq!(btn.selected_index(), None);
btn.set_selected_index(Some(0));
btn.remove_segment(0);
assert_eq!(btn.selected_index(), None);
}
#[test]
fn segmented_button_click_outside_does_nothing() {
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
btn.add_segment(Segment::new("Test"));
btn.handle_event(&Event::mouse_press(500, 500, 1));
assert_eq!(btn.selected_index(), None);
}
#[test]
fn the_track_keeps_its_own_height_in_any_rectangle() {
for height in [32u32, 60, 120, 300] {
let btn = SegmentedButton::new(Rect::new(0, 0, 240, height));
assert_eq!(
btn.track_band().height,
dimensions::SEGMENTED_CONTROL_HEIGHT,
"at control height {height}"
);
}
}
#[test]
fn pointer_state_reaches_the_base() {
use crate::style::WidgetState;
let mut btn = SegmentedButton::new(Rect::new(0, 0, 300, 36));
assert_eq!(btn.widget_state(), WidgetState::Normal);
btn.handle_event(&Event::MouseEnter { pos: Point::new(10, 10) });
assert_eq!(
btn.widget_state(),
WidgetState::Hover,
"the base must have been told the pointer arrived"
);
btn.handle_event(&Event::MouseLeave { pos: Point::new(500, 500) });
assert_eq!(btn.widget_state(), WidgetState::Normal, "and that it left");
}
#[test]
fn a_wide_segment_caption_is_elided_on_character_boundaries() {
let mut measure = |text: &str| text.chars().count() as u32 * 7;
assert_eq!(
fitted_caption("\u{4e16}\u{754c}\u{4f60}\u{597d}\u{4e16}\u{754c}", 35, &mut measure),
"\u{4e16}\u{754c}\u{4f60}\u{597d}\u{2026}"
);
assert_eq!(
fitted_caption(
"\u{1f389}\u{1f389}\u{1f389}\u{1f389}\u{1f389}\u{1f389}",
35,
&mut measure
),
"\u{1f389}\u{1f389}\u{1f389}\u{1f389}\u{2026}",
"an emoji is a single character to the budget, not four bytes"
);
}
#[test]
fn a_fitting_segment_caption_is_borrowed() {
let mut measure = |text: &str| text.chars().count() as u32 * 7;
assert!(matches!(
fitted_caption("Day", 35, &mut measure),
crate::compat::Cow::Borrowed("Day")
));
}
#[test]
fn a_group_id_makes_two_controls_mutually_exclusive() {
let mut first = SegmentedButton::new(Rect::new(0, 0, 200, 32));
let mut second = SegmentedButton::new(Rect::new(0, 0, 200, 32));
first.add_segment(Segment::new("Day"));
first.add_segment(Segment::new("Week"));
second.add_segment(Segment::new("Day"));
second.add_segment(Segment::new("Week"));
first.set_group_id(Some("range".to_string()));
second.set_group_id(Some("range".to_string()));
first.set_selected_index(Some(0));
assert!(first.is_selected(0));
second.set_selected_index(Some(1));
assert!(second.is_selected(1));
assert!(!first.is_selected(0), "the second control now owns the group");
assert_eq!(first.selected_index(), None);
assert_eq!(second.selected_index(), Some(1));
}
#[test]
fn the_keyboard_moves_a_single_selection() {
let mut button = SegmentedButton::new(Rect::new(0, 0, 300, 36));
button.add_segment(Segment::new("One"));
button.add_segment(Segment::new("Two"));
button.add_segment(Segment::new("Three"));
assert_eq!(button.selected_index(), None);
button.handle_event(&Event::key_press(crate::event::key_codes::RIGHT, 0));
assert_eq!(button.selected_index(), Some(0), "the first arrow picks the first segment");
button.handle_event(&Event::key_press(crate::event::key_codes::RIGHT, 0));
assert_eq!(button.selected_index(), Some(1));
button.handle_event(&Event::key_press(crate::event::key_codes::RIGHT, 0));
assert_eq!(button.selected_index(), Some(2));
button.handle_event(&Event::key_press(crate::event::key_codes::LEFT, 0));
assert_eq!(button.selected_index(), Some(1));
}
#[test]
fn the_keyboard_toggles_in_multi_select_mode() {
let mut button = SegmentedButton::new(Rect::new(0, 0, 300, 36));
button.set_allows_multiple(true);
button.add_segment(Segment::new("Bold"));
button.add_segment(Segment::new("Italic"));
button.handle_event(&Event::key_press(crate::event::key_codes::RIGHT, 0));
button.handle_event(&Event::key_press(crate::event::key_codes::SPACE, 0));
assert_eq!(button.selected_index(), Some(1));
button.handle_event(&Event::key_press(crate::event::key_codes::SPACE, 0));
assert_eq!(button.selected_index(), None, "a second Space clears it");
}
#[test]
fn a_segment_can_carry_a_tooltip() {
let segment = Segment::new("Long caption").with_tooltip("The full sentence");
assert_eq!(segment.tooltip(), Some("The full sentence"));
assert_eq!(Segment::new("Plain").tooltip(), None);
}
}