libcosmic-yoda/src/widget/segmented_button/widget.rs
2026-04-03 20:36:23 +02:00

2772 lines
99 KiB
Rust

// Copyright 2022 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
use super::model::{Entity, Model, Selectable};
use super::{InsertPosition, ReorderEvent};
use crate::iced_core::id::Internal;
use crate::theme::{SegmentedButton as Style, THEME};
use crate::widget::dnd_destination::DragId;
use crate::widget::menu::{
self, CloseCondition, ItemHeight, ItemWidth, MenuBarState, PathHighlight, menu_roots_children,
menu_roots_diff,
};
use crate::widget::{Icon, icon};
use crate::{Element, Renderer};
use derive_setters::Setters;
use iced::clipboard::dnd::{
self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent, SourceEvent,
};
use iced::clipboard::mime::AllowedMimeTypes;
use iced::touch::Finger;
use iced::{
Alignment, Background, Color, Event, Length, Padding, Rectangle, Size, Task, Vector, alignment,
keyboard, mouse, touch, window,
};
use iced_core::mouse::ScrollDelta;
use iced_core::text::{self, Ellipsize, LineHeight, Renderer as TextRenderer, Shaping, Wrapping};
use iced_core::widget::operation::Focusable;
use iced_core::widget::{self, operation, tree};
use iced_core::{Border, Point, Renderer as IcedRenderer, Shadow, Text};
use iced_core::{Clipboard, Layout, Shell, Widget, layout, renderer, widget::Tree};
use iced_runtime::{Action, task};
use slotmap::{Key, SecondaryMap};
use std::borrow::Cow;
use std::cell::{Cell, LazyCell};
use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
use std::time::{Duration, Instant};
thread_local! {
// Prevents two segmented buttons from being focused at the same time.
static LAST_FOCUS_UPDATE: LazyCell<Cell<Instant>> = LazyCell::new(|| Cell::new(Instant::now()));
}
const TAB_REORDER_LOG_TARGET: &str = "libcosmic::widget::tab_reorder";
/// A command that focuses a segmented item stored in a widget.
pub fn focus<Message: 'static>(id: Id) -> Task<Message> {
task::effect(Action::Widget(Box::new(operation::focusable::focus(id.0))))
}
pub enum ItemBounds {
Button(Entity, Rectangle),
Divider(Rectangle, bool),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DropSide {
Before,
After,
}
impl From<DropSide> for InsertPosition {
fn from(side: DropSide) -> Self {
match side {
DropSide::Before => InsertPosition::Before,
DropSide::After => InsertPosition::After,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DropHint {
entity: Entity,
side: DropSide,
}
/// Isolates variant-specific behaviors from [`SegmentedButton`].
pub trait SegmentedVariant {
const VERTICAL: bool;
/// 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 visible buttons.
fn variant_bounds<'b>(
&'b self,
state: &'b LocalState,
bounds: Rectangle,
) -> Box<dyn Iterator<Item = ItemBounds> + 'b>;
/// Calculates the layout of this variant.
fn variant_layout(
&self,
state: &mut LocalState,
renderer: &crate::Renderer,
limits: &layout::Limits,
) -> Size;
}
/// A conjoined group of items that function together as a button.
#[derive(Setters)]
#[must_use]
pub struct SegmentedButton<'a, Variant, SelectionMode, Message>
where
Model<SelectionMode>: Selectable,
SelectionMode: Default,
{
/// The model borrowed from the application create this widget.
#[setters(skip)]
pub(super) model: &'a Model<SelectionMode>,
/// iced widget ID
pub(super) id: Id,
/// The icon used for the close button.
pub(super) close_icon: Icon,
/// Scrolling switches focus between tabs.
pub(super) scrollable_focus: bool,
/// Show the close icon only when item is hovered.
pub(super) show_close_icon_on_hover: bool,
/// Padding of the whole widget.
#[setters(into)]
pub(super) padding: Padding,
/// Whether to place dividers between buttons.
pub(super) dividers: bool,
/// Alignment of button contents.
pub(super) button_alignment: Alignment,
/// 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,
/// Maximum width of a button.
pub(super) maximum_button_width: u16,
/// Minimum width of a button.
pub(super) minimum_button_width: u16,
/// Spacing for each indent.
pub(super) indent_spacing: u16,
/// Desired font for active tabs.
pub(super) font_active: crate::font::Font,
/// Desired font for hovered tabs.
pub(super) font_hovered: crate::font::Font,
/// Desired font for inactive tabs.
pub(super) font_inactive: crate::font::Font,
/// 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,
/// Ellipsize strategy for button text.
pub(super) ellipsize: Ellipsize,
/// Style to draw the widget in.
#[setters(into)]
pub(super) style: Style,
/// The context menu to display when a context is activated
#[setters(skip)]
pub(super) context_menu: Option<Vec<menu::Tree<Message>>>,
/// Emits the ID of the item that was activated.
#[setters(skip)]
pub(super) on_activate: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_close: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_context: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_middle_press: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_dnd_drop:
Option<Box<dyn Fn(Entity, Vec<u8>, String, DndAction) -> Message + 'static>>,
pub(super) mimes: Vec<String>,
#[setters(skip)]
pub(super) on_dnd_enter: Option<Box<dyn Fn(Entity, Vec<String>) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_dnd_leave: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(strip_option)]
pub(super) drag_id: Option<DragId>,
#[setters(skip)]
pub(super) tab_drag: Option<TabDragSource<Message>>,
#[setters(skip)]
pub(super) on_drop_hint: Option<Box<dyn Fn(Option<(Entity, bool)>) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_reorder: Option<Box<dyn Fn(ReorderEvent) -> Message + 'static>>,
#[setters(skip)]
/// Defines the implementation of this struct
variant: PhantomData<Variant>,
}
impl<'a, Variant, SelectionMode, Message> SegmentedButton<'a, Variant, SelectionMode, Message>
where
Self: SegmentedVariant,
Model<SelectionMode>: Selectable,
SelectionMode: Default,
{
#[inline]
pub fn new(model: &'a Model<SelectionMode>) -> Self {
Self {
model,
id: Id::unique(),
close_icon: icon::from_name("window-close-symbolic").size(16).icon(),
scrollable_focus: false,
show_close_icon_on_hover: false,
button_alignment: Alignment::Start,
padding: Padding::from(0.0),
dividers: false,
button_padding: [0, 0, 0, 0],
button_height: 32,
button_spacing: 0,
minimum_button_width: u16::MIN,
maximum_button_width: u16::MAX,
indent_spacing: 16,
font_active: crate::font::semibold(),
font_hovered: crate::font::default(),
font_inactive: crate::font::default(),
font_size: 14.0,
height: Length::Shrink,
width: Length::Fill,
spacing: 0,
line_height: LineHeight::default(),
ellipsize: Ellipsize::default(),
style: Style::default(),
context_menu: None,
on_activate: None,
on_close: None,
on_context: None,
on_middle_press: None,
on_dnd_drop: None,
on_dnd_enter: None,
on_dnd_leave: None,
mimes: Vec::new(),
variant: PhantomData,
drag_id: None,
tab_drag: None,
on_drop_hint: None,
on_reorder: None,
}
}
fn update_entity_paragraph(&mut self, state: &mut LocalState, key: Entity) {
if let Some(text) = self.model.text.get(key) {
let font = if self.button_is_focused(state, key) {
self.font_active
} else if state.show_context == Some(key) || self.button_is_hovered(state, key) {
self.font_hovered
} else if self.model.is_active(key) {
self.font_active
} else {
self.font_inactive
};
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
font.hash(&mut hasher);
let text_hash = hasher.finish();
if let Some(prev_hash) = state.text_hashes.insert(key, text_hash) {
if prev_hash == text_hash {
return;
}
}
if let Some(paragraph) = state.paragraphs.get_mut(key) {
let text = Text {
content: text.as_ref(),
size: iced::Pixels(self.font_size),
bounds: Size::INFINITE,
font,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: Shaping::Advanced,
wrapping: Wrapping::None,
line_height: self.line_height,
ellipsize: self.ellipsize,
};
paragraph.update(text);
} else {
let text = Text {
content: text.to_string(),
size: iced::Pixels(self.font_size),
bounds: Size::INFINITE,
font,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: Shaping::Advanced,
wrapping: Wrapping::None,
line_height: self.line_height,
ellipsize: self.ellipsize,
};
state.paragraphs.insert(key, crate::Plain::new(text));
}
}
}
pub fn context_menu(mut self, context_menu: Option<Vec<menu::Tree<Message>>>) -> Self
where
Message: Clone + 'static,
{
self.context_menu = context_menu.map(|menus| {
vec![menu::Tree::with_children(
crate::Element::from(crate::widget::row::<'static, Message>()),
menus,
)]
});
if let Some(ref mut context_menu) = self.context_menu {
context_menu.iter_mut().for_each(menu::Tree::set_index);
}
self
}
/// Emitted when a tab is pressed.
pub fn on_activate<T>(mut self, on_activate: T) -> Self
where
T: Fn(Entity) -> Message + 'static,
{
self.on_activate = Some(Box::new(on_activate));
self
}
/// Emitted when a tab close button is pressed.
pub fn on_close<T>(mut self, on_close: T) -> Self
where
T: Fn(Entity) -> Message + 'static,
{
self.on_close = Some(Box::new(on_close));
self
}
/// Emitted when a button is right-clicked.
pub fn on_context<T>(mut self, on_context: T) -> Self
where
T: Fn(Entity) -> Message + 'static,
{
self.on_context = Some(Box::new(on_context));
self
}
/// Emitted when the middle mouse button is pressed on a button.
pub fn on_middle_press<T>(mut self, on_middle_press: T) -> Self
where
T: Fn(Entity) -> Message + 'static,
{
self.on_middle_press = Some(Box::new(on_middle_press));
self
}
/// Enable drag-and-drop support for tabs using the provided payload builder.
pub fn enable_tab_drag(mut self, mime: String) -> Self {
self.tab_drag = Some(TabDragSource::new(mime));
self
}
/// Receive drop hint updates during drag-and-drop.
pub fn on_drop_hint(
mut self,
callback: impl Fn(Option<(Entity, bool)>) -> Message + 'static,
) -> Self {
self.on_drop_hint = Some(Box::new(callback));
self
}
/// Emit a message when a tab drag is dropped inside this widget.
pub fn on_reorder(mut self, callback: impl Fn(ReorderEvent) -> Message + 'static) -> Self {
self.on_reorder = Some(Box::new(callback));
self
}
/// Set the pointer distance threshold before a drag is started.
pub fn tab_drag_threshold(mut self, threshold: f32) -> Self {
if let Some(tab_drag) = self.tab_drag.as_mut() {
tab_drag.threshold = threshold.max(1.0);
}
self
}
fn reorder_event_for_drop(&self, state: &LocalState, target: Entity) -> Option<ReorderEvent> {
let dragged = state.dragging_tab?;
if dragged == target
|| !self.model.contains_item(dragged)
|| !self.model.contains_item(target)
{
return None;
}
let position = state
.drop_hint
.filter(|hint| hint.entity == target)
.map(|hint| InsertPosition::from(hint.side))
.unwrap_or_else(|| self.default_insert_position(dragged, target));
Some(ReorderEvent {
dragged,
target,
position,
})
}
fn default_insert_position(&self, dragged: Entity, target: Entity) -> InsertPosition {
let len = self.model.len();
let target_pos = self
.model
.position(target)
.map(|pos| pos as usize)
.unwrap_or(len);
let from_pos = self
.model
.position(dragged)
.map(|pos| pos as usize)
.unwrap_or(target_pos);
if from_pos < target_pos {
InsertPosition::After
} else {
InsertPosition::Before
}
}
/// Check if an item is enabled.
fn is_enabled(&self, key: Entity) -> bool {
self.model.items.get(key).is_some_and(|item| item.enabled)
}
/// Handle the dnd drop event.
pub fn on_dnd_drop<D: AllowedMimeTypes>(
mut self,
dnd_drop_handler: impl Fn(Entity, Option<D>, DndAction) -> Message + 'static,
) -> Self {
self.on_dnd_drop = Some(Box::new(move |entity, data, mime, action| {
dnd_drop_handler(entity, D::try_from((data, mime)).ok(), action)
}));
self.mimes = D::allowed().into_owned();
self
}
/// Handle the dnd enter event.
pub fn on_dnd_enter(
mut self,
dnd_enter_handler: impl Fn(Entity, Vec<String>) -> Message + 'static,
) -> Self {
self.on_dnd_enter = Some(Box::new(dnd_enter_handler));
self
}
/// Handle the dnd leave event.
pub fn on_dnd_leave(mut self, dnd_leave_handler: impl Fn(Entity) -> Message + 'static) -> Self {
self.on_dnd_leave = Some(Box::new(dnd_leave_handler));
self
}
/// Item the previous item in the widget.
fn focus_previous(&mut self, state: &mut LocalState, shell: &mut Shell<'_, Message>) {
match state.focused_item {
Item::Tab(entity) => {
let mut keys = self.iterate_visible_tabs(state).rev();
while let Some(key) = keys.next() {
if key == entity {
for key in keys {
// Skip disabled buttons.
if !self.is_enabled(key) {
continue;
}
state.focused_item = Item::Tab(key);
shell.capture_event();
return;
}
break;
}
}
if self.prev_tab_sensitive(state) {
state.focused_item = Item::PrevButton;
shell.capture_event();
return;
}
}
Item::NextButton => {
if let Some(last) = self.last_tab(state) {
state.focused_item = Item::Tab(last);
shell.capture_event();
return;
}
}
Item::None => {
if self.next_tab_sensitive(state) {
state.focused_item = Item::NextButton;
shell.capture_event();
return;
} else if let Some(last) = self.last_tab(state) {
state.focused_item = Item::Tab(last);
shell.capture_event();
return;
}
}
Item::PrevButton | Item::Set => (),
}
state.focused_item = Item::None;
}
/// Item the next item in the widget.
fn focus_next(&mut self, state: &mut LocalState, shell: &mut Shell<'_, Message>) {
match state.focused_item {
Item::Tab(entity) => {
let mut keys = self.iterate_visible_tabs(state);
while let Some(key) = keys.next() {
if key == entity {
for key in keys {
// Skip disabled buttons.
if !self.is_enabled(key) {
continue;
}
state.focused_item = Item::Tab(key);
shell.capture_event();
return;
}
break;
}
}
if self.next_tab_sensitive(state) {
state.focused_item = Item::NextButton;
shell.capture_event();
return;
}
}
Item::PrevButton => {
if let Some(first) = self.first_tab(state) {
state.focused_item = Item::Tab(first);
shell.capture_event();
return;
}
}
Item::None => {
if self.prev_tab_sensitive(state) {
state.focused_item = Item::PrevButton;
shell.capture_event();
return;
} else if let Some(first) = self.first_tab(state) {
state.focused_item = Item::Tab(first);
shell.capture_event();
return;
}
}
Item::NextButton | Item::Set => (),
}
state.focused_item = Item::None;
}
fn iterate_visible_tabs<'b>(
&'b self,
state: &LocalState,
) -> impl DoubleEndedIterator<Item = Entity> + 'b {
self.model
.order
.iter()
.copied()
.skip(state.buttons_offset)
.take(state.buttons_visible)
}
fn first_tab(&self, state: &LocalState) -> Option<Entity> {
self.model.order.get(state.buttons_offset).copied()
}
fn last_tab(&self, state: &LocalState) -> Option<Entity> {
self.model
.order
.get(state.buttons_offset + state.buttons_visible)
.copied()
}
#[allow(clippy::unused_self)]
fn prev_tab_sensitive(&self, state: &LocalState) -> bool {
state.buttons_offset > 0
}
fn next_tab_sensitive(&self, state: &LocalState) -> bool {
state.buttons_offset < self.model.order.len() - state.buttons_visible
}
pub(super) fn button_dimensions(
&self,
state: &mut LocalState,
font: crate::font::Font,
button: Entity,
) -> (f32, f32) {
let mut width = 0.0f32;
let mut icon_spacing = 0.0f32;
// Add text to measurement if text was given.
if let Some((text, entry)) = self
.model
.text
.get(button)
.zip(state.paragraphs.entry(button))
&& !text.is_empty()
{
icon_spacing = f32::from(self.button_spacing);
let paragraph = entry.or_insert_with(|| {
crate::Plain::new(Text {
content: text.to_string(), // TODO should we just use String at this point?
size: iced::Pixels(self.font_size),
bounds: Size::INFINITE,
font,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: Shaping::Advanced,
wrapping: Wrapping::default(),
ellipsize: self.ellipsize,
line_height: self.line_height,
})
});
let size = paragraph.min_bounds();
width += size.width;
}
// Add indent to measurement if found.
if let Some(indent) = self.model.indent(button) {
width = f32::from(indent).mul_add(f32::from(self.indent_spacing), width);
}
// Add icon to measurement if icon was given.
if let Some(icon) = self.model.icon(button) {
width += f32::from(icon.size) + icon_spacing;
} else if self.model.is_active(button) {
// Add selection icon measurements when widget is a selection widget.
if let crate::theme::SegmentedButton::Control = self.style {
width += 16.0 + icon_spacing;
}
}
// Add close button to measurement if found.
if self.model.is_closable(button) {
width += f32::from(self.close_icon.size) + f32::from(self.button_spacing);
}
// Add button padding to the max size found
width += f32::from(self.button_padding[0]) + f32::from(self.button_padding[2]);
width = width.min(f32::from(self.maximum_button_width));
(width, f32::from(self.button_height))
}
/// Resizes paragraph bounds based on the actual available button width so that
/// text ellipsis can take effect. Call this after `variant_layout` has populated
/// `state.internal_layout` with final button sizes.
pub(super) fn resize_paragraphs(&self, state: &mut LocalState, available_width: f32) {
if matches!(self.ellipsize, Ellipsize::None) {
return;
}
for (nth, key) in self.model.order.iter().copied().enumerate() {
if self.model.text(key).is_some_and(|text| !text.is_empty()) {
let mut non_text_width =
f32::from(self.button_padding[0]) + f32::from(self.button_padding[2]);
if let Some(icon) = self.model.icon(key) {
non_text_width += f32::from(icon.size) + f32::from(self.button_spacing);
} else if self.model.is_active(key) {
if let crate::theme::SegmentedButton::Control = self.style {
non_text_width += 16.0 + f32::from(self.button_spacing);
}
}
if self.model.is_closable(key) {
non_text_width +=
f32::from(self.close_icon.size) + f32::from(self.button_spacing);
}
let text_width = (available_width - non_text_width).max(0.0);
if let Some(paragraph) = state.paragraphs.get_mut(key) {
paragraph.resize(Size::new(text_width, f32::INFINITY));
// Update internal_layout actual content width so that
// button_alignment centering uses the ellipsized size.
let content_width = paragraph.min_bounds().width + non_text_width
- f32::from(self.button_padding[0])
- f32::from(self.button_padding[2]);
if let Some(entry) = state.internal_layout.get_mut(nth) {
entry.1.width = content_width;
}
}
}
}
}
pub(super) fn max_button_dimensions(
&self,
state: &mut LocalState,
renderer: &Renderer,
) -> (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 (button_width, button_height) = self.button_dimensions(state, font, key);
state.internal_layout.push((
Size::new(button_width, button_height),
Size::new(
button_width
- f32::from(self.button_padding[0])
- f32::from(self.button_padding[2]),
button_height,
),
));
height = height.max(button_height);
width = width.max(button_width);
}
for (size, actual) in &mut state.internal_layout {
size.height = height;
actual.height = height;
}
(width, height)
}
fn button_is_focused(&self, state: &LocalState, key: Entity) -> bool {
state.focused.is_some()
&& self.on_activate.is_some()
&& Item::Tab(key) == state.focused_item
}
fn button_is_hovered(&self, state: &LocalState, key: Entity) -> bool {
self.on_activate.is_some() && state.hovered == Item::Tab(key)
|| state
.dnd_state
.drag_offer
.as_ref()
.is_some_and(|id| id.data.is_some_and(|d| d == key))
}
fn button_is_pressed(&self, state: &LocalState, key: Entity) -> bool {
state.pressed_item == Some(Item::Tab(key))
}
fn emit_drop_hint(&self, shell: &mut Shell<'_, Message>, hint: Option<DropHint>) {
if let Some(on_hint) = self.on_drop_hint.as_ref() {
let mapped = hint.map(|hint| (hint.entity, matches!(hint.side, DropSide::After)));
shell.publish(on_hint(mapped));
}
}
fn drop_hint_for_position(
&self,
state: &LocalState,
bounds: Rectangle,
cursor: Point,
) -> Option<DropHint> {
let _ = state.dragging_tab?;
self.variant_bounds(state, bounds)
.filter_map(|item| match item {
ItemBounds::Button(entity, rect) if rect.contains(cursor) => Some((entity, rect)),
_ => None,
})
.map(|(entity, rect)| {
let before = if Self::VERTICAL {
cursor.y < rect.center_y()
} else {
cursor.x < rect.center_x()
};
DropHint {
entity,
side: if before {
DropSide::Before
} else {
DropSide::After
},
}
})
.next()
}
fn start_tab_drag(
&self,
state: &mut LocalState,
entity: Entity,
bounds: Rectangle,
cursor: Point,
clipboard: &mut dyn Clipboard,
) -> bool {
let Some(tab_drag) = self.tab_drag.as_ref() else {
return false;
};
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"start_tab_drag requested entity={:?} cursor=({:.2},{:.2}) bounds=({:.2},{:.2},{:.2},{:.2}) threshold={}",
entity,
cursor.x,
cursor.y,
bounds.x,
bounds.y,
bounds.width,
bounds.height,
tab_drag.threshold
);
let data_len = 0;
iced_core::clipboard::start_dnd::<crate::Theme, crate::Renderer>(
clipboard,
false,
Some(iced_core::clipboard::DndSource::Widget(self.id.0.clone())),
None,
Box::new(SimpleDragData::new(tab_drag.mime.clone(), vec![1])),
DndAction::Move,
);
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"tab drag started entity={:?} mime={} bytes={}",
entity,
tab_drag.mime,
data_len
);
state.dragging_tab = Some(entity);
state.tab_drag_candidate = None;
state.pressed_item = None;
true
}
/// Returns the drag id of the destination.
///
/// # Panics
/// Panics if the destination has been assigned a Set id, which is invalid.
#[must_use]
pub fn get_drag_id(&self) -> u128 {
self.drag_id.map_or_else(
|| {
u128::from(match &self.id.0.0 {
Internal::Unique(id) | Internal::Custom(id, _) => *id,
Internal::Set(_) => panic!("Invalid Id assigned to dnd destination."),
})
},
|id| id.0,
)
}
}
impl<Variant, SelectionMode, Message> Widget<Message, crate::Theme, Renderer>
for SegmentedButton<'_, Variant, SelectionMode, Message>
where
Self: SegmentedVariant,
Model<SelectionMode>: Selectable,
SelectionMode: Default,
Message: 'static + Clone,
{
fn id(&self) -> Option<widget::Id> {
Some(self.id.0.clone())
}
fn set_id(&mut self, id: widget::Id) {
self.id = Id(id);
}
fn children(&self) -> Vec<Tree> {
let mut children = Vec::new();
// Assign the context menu's elements as this widget's children.
if let Some(ref context_menu) = self.context_menu {
let mut tree = Tree::empty();
tree.state = tree::State::new(MenuBarState::default());
tree.children = menu_roots_children(context_menu);
children.push(tree);
}
children
}
fn tag(&self) -> tree::Tag {
tree::Tag::of::<LocalState>()
}
fn state(&self) -> tree::State {
#[allow(clippy::default_trait_access)]
tree::State::new(LocalState {
menu_state: Default::default(),
paragraphs: SecondaryMap::new(),
text_hashes: SecondaryMap::new(),
buttons_visible: Default::default(),
buttons_offset: Default::default(),
collapsed: Default::default(),
focused: Default::default(),
focused_item: Default::default(),
focused_visible: false,
hovered: Default::default(),
known_length: Default::default(),
middle_clicked: Default::default(),
internal_layout: Default::default(),
context_cursor: Point::default(),
show_context: Default::default(),
wheel_timestamp: Default::default(),
dnd_state: Default::default(),
fingers_pressed: Default::default(),
pressed_item: None,
tab_drag_candidate: None,
dragging_tab: None,
drop_hint: None,
offer_mimes: Vec::new(),
})
}
fn diff(&mut self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<LocalState>();
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
// Diff the context menu
if let Some(context_menu) = &mut self.context_menu {
state.menu_state.inner.with_data_mut(|inner| {
menu_roots_diff(context_menu, &mut inner.tree);
});
}
// Unfocus if another segmented control was focused.
if let Some(f) = state.focused.as_ref()
&& f.updated_at != LAST_FOCUS_UPDATE.with(|f| f.get())
{
state.unfocus();
}
}
fn size(&self) -> Size<Length> {
Size::new(self.width, self.height)
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let state = tree.state.downcast_mut::<LocalState>();
let limits = limits.shrink(self.padding);
let size = self
.variant_layout(state, renderer, &limits)
.expand(self.padding);
layout::Node::new(size)
}
#[allow(clippy::too_many_lines)]
fn update(
&mut self,
tree: &mut Tree,
mut event: &Event,
layout: Layout<'_>,
cursor_position: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &iced::Rectangle,
) {
let my_bounds = layout.bounds();
let state = tree.state.downcast_mut::<LocalState>();
let my_id = self.get_drag_id();
if let Event::Dnd(e) = &mut event {
let entity = state
.dnd_state
.drag_offer
.as_ref()
.map(|dnd_state| dnd_state.data);
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"segmented button {:?} received DnD event: {:?} entity={entity:?}",
my_id,
e
);
match e {
DndEvent::Source(SourceEvent::Cancelled | SourceEvent::Finished) => {
if state.dragging_tab.take().is_some() {
state.tab_drag_candidate = None;
state.drop_hint = None;
self.emit_drop_hint(shell, state.drop_hint);
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"tab drag source finished id={:?}",
my_id
);
shell.capture_event();
return;
}
}
DndEvent::Offer(
id,
OfferEvent::Enter {
x, y, mime_types, ..
},
) if Some(my_id) == *id => {
let entity = self
.variant_bounds(state, my_bounds)
.filter_map(|item| match item {
ItemBounds::Button(entity, bounds) => Some((entity, bounds)),
_ => None,
})
.find(|(_key, bounds)| bounds.contains(Point::new(*x as f32, *y as f32)))
.map(|(key, _)| key);
state.drop_hint = self.drop_hint_for_position(
state,
my_bounds,
Point::new(*x as f32, *y as f32),
);
self.emit_drop_hint(shell, state.drop_hint);
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"offer enter id={my_id:?} entity={entity:?} @ ({x},{y}) mimes={mime_types:?}"
);
// force hovered state update
if let Some(entity) = entity {
state.hovered = Item::Tab(entity);
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
}
let on_dnd_enter = self
.on_dnd_enter
.as_ref()
.zip(entity)
.map(|(on_enter, entity)| move |_, _, mimes| on_enter(entity, mimes));
let mimes = if let Some(mime) = self.tab_drag.as_ref().map(|d| &d.mime)
&& mime_types.is_empty()
{
vec![mime.clone()]
} else {
mime_types.clone()
};
state.offer_mimes.clone_from(&mimes);
_ = state
.dnd_state
.on_enter::<Message>(*x, *y, mimes, on_dnd_enter, entity);
}
DndEvent::Offer(id, OfferEvent::LeaveDestination) if Some(my_id) != *id => {}
DndEvent::Offer(id, leave)
if matches!(leave, OfferEvent::Leave | OfferEvent::LeaveDestination)
&& Some(my_id) == *id =>
{
state.drop_hint = None;
self.emit_drop_hint(shell, state.drop_hint);
if let Some(Some(entity)) = entity {
if let Some(on_dnd_leave) = self.on_dnd_leave.as_ref() {
shell.publish(on_dnd_leave(entity));
}
}
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"offer leave id={my_id:?} entity={entity:?}"
);
state.hovered = Item::None;
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
_ = state.dnd_state.on_leave::<Message>(None);
}
DndEvent::Offer(id, OfferEvent::Motion { x, y }) if Some(my_id) == *id => {
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"offer motion id={my_id:?} cursor=({x},{y}) current_entity={entity:?}"
);
let new = self
.variant_bounds(state, my_bounds)
.filter_map(|item| match item {
ItemBounds::Button(entity, bounds) => Some((entity, bounds)),
_ => None,
})
.find(|(_key, bounds)| bounds.contains(Point::new(*x as f32, *y as f32)))
.map(|(key, _)| key);
if let Some(new_entity) = new {
state.dnd_state.on_motion::<Message>(
*x,
*y,
None::<fn(_, _) -> Message>,
None::<fn(_, _, _) -> Message>,
Some(new_entity),
);
state.drop_hint = self.drop_hint_for_position(
state,
my_bounds,
Point::new(*x as f32, *y as f32),
);
self.emit_drop_hint(shell, state.drop_hint);
if Some(Some(new_entity)) != entity {
state.hovered = Item::Tab(new_entity);
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
let prev_action = state
.dnd_state
.drag_offer
.as_ref()
.map(|dnd| dnd.selected_action);
if let Some(on_dnd_enter) = self.on_dnd_enter.as_ref() {
shell.publish(on_dnd_enter(new_entity, state.offer_mimes.clone()));
}
if let Some(dnd) = state.dnd_state.drag_offer.as_mut() {
dnd.data = Some(new_entity);
if let Some(prev_action) = prev_action {
dnd.selected_action = prev_action;
}
}
}
} else if entity.is_some() {
state.hovered = Item::None;
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"offer motion leaving id={my_id:?}"
);
state.drop_hint = None;
self.emit_drop_hint(shell, state.drop_hint);
state.dnd_state.on_motion::<Message>(
*x,
*y,
None::<fn(_, _) -> Message>,
None::<fn(_, _, _) -> Message>,
None,
);
if let Some(on_dnd_leave) = self.on_dnd_leave.as_ref() {
if let Some(Some(entity)) = entity {
shell.publish(on_dnd_leave(entity));
}
}
}
}
DndEvent::Offer(id, OfferEvent::Drop) if Some(my_id) == *id => {
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"offer drop id={my_id:?} entity={entity:?}"
);
_ = state
.dnd_state
.on_drop::<Message>(None::<fn(_, _) -> Message>);
}
DndEvent::Offer(id, OfferEvent::SelectedAction(action)) if Some(my_id) == *id => {
if state.dnd_state.drag_offer.is_some() {
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"offer selected action id={my_id:?} action={action:?} entity={entity:?}"
);
_ = state
.dnd_state
.on_action_selected::<Message>(*action, None::<fn(_) -> Message>);
}
}
DndEvent::Offer(id, OfferEvent::Data { data, mime_type }) if Some(my_id) == *id => {
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"offer data id={my_id:?} entity={entity:?} mime={mime_type:?}"
);
let drop_entity = entity
.flatten()
.or_else(|| state.drop_hint.map(|hint| hint.entity));
let allow_reorder = state
.dnd_state
.drag_offer
.as_ref()
.is_some_and(|offer| offer.selected_action.contains(DndAction::Move));
let pending_reorder = if allow_reorder
&& self.on_reorder.is_some()
&& self.tab_drag.as_ref().is_some_and(|d| d.mime == *mime_type)
&& state.dragging_tab.is_some()
{
drop_entity.and_then(|target| self.reorder_event_for_drop(state, target))
} else {
None
};
if let Some(entity) = drop_entity {
let on_drop = self.on_dnd_drop.as_ref();
let on_drop = on_drop.map(|on_drop| {
|mime, data, action, _, _| on_drop(entity, data, mime, action)
});
let (maybe_msg, ret) = state.dnd_state.on_data_received(
mime_type.clone(),
data.clone(),
None::<fn(_, _) -> Message>,
on_drop,
);
if matches!(ret, iced::event::Status::Captured) {
shell.capture_event();
}
if let Some(msg) = maybe_msg {
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"publishing drop message entity={entity:?}"
);
shell.publish(msg);
}
state.drop_hint = None;
self.emit_drop_hint(shell, state.drop_hint);
if let Some(event) = pending_reorder {
state.focused_item = Item::Tab(event.dragged);
state.hovered = Item::None;
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
if let Some(on_reorder) = self.on_reorder.as_ref() {
shell.publish(on_reorder(event));
shell.capture_event();
return;
}
}
return;
}
}
_ => {}
}
}
if cursor_position.is_over(my_bounds) {
let fingers_pressed = state.fingers_pressed.len();
match event {
Event::Touch(touch::Event::FingerPressed { id, .. }) => {
state.fingers_pressed.insert(*id);
}
Event::Touch(touch::Event::FingerLifted { id, .. }) => {
state.fingers_pressed.remove(id);
}
_ => (),
}
// Check for clicks on the previous and next tab buttons, when tabs are collapsed.
if state.collapsed {
// Check if the prev tab button was clicked.
if cursor_position
.is_over(prev_tab_bounds(&my_bounds, f32::from(self.button_height)))
&& self.prev_tab_sensitive(state)
{
state.hovered = Item::PrevButton;
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
| Event::Touch(touch::Event::FingerLifted { .. }) = event
{
state.buttons_offset -= 1;
}
} else {
// Check if the next tab button was clicked.
if cursor_position
.is_over(next_tab_bounds(&my_bounds, f32::from(self.button_height)))
&& self.next_tab_sensitive(state)
{
state.hovered = Item::NextButton;
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
| Event::Touch(touch::Event::FingerLifted { .. }) = event
{
state.buttons_offset += 1;
}
}
}
}
for (key, bounds) in self
.variant_bounds(state, my_bounds)
.filter_map(|item| match item {
ItemBounds::Button(entity, bounds) => Some((entity, bounds)),
_ => None,
})
.collect::<Vec<_>>()
{
if cursor_position.is_over(bounds) {
if self.model.items[key].enabled {
// Record that the mouse is hovering over this button.
if state.hovered != Item::Tab(key) {
state.hovered = Item::Tab(key);
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
}
let close_button_bounds =
close_bounds(bounds, f32::from(self.close_icon.size));
let over_close_button = self.model.items[key].closable
&& cursor_position.is_over(close_button_bounds);
// If marked as closable, show a close icon.
if self.model.items[key].closable {
// Emit close message if the close button is pressed.
if let Some(on_close) = self.on_close.as_ref() {
if over_close_button
&& (left_button_released(&event)
|| (touch_lifted(&event) && fingers_pressed == 1))
{
shell.publish(on_close(key));
shell.capture_event();
return;
}
if self.on_middle_press.is_none() {
// Emit close message if the tab is middle clicked.
if let Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Middle,
)) = event
{
if state.middle_clicked == Some(Item::Tab(key)) {
shell.publish(on_close(key));
shell.capture_event();
return;
}
state.middle_clicked = None;
}
}
}
}
if self.tab_drag.is_some()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
&& !over_close_button
&& let Some(position) = cursor_position.position()
{
state.tab_drag_candidate = Some(TabDragCandidate {
entity: key,
bounds,
origin: position,
});
if let Some(tab_drag) = self.tab_drag.as_ref() {
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"tab drag candidate entity={:?} origin=({:.2},{:.2}) bounds=({:.2},{:.2},{:.2},{:.2}) threshold={}",
key,
position.x,
position.y,
bounds.x,
bounds.y,
bounds.width,
bounds.height,
tab_drag.threshold
);
}
}
if is_lifted(&event) {
state.unfocus();
}
if let Some(on_activate) = self.on_activate.as_ref() {
if is_pressed(event) {
state.pressed_item = Some(Item::Tab(key));
} else if is_lifted(&event) && self.button_is_pressed(state, key) {
shell.publish(on_activate(key));
state.set_focused();
state.focused_item = Item::Tab(key);
state.pressed_item = None;
shell.capture_event();
return;
}
}
// Present a context menu on a right click event.
if self.context_menu.is_some()
&& let Some(on_context) = self.on_context.as_ref()
&& (right_button_released(&event)
|| (touch_lifted(&event) && fingers_pressed == 2))
{
state.show_context = Some(key);
state.context_cursor = cursor_position.position().unwrap_or_default();
state.menu_state.inner.with_data_mut(|data| {
data.open = true;
data.view_cursor = cursor_position;
});
shell.publish(on_context(key));
shell.capture_event();
return;
}
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) =
event
{
state.middle_clicked = Some(Item::Tab(key));
if let Some(on_middle_press) = self.on_middle_press.as_ref() {
shell.publish(on_middle_press(key));
shell.capture_event();
return;
}
}
}
break;
} else if state.hovered == Item::Tab(key) {
state.hovered = Item::None;
self.update_entity_paragraph(state, key);
}
}
if self.scrollable_focus
&& let Some(on_activate) = self.on_activate.as_ref()
&& let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event
{
let current = Instant::now();
// Permit successive scroll wheel events only after a given delay.
if state.wheel_timestamp.is_none_or(|previous| {
current.duration_since(previous) > Duration::from_millis(250)
}) {
state.wheel_timestamp = Some(current);
match delta {
ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } => {
let mut activate_key = None;
if *y < 0.0 {
let mut prev_key = Entity::null();
for key in self.model.order.iter().copied() {
if self.model.is_active(key) && !prev_key.is_null() {
activate_key = Some(prev_key);
}
if self.model.is_enabled(key) {
prev_key = key;
}
}
} else if *y > 0.0 {
let mut buttons = self.model.order.iter().copied();
while let Some(key) = buttons.next() {
if self.model.is_active(key) {
for key in buttons {
if self.model.is_enabled(key) {
activate_key = Some(key);
break;
}
}
break;
}
}
}
if let Some(key) = activate_key {
shell.publish(on_activate(key));
state.set_focused();
state.focused_item = Item::Tab(key);
shell.capture_event();
return;
}
}
}
}
}
} else {
if let Item::Tab(key) = std::mem::replace(&mut state.hovered, Item::None) {
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
}
if state.is_focused() {
// Unfocus on clicks outside of the boundaries of the segmented button.
if is_pressed(&event) {
state.unfocus();
state.pressed_item = None;
return;
}
} else if is_lifted(&event) {
state.pressed_item = None;
}
}
if let (Some(tab_drag), Some(candidate)) =
(self.tab_drag.as_ref(), state.tab_drag_candidate)
&& let Event::Mouse(mouse::Event::CursorMoved { .. }) = event
&& let Some(position) = cursor_position.position()
&& position.distance(candidate.origin) >= tab_drag.threshold
&& let Some(candidate) = state.tab_drag_candidate.take()
{
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"tab drag threshold met entity={:?} distance={:.2} threshold={}",
candidate.entity,
position.distance(candidate.origin),
tab_drag.threshold
);
if self.start_tab_drag(
state,
candidate.entity,
candidate.bounds,
position,
clipboard,
) {
shell.capture_event();
return;
}
}
if matches!(
event,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
) {
state.tab_drag_candidate = None;
}
if state.is_focused() {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Tab),
modifiers,
..
}) = event
{
state.focused_visible = true;
return if *modifiers == keyboard::Modifiers::SHIFT {
self.focus_previous(state, shell);
} else if modifiers.is_empty() {
self.focus_next(state, shell);
};
}
if let Some(on_activate) = self.on_activate.as_ref()
&& let Event::Keyboard(keyboard::Event::KeyReleased {
key: keyboard::Key::Named(keyboard::key::Named::Enter),
..
}) = event
{
match state.focused_item {
Item::Tab(entity) => {
shell.publish(on_activate(entity));
}
Item::PrevButton => {
if self.prev_tab_sensitive(state) {
state.buttons_offset -= 1;
// If the change would cause it to be insensitive, focus the first tab.
if !self.prev_tab_sensitive(state)
&& let Some(first) = self.first_tab(state)
{
state.focused_item = Item::Tab(first);
}
}
}
Item::NextButton => {
if self.next_tab_sensitive(state) {
state.buttons_offset += 1;
// If the change would cause it to be insensitive, focus the last tab.
if !self.next_tab_sensitive(state)
&& let Some(last) = self.last_tab(state)
{
state.focused_item = Item::Tab(last);
}
}
}
Item::None | Item::Set => (),
}
shell.capture_event();
}
}
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
_renderer: &Renderer,
operation: &mut dyn iced_core::widget::Operation<()>,
) {
let state = tree.state.downcast_mut::<LocalState>();
operation.focusable(Some(&self.id.0), layout.bounds(), state);
operation.custom(Some(&self.id.0), layout.bounds(), state);
if let Item::Set = state.focused_item {
if self.prev_tab_sensitive(state) {
state.focused_item = Item::PrevButton;
} else if let Some(first) = self.first_tab(state) {
state.focused_item = Item::Tab(first);
}
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor_position: mouse::Cursor,
_viewport: &iced::Rectangle,
_renderer: &Renderer,
) -> iced_core::mouse::Interaction {
if self.on_activate.is_none() {
return iced_core::mouse::Interaction::default();
}
let state = tree.state.downcast_ref::<LocalState>();
let bounds = layout.bounds();
if cursor_position.is_over(bounds) {
let hovered_button = self
.variant_bounds(state, bounds)
.filter_map(|item| match item {
ItemBounds::Button(entity, bounds) => Some((entity, bounds)),
_ => None,
})
.find(|(_key, bounds)| cursor_position.is_over(*bounds));
if let Some((key, _bounds)) = hovered_button {
return if self.model.items[key].enabled {
iced_core::mouse::Interaction::Pointer
} else {
iced_core::mouse::Interaction::Idle
};
}
}
iced_core::mouse::Interaction::default()
}
#[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::<LocalState>();
let appearance = Self::variant_appearance(theme, &self.style);
let bounds: Rectangle = layout.bounds();
let button_amount = self.model.items.len();
let show_drop_hint = state.dragging_tab.is_some();
let drop_hint = if show_drop_hint {
state.drop_hint
} else {
None
};
// Draw the background, if a background was defined.
if let Some(background) = appearance.background {
renderer.fill_quad(
renderer::Quad {
bounds,
border: appearance.border,
shadow: Shadow::default(),
snap: true,
},
background,
);
}
// Draw previous and next tab buttons if there is a need to paginate tabs.
if state.collapsed {
let mut tab_bounds = prev_tab_bounds(&bounds, f32::from(self.button_height));
// Previous tab button
let mut background_appearance =
if self.on_activate.is_some() && Item::PrevButton == state.focused_item {
Some(appearance.active)
} else if self.on_activate.is_some() && Item::PrevButton == state.hovered {
Some(appearance.hover)
} else {
None
};
if let Some(background_appearance) = background_appearance.take() {
renderer.fill_quad(
renderer::Quad {
bounds: tab_bounds,
border: Border {
radius: theme.cosmic().radius_s().into(),
..Default::default()
},
shadow: Shadow::default(),
snap: true,
},
background_appearance
.background
.unwrap_or(Background::Color(Color::TRANSPARENT)),
);
}
draw_icon::<Message>(
renderer,
theme,
style,
cursor,
viewport,
if state.buttons_offset == 0 {
appearance.inactive.text_color
} else {
appearance.active.text_color
},
Rectangle {
x: tab_bounds.x + 8.0,
y: tab_bounds.y + f32::from(self.button_height) / 4.0,
width: 16.0,
height: 16.0,
},
icon::from_name("go-previous-symbolic").size(16).icon(),
);
tab_bounds = next_tab_bounds(&bounds, f32::from(self.button_height));
// Next tab button
background_appearance =
if self.on_activate.is_some() && Item::NextButton == state.focused_item {
Some(appearance.active)
} else if self.on_activate.is_some() && Item::NextButton == state.hovered {
Some(appearance.hover)
} else {
None
};
if let Some(background_appearance) = background_appearance {
renderer.fill_quad(
renderer::Quad {
bounds: tab_bounds,
border: Border {
radius: theme.cosmic().radius_s().into(),
..Default::default()
},
shadow: Shadow::default(),
snap: true,
},
background_appearance
.background
.unwrap_or(Background::Color(Color::TRANSPARENT)),
);
}
draw_icon::<Message>(
renderer,
theme,
style,
cursor,
viewport,
if self.next_tab_sensitive(state) {
appearance.active.text_color
} else if let Item::NextButton = state.focused_item {
appearance.active.text_color
} else {
appearance.inactive.text_color
},
Rectangle {
x: tab_bounds.x + 8.0,
y: tab_bounds.y + f32::from(self.button_height) / 4.0,
width: 16.0,
height: 16.0,
},
icon::from_name("go-next-symbolic").size(16).icon(),
);
}
let rad_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0;
let divider_background = Background::Color(
crate::theme::active()
.cosmic()
.primary_component_divider()
.into(),
);
// Draw each of the items in the widget.
let mut nth = 0;
let drop_hint_marker = drop_hint;
let show_drop_hint_marker = show_drop_hint;
self.variant_bounds(state, bounds).for_each(move |item| {
let (key, mut bounds) = match item {
// Draw a button
ItemBounds::Button(entity, bounds) => (entity, bounds),
// Draw a divider between buttons
ItemBounds::Divider(bounds, accented) => {
renderer.fill_quad(
renderer::Quad {
bounds,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
},
{
let theme = crate::theme::active();
if accented {
Background::Color(theme.cosmic().small_widget_divider().into())
} else {
Background::Color(theme.cosmic().primary_container_divider().into())
}
},
);
return;
}
};
let original_bounds = bounds;
let center_y = bounds.center_y();
if show_drop_hint_marker
&& matches!(
drop_hint_marker,
Some(DropHint {
entity,
side: DropSide::Before
}) if entity == key
)
{
draw_drop_indicator(
renderer,
original_bounds,
DropSide::Before,
Self::VERTICAL,
appearance.active.text_color,
);
}
let menu_open = || {
state.show_context == Some(key)
&& !tree.children.is_empty()
&& tree.children[0]
.state
.downcast_ref::<MenuBarState>()
.inner
.with_data(|data| data.open)
};
let key_is_active = self.model.is_active(key);
let key_is_focused = state.focused_visible && self.button_is_focused(state, key);
let key_is_hovered = self.button_is_hovered(state, key);
let status_appearance = if self.button_is_pressed(state, key) {
appearance.pressed
} else if key_is_hovered || menu_open() {
appearance.hover
} else if key_is_active {
appearance.active
} else {
appearance.inactive
};
let button_appearance = if nth == 0 {
status_appearance.first
} else if nth + 1 == button_amount {
status_appearance.last
} else {
status_appearance.middle
};
// Draw the active hint on tabs
if appearance.active_width > 0.0 {
let active_width = if key_is_active {
appearance.active_width
} else {
1.0
};
renderer.fill_quad(
renderer::Quad {
bounds: if Self::VERTICAL {
Rectangle {
x: bounds.x + bounds.width - active_width,
width: active_width,
..bounds
}
} else {
Rectangle {
y: bounds.y + bounds.height - active_width,
height: active_width,
..bounds
}
},
border: Border {
radius: rad_0.into(),
..Default::default()
},
shadow: Shadow::default(),
snap: true,
},
appearance.active.text_color,
);
}
bounds.x += f32::from(self.button_padding[0]);
bounds.width -= f32::from(self.button_padding[0]) - f32::from(self.button_padding[2]);
let mut indent_padding = 0.0;
// Adjust bounds by indent
if let Some(indent) = self.model.indent(key)
&& indent > 0
{
let adjustment = f32::from(indent) * f32::from(self.indent_spacing);
bounds.x += adjustment;
bounds.width -= adjustment;
// Draw indent line
if let crate::theme::SegmentedButton::FileNav = self.style
&& indent > 1
{
indent_padding = 7.0;
for level in 1..indent {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: (level as f32)
.mul_add(-(self.indent_spacing as f32), bounds.x)
+ indent_padding,
width: 1.0,
..bounds
},
border: Border {
radius: rad_0.into(),
..Default::default()
},
shadow: Shadow::default(),
snap: true,
},
divider_background,
);
}
indent_padding += 4.0;
}
}
// Render the background of the button.
if key_is_focused || status_appearance.background.is_some() {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: bounds.x - f32::from(self.button_padding[0]) + indent_padding,
width: bounds.width + f32::from(self.button_padding[0])
- f32::from(self.button_padding[2])
- indent_padding,
..bounds
},
border: if key_is_focused {
Border {
width: 1.0,
color: appearance.active.text_color,
radius: button_appearance.border.radius,
}
} else {
button_appearance.border
},
shadow: Shadow::default(),
snap: true,
},
status_appearance
.background
.unwrap_or(Background::Color(Color::TRANSPARENT)),
);
}
// Align contents of the button to the requested `button_alignment`.
{
// Avoid shifting content outside the left edge when the measured content is
// wider than the available button bounds (for example, non-ellipsized text).
let actual_width = state.internal_layout[nth].1.width.min(bounds.width);
let offset = match self.button_alignment {
Alignment::Start => None,
Alignment::Center => Some((bounds.width - actual_width) / 2.0),
Alignment::End => Some(bounds.width - actual_width),
};
if let Some(offset) = offset {
bounds.x += offset - f32::from(self.button_padding[0]);
bounds.width = actual_width;
}
}
// Draw the image beside the text.
if let Some(icon) = self.model.icon(key) {
let mut image_bounds = bounds;
let width = f32::from(icon.size);
let offset = width + f32::from(self.button_spacing);
image_bounds.y = center_y - width / 2.0;
draw_icon::<Message>(
renderer,
theme,
style,
cursor,
viewport,
status_appearance.text_color,
Rectangle {
width,
height: width,
..image_bounds
},
icon.clone(),
);
bounds.x += offset;
} else {
// Draw the selection indicator if widget is a segmented selection, and the item is selected.
if key_is_active && let crate::theme::SegmentedButton::Control = self.style {
let mut image_bounds = bounds;
image_bounds.y = center_y - 8.0;
draw_icon::<Message>(
renderer,
theme,
style,
cursor,
viewport,
status_appearance.text_color,
Rectangle {
width: 16.0,
height: 16.0,
..image_bounds
},
crate::widget::icon(match crate::widget::common::object_select().data() {
crate::iced_core::svg::Data::Bytes(bytes) => {
crate::widget::icon::from_svg_bytes(bytes.as_ref()).symbolic(true)
}
crate::iced_core::svg::Data::Path(path) => {
crate::widget::icon::from_path(path.clone())
}
}),
);
let offset = 16.0 + f32::from(self.button_spacing);
bounds.x += offset;
}
}
// Whether to show the close button on this tab.
let show_close_button =
(key_is_active || !self.show_close_icon_on_hover || key_is_hovered)
&& self.model.is_closable(key);
// Width of the icon used by the close button, which we will subtract from the text bounds.
let close_icon_width = if show_close_button {
f32::from(self.close_icon.size)
} else {
0.0
};
bounds.width = original_bounds.width
- (bounds.x - original_bounds.x)
- close_icon_width
- f32::from(self.button_padding[2]);
bounds.y = center_y;
if self.model.text(key).is_some_and(|text| !text.is_empty()) {
// FIXME why has this behavior changed? Does the center alignment not work with infinite bounds now?
bounds.y -= state.paragraphs[key].min_height() / 2.;
// Draw the text for this segmented button or tab.
renderer.fill_paragraph(
state.paragraphs[key].raw(),
bounds.position(),
status_appearance.text_color,
Rectangle {
x: bounds.x,
width: bounds.width,
height: original_bounds.height,
y: bounds.y,
// ..original_bounds,
},
);
}
// Draw a close button if set.
if show_close_button {
let close_button_bounds = close_bounds(original_bounds, close_icon_width);
draw_icon::<Message>(
renderer,
theme,
style,
cursor,
viewport,
status_appearance.text_color,
close_button_bounds,
self.close_icon.clone(),
);
}
if show_drop_hint_marker {
if matches!(
drop_hint_marker,
Some(DropHint {
entity,
side: DropSide::After
}) if entity == key
) {
draw_drop_indicator(
renderer,
original_bounds,
DropSide::After,
Self::VERTICAL,
appearance.active.text_color,
);
}
}
nth += 1;
});
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: iced_core::Layout<'b>,
_renderer: &Renderer,
viewport: &iced_core::Rectangle,
translation: Vector,
) -> Option<iced_core::overlay::Element<'b, Message, crate::Theme, Renderer>> {
let state = tree.state.downcast_mut::<LocalState>();
let menu_state = state.menu_state.clone();
let entity = state.show_context?;
let mut bounds =
self.variant_bounds(state, layout.bounds())
.find_map(|item| match item {
ItemBounds::Button(e, bounds) if e == entity => Some(bounds),
_ => None,
})?;
let context_menu = self.context_menu.as_mut()?;
if !menu_state.inner.with_data(|data| data.open) {
// If the menu is not open, we don't need to show it.
// We also clear the context entity and update the text
// cache so that the item is not bold when the context menu is closed
state.show_context = None;
for key in self.model.order.iter().copied() {
self.update_entity_paragraph(state, key);
}
return None;
}
bounds.x = state.context_cursor.x;
bounds.y = state.context_cursor.y;
Some(
crate::widget::menu::Menu {
tree: menu_state,
menu_roots: std::borrow::Cow::Owned(context_menu.clone()),
bounds_expand: 16,
menu_overlays_parent: true,
close_condition: CloseCondition {
leave: false,
click_outside: true,
click_inside: true,
},
item_width: ItemWidth::Uniform(240),
item_height: ItemHeight::Dynamic(40),
bar_bounds: bounds,
main_offset: -bounds.height as i32,
cross_offset: 0,
root_bounds_list: vec![bounds],
path_highlight: Some(PathHighlight::MenuActive),
style: std::borrow::Cow::Borrowed(&crate::theme::menu_bar::MenuBarStyle::Default),
position: Point::new(translation.x, translation.y),
is_overlay: true,
window_id: window::Id::NONE,
depth: 0,
on_surface_action: None,
}
.overlay(),
)
}
fn drag_destinations(
&self,
tree: &Tree,
layout: Layout<'_>,
_renderer: &Renderer,
dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles,
) {
let local_state = tree.state.downcast_ref::<LocalState>();
let my_id = self.get_drag_id();
let mut pushed = false;
for item in self.variant_bounds(local_state, layout.bounds()) {
if let ItemBounds::Button(_entity, rect) = item {
pushed = true;
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}",
my_id,
rect.x,
rect.y,
rect.width,
rect.height,
self.mimes
);
dnd_rectangles.push(DndDestinationRectangle {
id: my_id,
rectangle: dnd::Rectangle {
x: f64::from(rect.x),
y: f64::from(rect.y),
width: f64::from(rect.width),
height: f64::from(rect.height),
},
mime_types: self.mimes.clone().into_iter().map(Cow::Owned).collect(),
actions: DndAction::Copy | DndAction::Move,
preferred: DndAction::Move,
});
}
}
if let Some(mime) = self.tab_drag.as_ref().map(|d| &d.mime) {
for item in self.variant_bounds(local_state, layout.bounds()) {
if let ItemBounds::Button(_entity, rect) = item {
pushed = true;
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}",
my_id,
rect.x,
rect.y,
rect.width,
rect.height,
mime
);
dnd_rectangles.push(DndDestinationRectangle {
id: my_id,
rectangle: dnd::Rectangle {
x: f64::from(rect.x),
y: f64::from(rect.y),
width: f64::from(rect.width),
height: f64::from(rect.height),
},
mime_types: vec![Cow::Owned(mime.clone())],
actions: DndAction::Copy | DndAction::Move,
preferred: DndAction::Move,
});
}
}
}
if !pushed {
let bounds = layout.bounds();
log::trace!(
target: TAB_REORDER_LOG_TARGET,
"register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}",
my_id,
bounds.x,
bounds.y,
bounds.width,
bounds.height,
self.mimes
);
dnd_rectangles.push(DndDestinationRectangle {
id: my_id,
rectangle: dnd::Rectangle {
x: f64::from(bounds.x),
y: f64::from(bounds.y),
width: f64::from(bounds.width),
height: f64::from(bounds.height),
},
mime_types: self.mimes.clone().into_iter().map(Cow::Owned).collect(),
actions: DndAction::Copy | DndAction::Move,
preferred: DndAction::Move,
});
}
}
}
impl<'a, Variant, SelectionMode, Message> From<SegmentedButton<'a, Variant, SelectionMode, Message>>
for Element<'a, Message>
where
SegmentedButton<'a, Variant, SelectionMode, Message>: SegmentedVariant,
Variant: 'static,
Model<SelectionMode>: 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)
}
}
struct TabDragSource<Message> {
mime: String,
threshold: f32,
_marker: PhantomData<Message>,
}
impl<Message> TabDragSource<Message> {
fn new(mime: String) -> Self {
Self {
mime,
threshold: 8.0,
_marker: PhantomData,
}
}
}
struct SimpleDragData {
mime: String,
bytes: Vec<u8>,
}
impl SimpleDragData {
fn new(mime: String, bytes: Vec<u8>) -> Self {
Self { mime, bytes }
}
}
impl iced::clipboard::mime::AsMimeTypes for SimpleDragData {
fn available(&self) -> Cow<'static, [String]> {
Cow::Owned(vec![self.mime.clone()])
}
fn as_bytes(&self, mime_type: &str) -> Option<Cow<'static, [u8]>> {
if mime_type == self.mime {
Some(Cow::Owned(self.bytes.clone()))
} else {
None
}
}
}
#[derive(Clone, Copy)]
struct TabDragCandidate {
entity: Entity,
bounds: Rectangle,
origin: Point,
}
#[derive(Debug, Clone, Copy)]
struct Focus {
updated_at: Instant,
now: Instant,
}
/// State that is maintained by each individual widget.
pub struct LocalState {
/// Menu state
pub(crate) menu_state: MenuBarState,
/// Defines how many buttons to show at a time.
pub(super) buttons_visible: usize,
/// Button visibility offset, when collapsed.
pub(super) buttons_offset: usize,
/// Whether buttons need to be collapsed to preserve minimum width
pub(super) collapsed: bool,
/// Visibility of focus state
focused_visible: bool,
/// If the widget is focused or not.
focused: Option<Focus>,
/// The key inside the widget that is currently focused.
focused_item: Item,
/// The ID of the button that is being hovered. Defaults to null.
hovered: Item,
/// The ID of the button that was middle-clicked, but not yet released.
middle_clicked: Option<Item>,
/// Last known length of the model.
pub(super) known_length: usize,
/// Dimensions of internal buttons when shrinking
pub(super) internal_layout: Vec<(Size, Size)>,
/// The paragraphs for each text.
paragraphs: SecondaryMap<Entity, crate::Plain>,
/// Used to detect changes in text.
text_hashes: SecondaryMap<Entity, u64>,
/// Location of cursor when context menu was opened.
context_cursor: Point,
/// Track whether an item is currently showing a context menu.
show_context: Option<Entity>,
/// Time since last tab activation from wheel movements.
wheel_timestamp: Option<Instant>,
/// Dnd state
pub dnd_state: crate::widget::dnd_destination::State<Option<Entity>>,
/// Dnd state
pub offer_mimes: Vec<String>,
/// Tracks multi-touch events
fingers_pressed: HashSet<Finger>,
/// The currently pressed item
pressed_item: Option<Item>,
/// Pending tab drag candidate data
tab_drag_candidate: Option<TabDragCandidate>,
/// Currently dragging tab entity
dragging_tab: Option<Entity>,
/// Current drop hint for drag-and-drop indicator
drop_hint: Option<DropHint>,
}
#[derive(Debug, Default, PartialEq)]
enum Item {
NextButton,
#[default]
None,
PrevButton,
Set,
Tab(Entity),
}
impl LocalState {
fn set_focused(&mut self) {
let now = Instant::now();
LAST_FOCUS_UPDATE.with(|x| x.set(now));
self.focused = Some(Focus {
updated_at: now,
now,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::segmented_button::{self, Appearance as SegAppearance};
use iced::Size;
use slotmap::SecondaryMap;
use std::collections::HashSet;
#[derive(Clone, Debug)]
enum TestMessage {}
struct TestVariant;
impl<SelectionMode, Message> SegmentedVariant
for SegmentedButton<'_, TestVariant, SelectionMode, Message>
where
Model<SelectionMode>: Selectable,
SelectionMode: Default,
{
const VERTICAL: bool = false;
fn variant_appearance(
_theme: &crate::Theme,
_style: &crate::theme::SegmentedButton,
) -> SegAppearance {
SegAppearance::default()
}
fn variant_bounds<'b>(
&'b self,
_state: &'b LocalState,
bounds: Rectangle,
) -> Box<dyn Iterator<Item = ItemBounds> + 'b> {
let len = self.model.order.len();
if len == 0 {
return Box::new(std::iter::empty());
}
let width = bounds.width / len as f32;
Box::new(
self.model
.order
.iter()
.copied()
.enumerate()
.map(move |(idx, entity)| {
let rect = Rectangle {
x: bounds.x + (idx as f32) * width,
y: bounds.y,
width,
height: bounds.height,
};
ItemBounds::Button(entity, rect)
}),
)
}
fn variant_layout(
&self,
_state: &mut LocalState,
_renderer: &crate::Renderer,
_limits: &layout::Limits,
) -> Size {
Size::ZERO
}
}
fn sample_model() -> (
segmented_button::SingleSelectModel,
Vec<segmented_button::Entity>,
) {
let mut entities = Vec::new();
let model = segmented_button::Model::builder()
.insert(|b| b.text("One").with_id(|id| entities.push(id)))
.insert(|b| b.text("Two").with_id(|id| entities.push(id)))
.insert(|b| b.text("Three").with_id(|id| entities.push(id)))
.build();
(model, entities)
}
fn test_state(dragging: segmented_button::Entity, len: usize) -> LocalState {
let mut state = LocalState {
menu_state: MenuBarState::default(),
paragraphs: SecondaryMap::new(),
text_hashes: SecondaryMap::new(),
buttons_visible: 0,
buttons_offset: 0,
collapsed: false,
focused: None,
focused_item: Item::default(),
focused_visible: false,
hovered: Item::default(),
known_length: 0,
middle_clicked: None,
internal_layout: Vec::new(),
context_cursor: Point::ORIGIN,
show_context: None,
wheel_timestamp: None,
dnd_state: crate::widget::dnd_destination::State::<Option<Entity>>::new(),
fingers_pressed: HashSet::new(),
pressed_item: None,
tab_drag_candidate: None,
dragging_tab: Some(dragging),
drop_hint: None,
offer_mimes: Vec::new(),
};
state.buttons_visible = len;
state.known_length = len;
state
}
#[test]
fn drop_hint_reports_before_and_after() {
let (model, ids) = sample_model();
let button =
SegmentedButton::<TestVariant, segmented_button::SingleSelect, TestMessage>::new(
&model,
);
let state = test_state(ids[0], model.order.len());
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 300.0,
height: 30.0,
};
let before = button
.drop_hint_for_position(&state, bounds, Point::new(10.0, 15.0))
.expect("hint");
assert_eq!(before.entity, ids[0]);
assert!(matches!(before.side, DropSide::Before));
let after = button
.drop_hint_for_position(&state, bounds, Point::new(290.0, 15.0))
.expect("hint");
assert_eq!(after.entity, ids[2]);
assert!(matches!(after.side, DropSide::After));
}
}
impl operation::Focusable for LocalState {
fn is_focused(&self) -> bool {
self.focused
.is_some_and(|f| f.updated_at == LAST_FOCUS_UPDATE.with(|f| f.get()))
}
fn focus(&mut self) {
self.set_focused();
self.focused_visible = true;
self.focused_item = Item::Set;
}
fn unfocus(&mut self) {
self.focused = None;
self.focused_item = Item::None;
self.focused_visible = false;
self.show_context = None;
}
}
/// 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<std::borrow::Cow<'static, str>>) -> Self {
Self(widget::Id::new(id))
}
/// Creates a unique [`Id`].
///
/// This function produces a different [`Id`] every time it is called.
#[must_use]
#[inline]
pub fn unique() -> Self {
Self(widget::Id::unique())
}
}
impl From<Id> 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<f32>, icon_size: f32) -> Rectangle<f32> {
Rectangle {
x: area.x + area.width - icon_size - 8.0,
y: area.center_y() - (icon_size / 2.0),
width: icon_size,
height: icon_size,
}
}
/// Calculate the bounds of the `next_tab` button.
fn next_tab_bounds(bounds: &Rectangle, button_height: f32) -> Rectangle {
Rectangle {
x: bounds.x + bounds.width - button_height,
y: bounds.y,
width: button_height,
height: button_height,
}
}
/// Calculate the bounds of the `prev_tab` button.
fn prev_tab_bounds(bounds: &Rectangle, button_height: f32) -> Rectangle {
Rectangle {
x: bounds.x,
y: bounds.y,
width: button_height,
height: button_height,
}
}
#[allow(clippy::too_many_arguments)]
fn draw_icon<Message: 'static>(
renderer: &mut Renderer,
theme: &crate::Theme,
style: &renderer::Style,
cursor: mouse::Cursor,
viewport: &Rectangle,
color: Color,
bounds: Rectangle,
icon: Icon,
) {
let layout_node = layout::Node::new(Size {
width: bounds.width,
height: bounds.width,
})
.move_to(Point {
x: bounds.x,
y: bounds.y,
});
Widget::<Message, crate::Theme, Renderer>::draw(
Element::<Message>::from(icon).as_widget(),
&Tree::empty(),
renderer,
theme,
&renderer::Style {
icon_color: color,
text_color: color,
scale_factor: style.scale_factor,
},
Layout::new(&layout_node),
cursor,
viewport,
);
}
fn draw_drop_indicator(
renderer: &mut Renderer,
bounds: Rectangle,
side: DropSide,
vertical: bool,
color: Color,
) {
let thickness = 4.0;
let quad_bounds = if vertical {
let y = match side {
DropSide::Before => bounds.y - thickness / 2.0,
DropSide::After => bounds.y + bounds.height - thickness / 2.0,
};
Rectangle {
x: bounds.x,
y,
width: bounds.width,
height: thickness,
}
} else {
let x = match side {
DropSide::Before => bounds.x - thickness / 2.0,
DropSide::After => bounds.x + bounds.width - thickness / 2.0,
};
Rectangle {
x,
y: bounds.y,
width: thickness,
height: bounds.height,
}
};
renderer.fill_quad(
renderer::Quad {
bounds: quad_bounds,
border: Border {
radius: 2.0.into(),
..Default::default()
},
shadow: Shadow::default(),
snap: true,
},
Background::Color(color),
);
}
fn left_button_released(event: &Event) -> bool {
matches!(
event,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left,))
)
}
fn right_button_released(event: &Event) -> bool {
matches!(
event,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right,))
)
}
fn is_pressed(event: &Event) -> bool {
matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| Event::Touch(touch::Event::FingerPressed { .. })
)
}
fn is_lifted(event: &Event) -> bool {
matches!(
event,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left,))
| Event::Touch(touch::Event::FingerLifted { .. })
)
}
fn touch_lifted(event: &Event) -> bool {
matches!(event, Event::Touch(touch::Event::FingerLifted { .. }))
}