use crate::compat::Vec;
use crate::core::{Color, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::widget::capability::coercion::expect_string;
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::role_font;
use crate::widget::{BaseWidget, Draw, IconItemError, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChipOrientation {
#[default]
Horizontal,
Vertical,
}
impl ChipOrientation {
pub fn as_str(self) -> &'static str {
match self {
ChipOrientation::Horizontal => "horizontal",
ChipOrientation::Vertical => "vertical",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(token: &str) -> Option<Self> {
match token.trim().to_ascii_lowercase().as_str() {
"horizontal" => Some(ChipOrientation::Horizontal),
"vertical" => Some(ChipOrientation::Vertical),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PresetChip {
pub id: u64,
pub label: String,
}
impl PresetChip {
pub fn new(id: u64, label: impl Into<String>) -> Self {
Self { id, label: label.into() }
}
}
pub struct PresetChipBar {
base: BaseWidget,
presets: Vec<PresetChip>,
selected_preset_id: Option<u64>,
next_id: u64,
orientation: ChipOrientation,
chip_height: u32,
chip_padding: u32,
spacing: u32,
hovered_id: Option<u64>,
pub preset_selected: Signal1<u64>,
pub preset_reselected: GenericSignal,
}
impl PresetChipBar {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Chip, geometry, "PresetChipBar"),
presets: Vec::new(),
selected_preset_id: None,
next_id: 1,
orientation: ChipOrientation::Horizontal,
chip_height: 24,
chip_padding: 10,
spacing: 6,
hovered_id: None,
preset_selected: Signal1::new(),
preset_reselected: GenericSignal::new(),
}
}
pub fn presets(&self) -> &[PresetChip] {
&self.presets
}
pub fn add_preset(&mut self, label: impl Into<String>) -> u64 {
let id = self.next_id;
self.next_id = self.next_id.saturating_add(1);
self.presets.push(PresetChip::new(id, label));
self.base.request_redraw();
id
}
pub fn add_preset_with_id(
&mut self,
id: u64,
label: impl Into<String>,
) -> Result<u64, IconItemError> {
if id == 0 {
return Err(IconItemError::ItemNotFound { id });
}
if self.presets.iter().any(|preset| preset.id == id) {
return Err(IconItemError::AmbiguousItemId { id });
}
self.presets.push(PresetChip::new(id, label));
self.next_id = self.next_id.max(id.saturating_add(1));
self.base.request_redraw();
Ok(id)
}
pub fn remove_preset(&mut self, id: u64) -> bool {
let Some(index) = self.presets.iter().position(|preset| preset.id == id) else {
return false;
};
self.presets.remove(index);
if self.selected_preset_id == Some(id) {
self.selected_preset_id = None;
}
if self.hovered_id == Some(id) {
self.hovered_id = None;
}
self.base.request_redraw();
true
}
pub fn clear_presets(&mut self) {
if self.presets.is_empty() && self.selected_preset_id.is_none() {
return;
}
self.presets.clear();
self.selected_preset_id = None;
self.hovered_id = None;
self.base.request_redraw();
}
pub fn set_selected(&mut self, id: u64) -> bool {
if !self.presets.iter().any(|preset| preset.id == id) {
return false;
}
if self.selected_preset_id == Some(id) {
return true;
}
self.selected_preset_id = Some(id);
self.preset_selected.emit(id);
self.base.request_redraw();
true
}
pub fn selected_preset(&self) -> Option<u64> {
self.selected_preset_id
}
pub fn preset_at(&self, point: Point) -> Option<u64> {
self.chip_rects().into_iter().find(|(_, rect)| rect.contains_point(point)).map(|(id, _)| id)
}
pub fn orientation(&self) -> ChipOrientation {
self.orientation
}
pub fn set_orientation(&mut self, orientation: ChipOrientation) {
if self.orientation == orientation {
return;
}
self.orientation = orientation;
self.base.request_redraw();
}
pub fn chip_height(&self) -> u32 {
self.chip_height
}
pub fn set_chip_height(&mut self, height: u32) {
let height = height.max(8);
if self.chip_height == height {
return;
}
self.chip_height = height;
self.base.request_redraw();
}
pub fn chip_padding(&self) -> u32 {
self.chip_padding
}
pub fn set_chip_padding(&mut self, padding: u32) {
if self.chip_padding == padding {
return;
}
self.chip_padding = padding;
self.base.request_redraw();
}
pub fn spacing(&self) -> u32 {
self.spacing
}
pub fn set_spacing(&mut self, spacing: u32) {
if self.spacing == spacing {
return;
}
self.spacing = spacing;
self.base.request_redraw();
}
fn chip_rects(&self) -> Vec<(u64, Rect)> {
let rect = self.base.geometry();
let style = self.base.style();
let font = role_font(style, self.chip_font_size());
let mut rects = Vec::with_capacity(self.presets.len());
let mut cursor = match self.orientation {
ChipOrientation::Horizontal => rect.x,
ChipOrientation::Vertical => rect.y,
};
for preset in &self.presets {
let text = crate::widget::metrics::estimate_text_width(&preset.label, &font, 1.0);
let width = match self.orientation {
ChipOrientation::Horizontal => {
(text + self.chip_padding * 2).min(rect.width.max(1))
}
ChipOrientation::Vertical => rect.width,
};
let chip = match self.orientation {
ChipOrientation::Horizontal => {
Rect::new(cursor, rect.y, width, self.chip_height.min(rect.height.max(1)))
}
ChipOrientation::Vertical => {
Rect::new(rect.x, cursor, rect.width, self.chip_height.min(rect.height.max(1)))
}
};
rects.push((preset.id, chip));
cursor += match self.orientation {
ChipOrientation::Horizontal => width as i32 + self.spacing as i32,
ChipOrientation::Vertical => self.chip_height as i32 + self.spacing as i32,
};
}
rects
}
fn chip_font_size(&self) -> f32 {
(self.chip_height as f32 * 0.45).clamp(9.0, 14.0)
}
fn choose(&mut self, id: u64) {
let already = self.selected_preset_id == Some(id);
self.selected_preset_id = Some(id);
self.base.request_redraw();
if already {
self.preset_reselected.emit();
} else {
self.preset_selected.emit(id);
}
}
}
impl Widget for PresetChipBar {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn size_hint(&self) -> Size {
Size::new(240, self.chip_height)
}
fn is_focusable(&self) -> bool {
true
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl EventHandler for PresetChipBar {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos } => {
let hovered = self.preset_at(*pos);
if hovered != self.hovered_id {
self.hovered_id = hovered;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if self.hovered_id.take().is_some() {
self.base.request_redraw();
}
}
Event::MousePress { pos, button: 1, .. } => {
if let Some(id) = self.preset_at(*pos) {
self.choose(id);
}
}
Event::KeyPress { key, .. } => match *key {
crate::event::key_codes::LEFT | crate::event::key_codes::UP => {
self.step(-1);
}
crate::event::key_codes::RIGHT | crate::event::key_codes::DOWN => {
self.step(1);
}
crate::event::key_codes::ENTER | crate::event::key_codes::SPACE => {
if let Some(id) = self.selected_preset_id {
self.choose(id);
}
}
_ => {}
},
_ => {}
}
}
}
impl PresetChipBar {
fn step(&mut self, delta: i32) {
if self.presets.is_empty() {
return;
}
let count = self.presets.len() as i32;
let current = self
.selected_preset_id
.and_then(|id| self.presets.iter().position(|preset| preset.id == id))
.map_or(-delta, |index| index as i32);
let next = (current + delta).rem_euclid(count) as usize;
let id = self.presets[next].id;
self.choose(id);
}
}
impl Draw for PresetChipBar {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style();
let palette = crate::style::palette_shared();
let window_fill = palette.as_ref().map(|colors| colors.background).unwrap_or(Color::WHITE);
let primary = palette.as_ref().map(|colors| colors.primary).unwrap_or(Color::PRIMARY);
let ink = style.text_color.unwrap_or_else(|| {
palette.as_ref().map(|colors| colors.foreground).unwrap_or(Color::BLACK)
});
let body = match style.background_color {
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.08),
};
let border = style.border_color.unwrap_or_else(|| body.blend(&ink, 0.35));
let font = role_font(style, self.chip_font_size());
let radius = (self.chip_height / 2).max(2);
for (id, chip) in self.chip_rects() {
let selected = self.selected_preset_id == Some(id);
let hovered = self.hovered_id == Some(id);
let fill = if selected {
primary
} else if hovered {
body.blend(&ink, 0.10)
} else {
body
};
context.fill_rounded_rect(chip, radius, fill);
context.draw_rounded_rect_stroke(chip, radius, border, 1);
let label = self
.presets
.iter()
.find(|preset| preset.id == id)
.map(|preset| preset.label.as_str())
.unwrap_or("");
let text_color = if selected { primary.contrast_color() } else { ink };
let band = Rect::new(
chip.x + self.chip_padding as i32,
chip.y,
chip.width.saturating_sub(self.chip_padding * 2),
chip.height,
);
context.draw_text_line_cow(band, label, &font, text_color, HorizontalAlignment::Center);
}
}
}
impl WidgetProperties for PresetChipBar {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"selected_preset" => Ok(match self.selected_preset_id {
Some(id) => CapabilityValue::UInt(id),
None => CapabilityValue::Null,
}),
"preset_count" => Ok(CapabilityValue::UInt(self.presets.len() as u64)),
"orientation" => Ok(CapabilityValue::String(self.orientation.as_str().to_string())),
"chip_height" => Ok(CapabilityValue::UInt(u64::from(self.chip_height))),
"chip_padding" => Ok(CapabilityValue::UInt(u64::from(self.chip_padding))),
"spacing" => Ok(CapabilityValue::UInt(u64::from(self.spacing))),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"selected_preset" => {
let id = crate::widget::capability::coercion::expect_u32(value)? as u64;
if self.set_selected(id) {
Ok(())
} else {
Err(CapabilityAccessError::OutOfRange)
}
}
"orientation" => {
let token = expect_string(value)?;
let orientation =
ChipOrientation::from_str(&token).ok_or(CapabilityAccessError::TypeMismatch)?;
self.set_orientation(orientation);
Ok(())
}
"chip_height" => {
let height = crate::widget::capability::coercion::expect_u32(value)?;
self.set_chip_height(height);
Ok(())
}
"chip_padding" => {
let padding = crate::widget::capability::coercion::expect_u32(value)?;
self.set_chip_padding(padding);
Ok(())
}
"spacing" => {
let spacing = crate::widget::capability::coercion::expect_u32(value)?;
self.set_spacing(spacing);
Ok(())
}
"preset_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"selected_preset",
"preset_count",
"orientation",
"chip_height",
"chip_padding",
"spacing",
BASE_PROPERTY_NAMES
]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bar() -> PresetChipBar {
let mut bar = PresetChipBar::new(Rect::new(0, 0, 300, 24));
bar.add_preset("Today");
bar.add_preset("Last 7 days");
bar
}
#[test]
fn adding_a_preset_returns_a_unique_id() {
let mut bar = PresetChipBar::new(Rect::new(0, 0, 300, 24));
let first = bar.add_preset("A");
let second = bar.add_preset("B");
assert_ne!(first, second);
assert_eq!(bar.presets().len(), 2);
}
#[test]
fn a_pinned_id_is_never_handed_out_again() {
let mut bar = PresetChipBar::new(Rect::new(0, 0, 300, 24));
assert_eq!(bar.add_preset_with_id(9, "Pinned"), Ok(9));
let next = bar.add_preset("Next");
assert!(next > 9, "the counter must move past the pinned id");
assert_eq!(bar.add_preset_with_id(9, "Dup"), Err(IconItemError::AmbiguousItemId { id: 9 }));
assert_eq!(
bar.add_preset_with_id(0, "Zero"),
Err(IconItemError::ItemNotFound { id: 0 }),
"zero is reserved as 'no chip'"
);
}
#[test]
fn choosing_reports_selection_and_reselection_separately() {
use std::sync::{Arc, Mutex};
let mut bar = bar();
let selected = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&selected);
bar.preset_selected.connect(move |id| sink.lock().unwrap().push(*id));
let again = Arc::new(Mutex::new(0u32));
let sink = Arc::clone(&again);
bar.preset_reselected.connect(move || *sink.lock().unwrap() += 1);
let id = bar.presets()[0].id;
bar.choose(id);
bar.choose(id);
assert_eq!(selected.lock().unwrap().as_slice(), &[id]);
assert_eq!(*again.lock().unwrap(), 1);
}
#[test]
fn removing_the_selected_chip_clears_the_selection() {
let mut bar = bar();
let id = bar.presets()[0].id;
assert!(bar.set_selected(id));
assert!(bar.remove_preset(id));
assert_eq!(bar.selected_preset(), None);
}
#[test]
fn chips_are_hit_testable() {
let bar = bar();
let rects = bar.chip_rects();
assert_eq!(rects.len(), 2);
let (id, rect) = rects[1];
assert_eq!(bar.preset_at(Point::new(rect.x + 2, rect.y + 2)), Some(id));
assert_eq!(bar.preset_at(Point::new(-5, -5)), None);
}
}