// Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 use super::model::{Entity, Model, Selectable}; use crate::theme::SegmentedButton as Style; use crate::widget::{icon, Icon}; use crate::{Element, Renderer}; use derive_setters::Setters; use iced::{ alignment, event, keyboard, mouse, touch, Background, Color, Command, Event, Length, Rectangle, Size, }; use iced_core::text::{LineHeight, Paragraph, Renderer as TextRenderer, Shaping}; use iced_core::widget::{self, operation, tree}; use iced_core::{layout, renderer, widget::Tree, Clipboard, Layout, Shell, Widget}; use iced_core::{BorderRadius, Point, Renderer as IcedRenderer, Text}; use slotmap::SecondaryMap; use std::marker::PhantomData; /// State that is maintained by each individual widget. #[derive(Default)] pub struct LocalState { /// The first focusable key. first: Entity, /// If the widget is focused or not. focused: bool, /// The key inside the widget that is currently focused. focused_key: Entity, /// The ID of the button that is being hovered. Defaults to null. hovered: Entity, /// The paragraphs for each text. paragraphs: SecondaryMap, } impl operation::Focusable for LocalState { fn is_focused(&self) -> bool { self.focused } fn focus(&mut self) { self.focused = true; self.focused_key = self.first; } fn unfocus(&mut self) { self.focused = false; self.focused_key = Entity::default(); } } /// Isolates variant-specific behaviors from [`SegmentedButton`]. pub trait SegmentedVariant { /// Get the appearance for this variant of the widget. fn variant_appearance( theme: &crate::Theme, style: &crate::theme::SegmentedButton, ) -> super::Appearance; /// Calculates the bounds for the given button by its position. fn variant_button_bounds(&self, bounds: Rectangle, position: usize) -> Rectangle; /// Calculates the layout of this variant. fn variant_layout( &self, state: &mut LocalState, renderer: &crate::Renderer, limits: &layout::Limits, ) -> layout::Node; } /// A conjoined group of items that function together as a button. #[derive(Setters)] pub struct SegmentedButton<'a, Variant, SelectionMode, Message> where Model: Selectable, SelectionMode: Default, { /// The model borrowed from the application create this widget. #[setters(skip)] pub(super) model: &'a Model, /// iced widget ID pub(super) id: Option, /// The icon used for the close button. pub(super) close_icon: Icon, /// Show the close icon only when item is hovered. pub(super) show_close_icon_on_hover: bool, /// Padding around a button. pub(super) button_padding: [u16; 4], /// Desired height of a button. pub(super) button_height: u16, /// Spacing between icon and text in button. pub(super) button_spacing: u16, /// Spacing for each indent. pub(super) indent_spacing: u16, /// Desired font for active tabs. pub(super) font_active: Option, /// Desired font for hovered tabs. pub(super) font_hovered: Option, /// Desired font for inactive tabs. pub(super) font_inactive: Option, /// Size of the font. pub(super) font_size: f32, /// Desired width of the widget. pub(super) width: Length, /// Desired height of the widget. pub(super) height: Length, /// Desired spacing between items. pub(super) spacing: u16, /// LineHeight of the font. pub(super) line_height: LineHeight, /// Style to draw the widget in. #[setters(into)] pub(super) style: Style, /// Emits the ID of the item that was activated. #[setters(skip)] pub(super) on_activate: Option Message + 'static>>, #[setters(skip)] pub(super) on_close: Option Message + 'static>>, #[setters(skip)] /// Defines the implementation of this struct variant: PhantomData, } impl<'a, Variant, SelectionMode, Message> SegmentedButton<'a, Variant, SelectionMode, Message> where Self: SegmentedVariant, Model: Selectable, SelectionMode: Default, { #[must_use] pub fn new(model: &'a Model) -> Self { Self { model, id: None, close_icon: icon::from_name("window-close-symbolic").size(16).icon(), show_close_icon_on_hover: false, button_padding: [4, 4, 4, 4], button_height: 32, button_spacing: 4, indent_spacing: 16, font_active: None, font_hovered: None, font_inactive: None, font_size: 14.0, height: Length::Shrink, width: Length::Fill, spacing: 0, line_height: LineHeight::default(), style: Style::default(), on_activate: None, on_close: None, variant: PhantomData, } } pub fn on_activate(mut self, on_activate: T) -> Self where T: Fn(Entity) -> Message + 'static, { self.on_activate = Some(Box::new(on_activate)); self } pub fn on_close(mut self, on_close: T) -> Self where T: Fn(Entity) -> Message + 'static, { self.on_close = Some(Box::new(on_close)); self } /// Check if an item is enabled. fn is_enabled(&self, key: Entity) -> bool { self.model.items.get(key).map_or(false, |item| item.enabled) } /// Focus the previous item in the widget. fn focus_previous(&mut self, state: &mut LocalState) -> event::Status { let mut keys = self.model.order.iter().copied().rev(); while let Some(key) = keys.next() { if key == state.focused_key { for key in keys { // Skip disabled buttons. if !self.is_enabled(key) { continue; } state.focused_key = key; return event::Status::Captured; } break; } } state.focused_key = Entity::default(); event::Status::Ignored } /// Focus the next item in the widget. fn focus_next(&mut self, state: &mut LocalState) -> event::Status { let mut keys = self.model.order.iter().copied(); while let Some(key) = keys.next() { if key == state.focused_key { for key in keys { // Skip disabled buttons. if !self.is_enabled(key) { continue; } state.focused_key = key; return event::Status::Captured; } break; } } state.focused_key = Entity::default(); event::Status::Ignored } pub(super) fn max_button_dimensions( &self, state: &mut LocalState, renderer: &Renderer, bounds: Size, ) -> (f32, f32) { let mut width = 0.0f32; let mut height = 0.0f32; let font = renderer.default_font(); for key in self.model.order.iter().copied() { let mut button_width = 0.0f32; let mut button_height = 0.0f32; // Add text to measurement if text was given. if let Some((text, entry)) = self.model.text.get(key).zip(state.paragraphs.entry(key)) { let paragraph = entry.or_insert_with(|| { crate::Paragraph::with_text(Text { content: text, size: iced::Pixels(self.font_size), bounds: Size::INFINITY, font, horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, shaping: Shaping::Advanced, line_height: self.line_height, }) }); let Size { width, height } = paragraph.min_bounds(); button_width = width; button_height = height; } // Add indent to measurement if found. if let Some(indent) = self.model.indent(key) { button_width += f32::from(indent) * f32::from(self.indent_spacing); } // Add icon to measurement if icon was given. if let Some(icon) = self.model.icon(key) { button_height = button_height.max(f32::from(icon.size)); button_width += f32::from(icon.size) + f32::from(self.button_spacing); } // Add close button to measurement if found. if self.model.is_closable(key) { button_height = button_height.max(f32::from(self.close_icon.size)); button_width += f32::from(self.close_icon.size) + f32::from(self.button_spacing) + 8.0; } height = height.max(button_height); width = width.max(button_width); } // Add button padding to the max size found width += f32::from(self.button_padding[0]) + f32::from(self.button_padding[2]); height += f32::from(self.button_padding[1]) + f32::from(self.button_padding[3]); height = height.max(f32::from(self.button_height)); (width, height) } } impl<'a, Variant, SelectionMode, Message> Widget for SegmentedButton<'a, Variant, SelectionMode, Message> where Self: SegmentedVariant, Model: Selectable, SelectionMode: Default, Message: 'static + Clone, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { // update the paragraphs for the model tree::State::new(LocalState { first: self.model.order.iter().copied().next().unwrap_or_default(), paragraphs: SecondaryMap::new(), ..LocalState::default() }) } fn diff(&mut self, tree: &mut Tree) { for e in self.model.order.iter().copied() { if let Some(text) = self.model.text.get(e) { let text = Text { content: text, size: iced::Pixels(self.font_size), bounds: Size::INFINITY, font: self.font_active.unwrap_or(crate::font::FONT), horizontal_alignment: alignment::Horizontal::Left, vertical_alignment: alignment::Vertical::Center, shaping: Shaping::Advanced, line_height: self.line_height, }; if let Some(paragraph) = tree .state .downcast_mut::() .paragraphs .get_mut(e) { paragraph.update(text); } else { tree.state .downcast_mut::() .paragraphs .insert(e, crate::Paragraph::with_text(text)); } } } } fn width(&self) -> Length { self.width } fn height(&self) -> Length { self.height } fn layout( &self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { self.variant_layout(tree.state.downcast_mut::(), renderer, limits) } fn on_event( &mut self, tree: &mut Tree, event: Event, layout: Layout<'_>, cursor_position: mouse::Cursor, _renderer: &Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &iced::Rectangle, ) -> event::Status { let bounds = layout.bounds(); let state = tree.state.downcast_mut::(); if cursor_position.is_over(bounds) { for (nth, key) in self.model.order.iter().copied().enumerate() { let bounds = self.variant_button_bounds(bounds, nth); if cursor_position.is_over(bounds) { if self.model.items[key].enabled { // Record that the mouse is hovering over this button. state.hovered = key; // If marked as closable, show a close icon. if self.model.items[key].closable { if let Some(on_close) = self.on_close.as_ref() { if cursor_position.is_over(close_bounds( bounds, f32::from(self.close_icon.size), self.button_padding, )) { if let Event::Mouse(mouse::Event::ButtonReleased( mouse::Button::Left, )) | Event::Touch(touch::Event::FingerLifted { .. }) = event { shell.publish(on_close(key)); return event::Status::Captured; } } } } if let Some(on_activate) = self.on_activate.as_ref() { if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) = event { shell.publish(on_activate(key)); return event::Status::Captured; } } } break; } } } else { state.hovered = Entity::default(); } if state.focused { if let Event::Keyboard(keyboard::Event::KeyPressed { key_code: keyboard::KeyCode::Tab, modifiers, .. }) = event { return if modifiers.shift() { self.focus_previous(state) } else { self.focus_next(state) }; } if let Some(on_activate) = self.on_activate.as_ref() { if let Event::Keyboard(keyboard::Event::KeyReleased { key_code: keyboard::KeyCode::Enter, .. }) = event { shell.publish(on_activate(state.focused_key)); return event::Status::Captured; } } } event::Status::Ignored } fn operate( &self, tree: &mut Tree, _layout: Layout<'_>, _renderer: &Renderer, operation: &mut dyn iced_core::widget::Operation< iced_core::widget::OperationOutputWrapper, >, ) { let state = tree.state.downcast_mut::(); operation.focusable(state, self.id.as_ref().map(|id| &id.0)); } fn mouse_interaction( &self, _tree: &Tree, layout: Layout<'_>, cursor_position: mouse::Cursor, _viewport: &iced::Rectangle, _renderer: &Renderer, ) -> iced_core::mouse::Interaction { let bounds = layout.bounds(); if cursor_position.is_over(bounds) { for (nth, key) in self.model.order.iter().copied().enumerate() { if cursor_position.is_over(self.variant_button_bounds(bounds, nth)) { return if self.model.items[key].enabled { iced_core::mouse::Interaction::Pointer } else { iced_core::mouse::Interaction::Idle }; } } } iced_core::mouse::Interaction::Idle } #[allow(clippy::too_many_lines)] fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &crate::Theme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &iced::Rectangle, ) { let state = tree.state.downcast_ref::(); let appearance = Self::variant_appearance(theme, &self.style); let bounds = layout.bounds(); let button_amount = self.model.items.len(); // Draw the background, if a background was defined. if let Some(background) = appearance.background { renderer.fill_quad( renderer::Quad { bounds, border_radius: appearance.border_radius, border_width: 0.0, border_color: Color::TRANSPARENT, }, background, ); } // Draw each of the items in the widget. for (nth, key) in self.model.order.iter().copied().enumerate() { let mut bounds = self.variant_button_bounds(bounds, nth); let key_is_active = self.model.is_active(key); let key_is_hovered = state.hovered == key; let (status_appearance, font) = if state.focused_key == key { (appearance.focus, &self.font_active) } else if key_is_active { (appearance.active, &self.font_active) } else if key_is_hovered { (appearance.hover, &self.font_hovered) } else { (appearance.inactive, &self.font_inactive) }; let font = font.unwrap_or_else(|| renderer.default_font()); let button_appearance = if nth == 0 { status_appearance.first } else if nth + 1 == button_amount { status_appearance.last } else { status_appearance.middle }; // Render the background of the button. if status_appearance.background.is_some() { renderer.fill_quad( renderer::Quad { bounds, border_radius: button_appearance.border_radius, border_width: 0.0, border_color: Color::TRANSPARENT, }, status_appearance .background .unwrap_or(Background::Color(Color::TRANSPARENT)), ); } // Draw the bottom border defined for this button. if let Some((width, background)) = button_appearance.border_bottom { let mut bounds = bounds; bounds.y = bounds.y + bounds.height - width; bounds.height = width; renderer.fill_quad( renderer::Quad { bounds, border_radius: BorderRadius::from(0.0), border_width: 0.0, border_color: Color::TRANSPARENT, }, background, ); } let original_bounds = bounds; let y = bounds.center_y(); // Adjust bounds by indent if let Some(indent) = self.model.indent(key) { let adjustment = f32::from(indent) * f32::from(self.indent_spacing); bounds.x += adjustment; bounds.width -= adjustment; } // Draw the image beside the text. let horizontal_alignment = if let Some(icon) = self.model.icon(key) { bounds.x += f32::from(self.button_padding[0]); bounds.y += f32::from(self.button_padding[1]); bounds.width -= f32::from(self.button_padding[0]) - f32::from(self.button_padding[2]); bounds.height -= f32::from(self.button_padding[1]) - f32::from(self.button_padding[3]); let width = f32::from(icon.size); let offset = width + f32::from(self.button_spacing); bounds.y = y - width / 2.0; let mut layout_node = layout::Node::new(Size { width, height: width, }); layout_node.move_to(Point { x: bounds.x, y: bounds.y, }); Widget::::draw( Element::::from(icon.clone()).as_widget(), &Tree::empty(), renderer, theme, &renderer::Style { icon_color: status_appearance.text_color, text_color: status_appearance.text_color, scale_factor: style.scale_factor, }, Layout::new(&layout_node), cursor, viewport, ); bounds.x += offset; bounds.width -= offset; alignment::Horizontal::Left } else { bounds.x = bounds.center_x(); alignment::Horizontal::Center }; if let Some(text) = self.model.text(key) { bounds.y = y; // Draw the text in this button. renderer.fill_text( iced_core::text::Text { content: text, size: iced::Pixels(self.font_size), bounds: bounds.size(), font, horizontal_alignment, vertical_alignment: alignment::Vertical::Center, shaping: Shaping::Advanced, line_height: self.line_height, }, bounds.position(), status_appearance.text_color, ); } let show_close_button = (key_is_active || !self.show_close_icon_on_hover || key_is_hovered) && self.model.is_closable(key); // Draw a close button if this is set. if show_close_button { let width = f32::from(self.close_icon.size); let icon_bounds = close_bounds(original_bounds, width, self.button_padding); let mut layout_node = layout::Node::new(Size { width: icon_bounds.width, height: icon_bounds.height, }); layout_node.move_to(Point { x: icon_bounds.x, y: icon_bounds.y, }); Widget::::draw( &Element::::from(self.close_icon.clone()), &Tree::empty(), renderer, theme, &renderer::Style { icon_color: status_appearance.text_color, text_color: status_appearance.text_color, scale_factor: style.scale_factor, }, Layout::new(&layout_node), cursor, viewport, ); } } } fn overlay<'b>( &'b mut self, _tree: &'b mut Tree, _layout: iced_core::Layout<'_>, _renderer: &Renderer, ) -> Option> { None } } impl<'a, Variant, SelectionMode, Message> From> for Element<'a, Message> where SegmentedButton<'a, Variant, SelectionMode, Message>: SegmentedVariant, Variant: 'static, Model: Selectable, SelectionMode: Default, Message: 'static + Clone, { fn from(mut widget: SegmentedButton<'a, Variant, SelectionMode, Message>) -> Self { if widget.model.items.is_empty() { widget.spacing = 0; } Self::new(widget) } } /// A command that focuses a segmented item stored in a widget. pub fn focus(id: Id) -> Command { Command::widget(operation::focusable::focus(id.0)) } /// The iced identifier of a segmented button. #[derive(Debug, Clone, PartialEq)] pub struct Id(widget::Id); impl Id { /// Creates a custom [`Id`]. pub fn new(id: impl Into>) -> Self { Self(widget::Id::new(id)) } /// Creates a unique [`Id`]. /// /// This function produces a different [`Id`] every time it is called. #[must_use] pub fn unique() -> Self { Self(widget::Id::unique()) } } impl From for widget::Id { fn from(id: Id) -> Self { id.0 } } /// Calculates the bounds of the close button within the area of an item. fn close_bounds(area: Rectangle, icon_size: f32, button_padding: [u16; 4]) -> Rectangle { let unpadded_height = area.height - f32::from(button_padding[1]) - f32::from(button_padding[3]); Rectangle { x: area.x + area.width - icon_size - 8.0, y: area.y + (unpadded_height / 2.0) - (icon_size / 2.0), width: icon_size, height: icon_size, } }