From 4fa61eeafd57dcc50325fac5048e4c510ba25717 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Mon, 13 Feb 2023 15:57:30 +0100 Subject: [PATCH] feat(segmented-button): configurable close icons --- examples/cosmic/Cargo.toml | 1 + examples/cosmic/src/window.rs | 10 ++ examples/cosmic/src/window/editor.rs | 78 +++++++++++++ src/widget/icon.rs | 73 ++++++------ src/widget/nav_bar.rs | 2 +- src/widget/segmented_button/model/builder.rs | 7 ++ src/widget/segmented_button/model/entity.rs | 7 ++ src/widget/segmented_button/model/mod.rs | 59 ++++++++-- .../segmented_button/model/selection.rs | 6 +- src/widget/segmented_button/widget.rs | 109 +++++++++++++++--- 10 files changed, 288 insertions(+), 64 deletions(-) create mode 100644 examples/cosmic/src/window/editor.rs diff --git a/examples/cosmic/Cargo.toml b/examples/cosmic/Cargo.toml index 2708930..5d7ec79 100644 --- a/examples/cosmic/Cargo.toml +++ b/examples/cosmic/Cargo.toml @@ -10,3 +10,4 @@ apply = "0.3.0" fraction = "0.13.0" libcosmic = { path = "../..", default-features = false, features = ["debug", "winit_softbuffer"] } once_cell = "1.15" +slotmap = "1.0.6" diff --git a/examples/cosmic/src/window.rs b/examples/cosmic/src/window.rs index 1d5465f..8bba3f1 100644 --- a/examples/cosmic/src/window.rs +++ b/examples/cosmic/src/window.rs @@ -28,6 +28,8 @@ mod demo; use self::desktop::DesktopPage; mod desktop; +mod editor; + use self::input_devices::InputDevicesPage; mod input_devices; @@ -51,6 +53,7 @@ pub trait SubPage { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Page { Demo, + Editor, WiFi, Networking(Option), Bluetooth, @@ -74,6 +77,7 @@ impl Page { use Page::*; match self { Demo => "Demo", + Editor => "Editor", WiFi => "Wi-Fi", Networking(_) => "Networking", Bluetooth => "Bluetooth", @@ -96,6 +100,7 @@ impl Page { use Page::*; match self { Demo => "document-properties-symbolic", + Editor => "text-editor-symbolic", WiFi => "network-wireless-symbolic", Networking(_) => "network-workgroup-symbolic", Bluetooth => "bluetooth-active-symbolic", @@ -130,6 +135,7 @@ pub struct Window { bluetooth: bluetooth::State, debug: bool, demo: demo::State, + editor: editor::State, desktop: desktop::State, nav_bar: segmented_button::SingleSelectModel, nav_id_to_page: segmented_button::SecondaryMap, @@ -178,6 +184,7 @@ pub enum Message { Demo(demo::Message), Desktop(desktop::Message), Drag, + Editor(editor::Message), InputChanged, KeyboardNav(keyboard_nav::Message), Maximize, @@ -318,6 +325,7 @@ impl Application for Window { window.warning_message = String::from("You were not supposed to touch that."); window.insert_page(Page::Demo); + window.insert_page(Page::Editor); window.insert_page(Page::WiFi); window.insert_page(Page::Networking(None)); window.insert_page(Page::Bluetooth); @@ -386,6 +394,7 @@ impl Application for Window { Some(demo::Output::ToggleWarning) => self.toggle_warning(), None => (), }, + Message::Editor(message) => self.editor.update(message), Message::Desktop(message) => match self.desktop.update(message) { Some(desktop::Output::Page(page)) => self.page(page), None => (), @@ -460,6 +469,7 @@ impl Application for Window { if !(self.is_condensed() && nav_bar_toggled) { let content: Element<_> = match self.page { Page::Demo => self.demo.view(self).map(Message::Demo), + Page::Editor => self.editor.view(self).map(Message::Editor), Page::Networking(None) => settings::view_column(vec![ self.page_title(self.page), column!( diff --git a/examples/cosmic/src/window/editor.rs b/examples/cosmic/src/window/editor.rs new file mode 100644 index 0000000..c9be4cf --- /dev/null +++ b/examples/cosmic/src/window/editor.rs @@ -0,0 +1,78 @@ +use cosmic::iced::widget::row; +use cosmic::iced::Length; +use cosmic::iced_winit::Alignment; +use cosmic::widget::{button, segmented_button, view_switcher}; +use cosmic::{theme, Element}; +use slotmap::Key; + +#[derive(Clone, Copy, Debug)] +pub enum Message { + Activate(segmented_button::Entity), + AddNew, + Close(segmented_button::Entity), +} + +pub struct State { + pub pages: segmented_button::SingleSelectModel, +} + +impl Default for State { + fn default() -> Self { + let mut state = Self { + pages: segmented_button::Model::default(), + }; + + let id = state.tab_add_new(); + state.pages.activate(id); + + state + } +} + +impl State { + pub(super) fn update(&mut self, message: Message) { + match message { + Message::Activate(id) => self.pages.activate(id), + Message::AddNew => { + self.tab_add_new(); + } + Message::Close(id) => self.tab_close(id), + } + } + + pub fn tab_add_new(&mut self) -> segmented_button::Entity { + let id = self.pages.insert().closable().id(); + + self.pages + .text_set(id, format!("Tab {}", id.data().as_ffi() & 0xffff_ffff)); + + id + } + + pub fn tab_close(&mut self, id: segmented_button::Entity) { + if self.pages.is_active(id) { + if let Some(pos) = self.pages.position(id) { + let next = if pos == 0 { pos + 1 } else { pos - 1 }; + self.pages.activate_position(next); + } + } + + self.pages.remove(id); + } + + pub(super) fn view<'a>(&'a self, window: &'a super::Window) -> Element<'a, Message> { + let tabs = view_switcher::horizontal(&self.pages) + .show_close_icon_on_hover(true) + .on_activate(Message::Activate) + .on_close(Message::Close) + .width(Length::Fill); + + let new_tab_button = button(theme::Button::Text) + .icon(theme::Svg::Symbolic, "tab-new-symbolic", 20) + .on_press(Message::AddNew); + + row!(tabs, new_tab_button) + .align_items(Alignment::Center) + .into() + } +} diff --git a/src/widget/icon.rs b/src/widget/icon.rs index 814acd9..e4f95cf 100644 --- a/src/widget/icon.rs +++ b/src/widget/icon.rs @@ -14,17 +14,17 @@ use std::{ path::Path, path::PathBuf, }; +#[derive(Clone, Debug, Hash)] pub enum Handle { Image(image::Handle), Svg(svg::Handle), } -#[derive(Debug, Hash)] +#[derive(Clone, Debug, Hash)] pub enum IconSource<'a> { Path(Cow<'a, Path>), Name(Cow<'a, str>), - Embedded(image::Handle), - EmbeddedSvg(svg::Handle), + Handle(Handle), } impl<'a> IconSource<'a> { @@ -33,6 +33,7 @@ impl<'a> IconSource<'a> { pub fn load(&self, size: u16, theme: Option<&str>, svg: bool) -> Handle { let name_path_buffer: Option; let icon: Option<&Path> = match self { + IconSource::Handle(handle) => return handle.clone(), IconSource::Path(ref path) => Some(path), IconSource::Name(ref name) => { let icon = crate::settings::DEFAULT_ICON_THEME.with(|default_theme| { @@ -55,8 +56,6 @@ impl<'a> IconSource<'a> { name_path_buffer.as_deref() } - IconSource::Embedded(handle) => return Handle::Image(handle.clone()), - IconSource::EmbeddedSvg(handle) => return Handle::Svg(handle.clone()), }; let is_svg = svg @@ -83,12 +82,12 @@ impl<'a> IconSource<'a> { /// Get a handle to a raster image from a path. pub fn raster_from_path(path: impl Into) -> Self { - IconSource::Embedded(image::Handle::from_path(path)) + IconSource::Handle(Handle::Image(image::Handle::from_path(path))) } /// Get a handle to a raster image from memory. pub fn raster_from_memory(bytes: impl Into>) -> Self { - IconSource::Embedded(image::Handle::from_memory(bytes)) + IconSource::Handle(Handle::Image(image::Handle::from_memory(bytes))) } /// Get a handle to a raster image from RGBA data, where you must define the width and height. @@ -97,17 +96,19 @@ impl<'a> IconSource<'a> { height: u32, pixels: impl Into>, ) -> Self { - IconSource::Embedded(image::Handle::from_pixels(width, height, pixels)) + IconSource::Handle(Handle::Image(image::Handle::from_pixels( + width, height, pixels, + ))) } /// Get a handle to a SVG from a path. pub fn svg_from_path(path: impl Into) -> Self { - IconSource::EmbeddedSvg(svg::Handle::from_path(path)) + IconSource::Handle(Handle::Svg(svg::Handle::from_path(path))) } /// Get a handle to a SVG from memory. pub fn svg_from_memory(bytes: impl Into>) -> Self { - IconSource::EmbeddedSvg(svg::Handle::from_memory(bytes)) + IconSource::Handle(Handle::Svg(svg::Handle::from_memory(bytes))) } } @@ -149,13 +150,13 @@ impl<'a> From<&'a str> for IconSource<'a> { impl From for IconSource<'static> { fn from(handle: image::Handle) -> Self { - Self::Embedded(handle) + Self::Handle(Handle::Image(handle)) } } impl From for IconSource<'static> { fn from(handle: svg::Handle) -> Self { - Self::EmbeddedSvg(handle) + Self::Handle(Handle::Svg(handle)) } } @@ -192,16 +193,25 @@ pub fn icon<'a>(source: impl Into>, size: u16) -> Icon<'a> { } impl<'a> Icon<'a> { - #[must_use] - fn into_element(self) -> Element<'a, Message> { - if let IconSource::Embedded(image) = self.source { - return iced::widget::image(image) - .width(self.width.unwrap_or(Length::Units(self.size))) - .height(self.height.unwrap_or(Length::Units(self.size))) - .content_fit(self.content_fit) - .into(); - } + fn raster_element(&self, handle: image::Handle) -> Element<'static, Message> { + Image::new(handle) + .width(self.width.unwrap_or(Length::Units(self.size))) + .height(self.height.unwrap_or(Length::Units(self.size))) + .content_fit(self.content_fit) + .into() + } + fn svg_element(&self, handle: svg::Handle) -> Element<'static, Message> { + svg::Svg::::new(handle) + .style(self.style) + .width(self.width.unwrap_or(Length::Units(self.size))) + .height(self.height.unwrap_or(Length::Units(self.size))) + .content_fit(self.content_fit) + .into() + } + + #[must_use] + fn into_element(mut self) -> Element<'a, Message> { let mut hasher = DefaultHasher::new(); self.hash(&mut hasher); @@ -211,22 +221,13 @@ impl<'a> Icon<'a> { let hash = hasher.finish(); + let mut source = IconSource::Name(Cow::Borrowed("")); + std::mem::swap(&mut source, &mut self.source); + iced_lazy::lazy(hash, move || -> Element { - match self - .source - .load(self.size, self.theme.as_deref(), self.force_svg) - { - Handle::Svg(handle) => svg::Svg::::new(handle) - .style(self.style) - .width(self.width.unwrap_or(Length::Units(self.size))) - .height(self.height.unwrap_or(Length::Units(self.size))) - .content_fit(self.content_fit) - .into(), - Handle::Image(handle) => Image::new(handle) - .width(self.width.unwrap_or(Length::Units(self.size))) - .height(self.height.unwrap_or(Length::Units(self.size))) - .content_fit(self.content_fit) - .into(), + match source.load(self.size, self.theme.as_deref(), self.force_svg) { + Handle::Svg(handle) => self.svg_element(handle), + Handle::Image(handle) => self.raster_element(handle), } }) .into() diff --git a/src/widget/nav_bar.rs b/src/widget/nav_bar.rs index 6b361ed..7986cf9 100644 --- a/src/widget/nav_bar.rs +++ b/src/widget/nav_bar.rs @@ -19,7 +19,7 @@ use crate::{theme, widget::segmented_button, Theme}; /// For details on the model, see the [`segmented_button`] module for more details. pub fn nav_bar( model: &segmented_button::SingleSelectModel, - on_activate: impl Fn(segmented_button::Entity) -> Message + 'static, + on_activate: fn(segmented_button::Entity) -> Message, ) -> iced::widget::Container where Message: Clone + 'static, diff --git a/src/widget/segmented_button/model/builder.rs b/src/widget/segmented_button/model/builder.rs index 3705bf9..2ffa16e 100644 --- a/src/widget/segmented_button/model/builder.rs +++ b/src/widget/segmented_button/model/builder.rs @@ -48,6 +48,13 @@ where self } + /// Defines that the close button should appear + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn closable(mut self) -> Self { + self.model.0.closable_set(self.id, true); + self + } + /// Associates extra data with an external secondary map. /// /// The secondary map internally uses a `Vec`, so should only be used for data that diff --git a/src/widget/segmented_button/model/entity.rs b/src/widget/segmented_button/model/entity.rs index 02cad02..cf1eeac 100644 --- a/src/widget/segmented_button/model/entity.rs +++ b/src/widget/segmented_button/model/entity.rs @@ -63,6 +63,13 @@ where self } + /// Shows a close button for this item. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn closable(self) -> Self { + self.model.closable_set(self.id, true); + self + } + /// Associates data with the item. /// /// There may only be one data component per Rust type. diff --git a/src/widget/segmented_button/model/mod.rs b/src/widget/segmented_button/model/mod.rs index 434c95a..db1e844 100644 --- a/src/widget/segmented_button/model/mod.rs +++ b/src/widget/segmented_button/model/mod.rs @@ -24,11 +24,15 @@ slotmap::new_key_type! { #[derive(Clone, Debug)] pub struct Settings { pub enabled: bool, + pub closable: bool, } impl Default for Settings { fn default() -> Self { - Self { enabled: true } + Self { + enabled: true, + closable: false, + } } } @@ -83,6 +87,16 @@ where Selectable::activate(self, id); } + /// Activates the item at the given position, returning true if it was activated. + pub fn activate_position(&mut self, position: u16) -> bool { + if let Some(entity) = self.entity_at(position) { + self.activate(entity); + return true; + } + + false + } + /// Creates a builder for initializing a model. /// /// ```ignore @@ -112,6 +126,13 @@ where } } + /// Shows or hides the item's close button. + pub fn closable_set(&mut self, id: Entity, closable: bool) { + if let Some(settings) = self.items.get_mut(id) { + settings.closable = closable; + } + } + /// Check if an item exists in the map. /// /// ```ignore @@ -187,6 +208,12 @@ where } } + /// Get the item that is located at a given position. + #[must_use] + pub fn entity_at(&mut self, position: u16) -> Option { + self.order.get(position as usize).copied() + } + /// Immutable reference to the icon associated with the item. /// /// ```ignore @@ -239,10 +266,21 @@ where EntityMut { model: self, id } } + /// Check if the given ID is the active ID. + #[must_use] + pub fn is_active(&self, id: Entity) -> bool { + ::is_active(self, id) + } + + /// Whether the item should contain a close button. + #[must_use] + pub fn is_closable(&self, id: Entity) -> bool { + self.items.get(id).map_or(false, |e| e.closable) + } + /// Check if the item is enabled. /// /// ```ignore - /// /// if model.is_enabled(id) { /// if let Some(text) = model.text(id) { /// println!("{text} is enabled"); @@ -254,14 +292,21 @@ where self.items.get(id).map_or(false, |e| e.enabled) } + /// Iterates across items in the model in the order that they are displayed. + pub fn iter(&self) -> impl Iterator + '_ { + self.order.iter().copied() + } + /// The position of the item in the model. /// /// ```ignore /// if let Some(position) = model.position(id) { /// println!("found item at {}", position); /// } - pub fn position(&self, id: Entity) -> Option { - self.order.iter().position(|k| *k == id) + #[must_use] + pub fn position(&self, id: Entity) -> Option { + #[allow(clippy::cast_possible_truncation)] + self.order.iter().position(|k| *k == id).map(|v| v as u16) } /// Change the position of an item in the model. @@ -278,7 +323,7 @@ where let position = self.order.len().min(position as usize); - self.order.remove(index); + self.order.remove(index as usize); self.order.insert(position, id); Some(position) } @@ -301,7 +346,7 @@ where return false }; - self.order.swap(first_index, second_index); + self.order.swap(first_index as usize, second_index as usize); true } @@ -319,7 +364,7 @@ where } if let Some(index) = self.position(id) { - self.order.remove(index); + self.order.remove(index as usize); } } diff --git a/src/widget/segmented_button/model/selection.rs b/src/widget/segmented_button/model/selection.rs index 59a39cd..1366c18 100644 --- a/src/widget/segmented_button/model/selection.rs +++ b/src/widget/segmented_button/model/selection.rs @@ -33,8 +33,10 @@ impl Selectable for Model { self.selection.active = id; } - fn deactivate(&mut self, _id: Entity) { - self.selection.active = Entity::default(); + fn deactivate(&mut self, id: Entity) { + if id == self.selection.active { + self.selection.active = Entity::default(); + } } fn is_active(&self, id: Entity) -> bool { diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index c7f9f72..f54efc2 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -1,11 +1,9 @@ // Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 -use std::marker::PhantomData; - use super::model::{Entity, Model, Selectable}; use super::style::StyleSheet; - +use crate::widget::{icon, IconSource}; use derive_setters::Setters; use iced::{ alignment, event, keyboard, mouse, touch, Background, Color, Command, Element, Event, Length, @@ -14,6 +12,7 @@ use iced::{ use iced_core::BorderRadius; use iced_native::widget::{self, operation, tree, Operation}; use iced_native::{layout, renderer, widget::Tree, Clipboard, Layout, Shell, Widget}; +use std::marker::PhantomData; /// State that is maintained by each individual widget. #[derive(Default)] @@ -80,6 +79,10 @@ where pub(super) model: &'a Model, /// iced widget ID pub(super) id: Option, + /// The icon used for the close button. + pub(super) close_icon: IconSource<'a>, + /// 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. @@ -105,9 +108,11 @@ where /// Style to draw the widget in. #[setters(into)] pub(super) style: ::Style, - #[setters(skip)] - /// Emits the ID of the activated widget on selection. - pub(super) on_activate: Option Message>>, + /// Emits the ID of the item that was activated. + #[setters(strip_option)] + pub(super) on_activate: Option Message>, + #[setters(strip_option)] + pub(super) on_close: Option Message>, #[setters(skip)] /// Defines the implementation of this struct variant: PhantomData, @@ -130,6 +135,8 @@ where Self { model, id: None, + close_icon: IconSource::from("window-close-symbolic"), + show_close_icon_on_hover: false, button_padding: [4, 4, 4, 4], button_height: 32, button_spacing: 4, @@ -143,6 +150,7 @@ where spacing: 0, style: ::Style::default(), on_activate: None, + on_close: None, variant: PhantomData, } } @@ -200,13 +208,6 @@ where event::Status::Ignored } - /// Emits the ID of the activated widget on selection. - #[must_use] - pub fn on_activate(mut self, on_activate: impl Fn(Entity) -> Message + 'static) -> Self { - self.on_activate = Some(Box::from(on_activate)); - self - } - pub(super) fn max_button_dimensions(&self, renderer: &Renderer, bounds: Size) -> (f32, f32) { let mut width = 0.0f32; let mut height = 0.0f32; @@ -225,8 +226,14 @@ where // Add icon to measurement if icon was given. if self.model.icon(key).is_some() { + button_height = button_height.max(f32::from(self.icon_size)); + button_width += f32::from(self.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.icon_size)); button_width += f32::from(self.icon_size) + f32::from(self.button_spacing); - button_height = f32::from(self.icon_size); } height = height.max(button_height); @@ -299,6 +306,28 @@ where // 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 close_bounds( + bounds, + f32::from(self.icon_size), + self.button_padding, + ) + .contains(cursor_position) + { + 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 @@ -416,11 +445,14 @@ where 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 self.model.is_active(key) { + } else if key_is_active { (appearance.active, &self.font_active) - } else if state.hovered == key { + } else if key_is_hovered { (appearance.hover, &self.font_hovered) } else { (appearance.inactive, &self.font_inactive) @@ -466,6 +498,8 @@ where ); } + let original_bounds = bounds; + let y = bounds.center_y(); // Draw the image beside the text. @@ -489,11 +523,12 @@ where bounds.x += offset; bounds.width -= offset; + match icon.load(self.icon_size, None, false) { - crate::widget::icon::Handle::Image(_handle) => { + icon::Handle::Image(_handle) => { unimplemented!() } - crate::widget::icon::Handle::Svg(handle) => { + icon::Handle::Svg(handle) => { iced_native::svg::Renderer::draw( renderer, handle, @@ -523,6 +558,30 @@ where vertical_alignment: alignment::Vertical::Center, }); } + + 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.icon_size); + let icon_bounds = close_bounds(original_bounds, width, self.button_padding); + + match self.close_icon.load(self.icon_size, None, false) { + icon::Handle::Image(_handle) => { + unimplemented!() + } + icon::Handle::Svg(handle) => { + iced_native::svg::Renderer::draw( + renderer, + handle, + Some(status_appearance.text_color), + icon_bounds, + ); + } + } + } } } @@ -592,3 +651,17 @@ impl From for widget::Id { 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 top = f32::from(button_padding[1]); + let end = f32::from(button_padding[2]); + let unpadded_height = area.height - top - end; + + Rectangle { + x: area.x + area.width - icon_size - f32::from(button_padding[2]), + y: area.y + f32::from(button_padding[1]) + (unpadded_height / 2.0), + width: icon_size, + height: icon_size, + } +}