diff --git a/Cargo.toml b/Cargo.toml index 86f3356..380dda3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ lazy_static = "1.4.0" palette = "0.6.1" cosmic-panel-config = {git = "https://github.com/pop-os/cosmic-panel", optional = true, branch = "master_jammy" } sctk = { package = "smithay-client-toolkit", git = "https://github.com/Smithay/client-toolkit", optional = true, rev = "73346019952f82ec7e4d4d15f5d66841b54e8b61" } +slotmap = "1.0.6" [dependencies.cosmic-theme] git = "https://github.com/pop-os/cosmic-theme.git" diff --git a/examples/cosmic/src/window.rs b/examples/cosmic/src/window.rs index e4b8581..fd5f934 100644 --- a/examples/cosmic/src/window.rs +++ b/examples/cosmic/src/window.rs @@ -1,24 +1,28 @@ /// Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 - use cosmic::{ - iced_native, - iced_native::window, iced::widget::{column, container, horizontal_space, row, text}, iced::{self, Application, Command, Length, Subscription}, - iced_winit::window::{close, drag, toggle_maximize, minimize}, + iced_native, + iced_native::window, + iced_winit::window::{close, drag, minimize, toggle_maximize}, theme::{self, Theme}, - widget::{icon, list, nav_bar, nav_button, header_bar, settings, scrollable, spin_button::{SpinButtonModel, SpinMessage}}, - Element, - ElementExt, + widget::{ + header_bar, icon, list, nav_bar, nav_button, scrollable, segmented_button, settings, + spin_button::{SpinButtonModel, SpinMessage}, + }, + Element, ElementExt, +}; +use std::{ + sync::atomic::{AtomicU32, Ordering}, + vec, }; -use std::{vec, sync::atomic::{AtomicU32, Ordering}}; mod bluetooth; mod demo; -use self::desktop::DesktopPage; +use self::{demo::DemoView, desktop::DesktopPage}; mod desktop; use self::input_devices::InputDevicesPage; @@ -125,6 +129,7 @@ pub struct Window { debug: bool, theme: Theme, slider_value: f32, + demo_tab_state: segmented_button::State, spin_button: SpinButtonModel, checkbox_value: bool, toggler_value: bool, @@ -155,36 +160,34 @@ impl Window { #[allow(dead_code)] #[derive(Clone, Copy, Debug)] pub enum Message { - Page(Page), - Debug(bool), - ThemeChanged(Theme), ButtonPressed, - SliderChanged(f32), CheckboxToggled(bool), - TogglerToggled(bool), + Close, + CondensedViewToggle(()), + Debug(bool), + DemoTabActivate(segmented_button::Key), + Drag, + InputChanged, + Maximize, + Minimize, + Page(Page), PickListSelected(&'static str), RowSelected(usize), - Close, + SliderChanged(f32), + SpinButton(SpinMessage), + ThemeChanged(Theme), + TogglerToggled(bool), ToggleSidebar, ToggleSidebarCondensed, - Drag, - Minimize, - Maximize, - InputChanged, - SpinButton(SpinMessage), - CondensedViewToggle(()), } impl Window { fn page_title(&self, page: Page) -> Element { - row!( - text(page.title()).size(30), - horizontal_space(Length::Fill), - ).into() + row!(text(page.title()).size(30), horizontal_space(Length::Fill),).into() } fn is_condensed(&self) -> bool { - WINDOW_WIDTH.load(Ordering::Relaxed) < BREAK_POINT + WINDOW_WIDTH.load(Ordering::Relaxed) < BREAK_POINT } fn parent_page_button(&self, sub_page: impl SubPage) -> Element { @@ -197,7 +200,6 @@ impl Window { .padding(0) .style(theme::Button::Link) .on_press(Message::Page(page)), - row!( text(sub_page.title()).size(30), horizontal_space(Length::Fill), @@ -209,17 +211,26 @@ impl Window { fn sub_page_button(&self, sub_page: impl SubPage) -> Element { iced::widget::Button::new( - container(settings::item_row(vec![ - icon(sub_page.icon_name(), 20).style(theme::Svg::Symbolic).into(), - column!( - text(sub_page.title()).size(18), - text(sub_page.description()).size(12), - ).spacing(2).into(), - horizontal_space(iced::Length::Fill).into(), - icon("go-next-symbolic", 20).style(theme::Svg::Symbolic).into(), - ]).spacing(16)) + container( + settings::item_row(vec![ + icon(sub_page.icon_name(), 20) + .style(theme::Svg::Symbolic) + .into(), + column!( + text(sub_page.title()).size(18), + text(sub_page.description()).size(12), + ) + .spacing(2) + .into(), + horizontal_space(iced::Length::Fill).into(), + icon("go-next-symbolic", 20) + .style(theme::Svg::Symbolic) + .into(), + ]) + .spacing(16), + ) .padding([20, 24]) - .style(theme::Container::Custom(list::column::style)) + .style(theme::Container::Custom(list::column::style)), ) .padding(0) .style(theme::Button::Transparent) @@ -259,6 +270,21 @@ impl Application for Window { window.title = String::from("COSMIC Design System - Iced"); window.spin_button.min = -10; window.spin_button.max = 10; + + let key = window + .demo_tab_state + .insert(String::from("Tab A"), DemoView::TabA); + + window.demo_tab_state.activate(key); + + window + .demo_tab_state + .insert(String::from("Tab B"), DemoView::TabB); + + window + .demo_tab_state + .insert(String::from("Tab C"), DemoView::TabC); + (window, Command::none()) } @@ -267,20 +293,25 @@ impl Application for Window { } fn subscription(&self) -> Subscription { - iced_native::subscription::events_with(|event, _| match event { - cosmic::iced::Event::Window(_window_id, window::Event::Resized{width, height: _}) => { - let old_width = WINDOW_WIDTH.load(Ordering::Relaxed); - if old_width == 0 - || old_width < BREAK_POINT && width > BREAK_POINT - || old_width > BREAK_POINT && width < BREAK_POINT { - WINDOW_WIDTH.store(width, Ordering::Relaxed); - Some(()) - } else { - None - } - } - _ => None - }).map(Message::CondensedViewToggle) + iced_native::subscription::events_with(|event, _| match event { + cosmic::iced::Event::Window( + _window_id, + window::Event::Resized { width, height: _ }, + ) => { + let old_width = WINDOW_WIDTH.load(Ordering::Relaxed); + if old_width == 0 + || old_width < BREAK_POINT && width > BREAK_POINT + || old_width > BREAK_POINT && width < BREAK_POINT + { + WINDOW_WIDTH.store(width, Ordering::Relaxed); + Some(()) + } else { + None + } + } + _ => None, + }) + .map(Message::CondensedViewToggle) } fn update(&mut self, message: Message) -> iced::Command { @@ -288,165 +319,199 @@ impl Application for Window { Message::Page(page) => { self.sidebar_toggled_condensed = false; self.page = page; - }, + } Message::Debug(debug) => self.debug = debug, Message::ThemeChanged(theme) => self.theme = theme, Message::ButtonPressed => {} Message::SliderChanged(value) => self.slider_value = value, Message::CheckboxToggled(value) => { self.checkbox_value = value; - }, + } Message::TogglerToggled(value) => self.toggler_value = value, Message::PickListSelected(value) => self.pick_list_selected = Some(value), Message::ToggleSidebar => self.sidebar_toggled = !self.sidebar_toggled, - Message::ToggleSidebarCondensed => self.sidebar_toggled_condensed = !self.sidebar_toggled_condensed, + Message::ToggleSidebarCondensed => { + self.sidebar_toggled_condensed = !self.sidebar_toggled_condensed + } Message::Drag => return drag(window::Id::new(0)), Message::Close => return close(window::Id::new(0)), Message::Minimize => return minimize(window::Id::new(0), true), Message::Maximize => return toggle_maximize(window::Id::new(0)), Message::RowSelected(row) => println!("Selected row {row}"), - Message::InputChanged => {}, + Message::InputChanged => {} Message::SpinButton(msg) => self.spin_button.update(msg), - Message::CondensedViewToggle(_) => {}, + Message::CondensedViewToggle(_) => {} + Message::DemoTabActivate(key) => self.demo_tab_state.activate(key), } Command::none() } fn view(&self) -> Element { - let (sidebar_message, sidebar_toggled) = if self.is_condensed() { - (Message::ToggleSidebarCondensed, self.sidebar_toggled_condensed) - } else { - (Message::ToggleSidebar, self.sidebar_toggled) - }; + let (sidebar_message, sidebar_toggled) = if self.is_condensed() { + ( + Message::ToggleSidebarCondensed, + self.sidebar_toggled_condensed, + ) + } else { + (Message::ToggleSidebar, self.sidebar_toggled) + }; - let mut header = header_bar() - .title("COSMIC Design System - Iced") - .on_close(Message::Close) - .on_drag(Message::Drag) - .start( - nav_button("Settings") - .on_sidebar_toggled(sidebar_message) - .sidebar_active(sidebar_toggled) - .into() - ); + let mut header = header_bar() + .title("COSMIC Design System - Iced") + .on_close(Message::Close) + .on_drag(Message::Drag) + .start( + nav_button("Settings") + .on_sidebar_toggled(sidebar_message) + .sidebar_active(sidebar_toggled) + .into(), + ); - if self.show_maximize { - header = header.on_maximize(Message::Maximize); - } + if self.show_maximize { + header = header.on_maximize(Message::Maximize); + } - if self.show_minimize { - header = header.on_minimize(Message::Minimize); - } + if self.show_minimize { + header = header.on_minimize(Message::Minimize); + } - let header = Into::>::into(header).debug(self.debug); + let header = Into::>::into(header).debug(self.debug); - let mut widgets = Vec::with_capacity(2); + let mut widgets = Vec::with_capacity(2); - if sidebar_toggled { - let sidebar_button_complex = |page: Page, active| { - cosmic::nav_button!( - page.icon_name(), - page.title(), - active - ) - .on_press(Message::Page(page)) - }; + if sidebar_toggled { + let sidebar_button_complex = |page: Page, active| { + cosmic::nav_button!(page.icon_name(), page.title(), active) + .on_press(Message::Page(page)) + }; - let sidebar_button = |page: Page| { - sidebar_button_complex(page, self.page == page) - }; + let sidebar_button = |page: Page| sidebar_button_complex(page, self.page == page); - let mut sidebar = container(scrollable(column!( - sidebar_button(Page::Demo), - sidebar_button(Page::WiFi), - sidebar_button_complex(Page::Networking(None), matches!(self.page, Page::Networking(_))), - sidebar_button(Page::Bluetooth), - sidebar_button_complex(Page::Desktop(None), matches!(self.page, Page::Desktop(_))), - sidebar_button_complex(Page::InputDevices(None), matches!(self.page, Page::InputDevices(_))), - sidebar_button(Page::Displays), - sidebar_button(Page::PowerAndBattery), - sidebar_button(Page::Sound), - sidebar_button(Page::PrintersAndScanners), - sidebar_button(Page::PrivacyAndSecurity), - sidebar_button_complex(Page::SystemAndAccounts(None), matches!(self.page, Page::SystemAndAccounts(_))), - sidebar_button(Page::UpdatesAndRecovery), - sidebar_button_complex(Page::TimeAndLanguage(None), matches!(self.page, Page::TimeAndLanguage(_))), - sidebar_button(Page::Accessibility), - sidebar_button(Page::Applications), - ).spacing(14))) - .height(Length::Fill) - .padding(8) - .style(theme::Container::Custom(nav_bar::nav_bar_sections_style)); + let mut sidebar = container(scrollable( + column!( + sidebar_button(Page::Demo), + sidebar_button(Page::WiFi), + sidebar_button_complex( + Page::Networking(None), + matches!(self.page, Page::Networking(_)) + ), + sidebar_button(Page::Bluetooth), + sidebar_button_complex( + Page::Desktop(None), + matches!(self.page, Page::Desktop(_)) + ), + sidebar_button_complex( + Page::InputDevices(None), + matches!(self.page, Page::InputDevices(_)) + ), + sidebar_button(Page::Displays), + sidebar_button(Page::PowerAndBattery), + sidebar_button(Page::Sound), + sidebar_button(Page::PrintersAndScanners), + sidebar_button(Page::PrivacyAndSecurity), + sidebar_button_complex( + Page::SystemAndAccounts(None), + matches!(self.page, Page::SystemAndAccounts(_)) + ), + sidebar_button(Page::UpdatesAndRecovery), + sidebar_button_complex( + Page::TimeAndLanguage(None), + matches!(self.page, Page::TimeAndLanguage(_)) + ), + sidebar_button(Page::Accessibility), + sidebar_button(Page::Applications), + ) + .spacing(14), + )) + .height(Length::Fill) + .padding(8) + .style(theme::Container::Custom(nav_bar::nav_bar_sections_style)); - if ! self.is_condensed() { - sidebar = sidebar.max_width(300) - } + if !self.is_condensed() { + sidebar = sidebar.max_width(300) + } - let sidebar: Element<_> = sidebar.into(); - widgets.push(sidebar.debug(self.debug)); - } + let sidebar: Element<_> = sidebar.into(); + widgets.push(sidebar.debug(self.debug)); + } - if ! (self.is_condensed() && sidebar_toggled) { - let content: Element<_> = match self.page { - Page::Demo => self.view_demo(), - Page::Networking(None) => settings::view_column(vec![ - self.page_title(self.page), - column!( - self.sub_page_button(NetworkingPage::Wired), - self.sub_page_button(NetworkingPage::OnlineAccounts), - ).spacing(16).into() - ]).into(), - Page::Networking(Some(sub_page)) => self.view_unimplemented_sub_page(sub_page), - Page::Bluetooth => self.view_bluetooth(), - Page::Desktop(desktop_page_opt) => self.view_desktop(desktop_page_opt), - Page::InputDevices(None) => settings::view_column(vec![ - self.page_title(self.page), - column!( - self.sub_page_button(InputDevicesPage::Keyboard), - self.sub_page_button(InputDevicesPage::Touchpad), - self.sub_page_button(InputDevicesPage::Mouse), - ).spacing(16).into() - ]).into(), - Page::InputDevices(Some(sub_page)) => self.view_unimplemented_sub_page(sub_page), - Page::SystemAndAccounts(None) => settings::view_column(vec![ - self.page_title(self.page), - column!( - self.sub_page_button(SystemAndAccountsPage::Users), - self.sub_page_button(SystemAndAccountsPage::About), - self.sub_page_button(SystemAndAccountsPage::Firmware), - ).spacing(16).into() - ]).into(), - Page::SystemAndAccounts(Some(SystemAndAccountsPage::About)) => self.view_system_and_accounts_about(), - Page::SystemAndAccounts(Some(sub_page)) => self.view_unimplemented_sub_page(sub_page), - Page::TimeAndLanguage(None) => settings::view_column(vec![ - self.page_title(self.page), - column!( - self.sub_page_button(TimeAndLanguagePage::DateAndTime), - self.sub_page_button(TimeAndLanguagePage::RegionAndLanguage), - ).spacing(16).into() - ]).into(), - Page::TimeAndLanguage(Some(sub_page)) => self.view_unimplemented_sub_page(sub_page), - _ => self.view_unimplemented_page(self.page), - }; + if !(self.is_condensed() && sidebar_toggled) { + let content: Element<_> = match self.page { + Page::Demo => self.view_demo(), + Page::Networking(None) => settings::view_column(vec![ + self.page_title(self.page), + column!( + self.sub_page_button(NetworkingPage::Wired), + self.sub_page_button(NetworkingPage::OnlineAccounts), + ) + .spacing(16) + .into(), + ]) + .into(), + Page::Networking(Some(sub_page)) => self.view_unimplemented_sub_page(sub_page), + Page::Bluetooth => self.view_bluetooth(), + Page::Desktop(desktop_page_opt) => self.view_desktop(desktop_page_opt), + Page::InputDevices(None) => settings::view_column(vec![ + self.page_title(self.page), + column!( + self.sub_page_button(InputDevicesPage::Keyboard), + self.sub_page_button(InputDevicesPage::Touchpad), + self.sub_page_button(InputDevicesPage::Mouse), + ) + .spacing(16) + .into(), + ]) + .into(), + Page::InputDevices(Some(sub_page)) => self.view_unimplemented_sub_page(sub_page), + Page::SystemAndAccounts(None) => settings::view_column(vec![ + self.page_title(self.page), + column!( + self.sub_page_button(SystemAndAccountsPage::Users), + self.sub_page_button(SystemAndAccountsPage::About), + self.sub_page_button(SystemAndAccountsPage::Firmware), + ) + .spacing(16) + .into(), + ]) + .into(), + Page::SystemAndAccounts(Some(SystemAndAccountsPage::About)) => { + self.view_system_and_accounts_about() + } + Page::SystemAndAccounts(Some(sub_page)) => { + self.view_unimplemented_sub_page(sub_page) + } + Page::TimeAndLanguage(None) => settings::view_column(vec![ + self.page_title(self.page), + column!( + self.sub_page_button(TimeAndLanguagePage::DateAndTime), + self.sub_page_button(TimeAndLanguagePage::RegionAndLanguage), + ) + .spacing(16) + .into(), + ]) + .into(), + Page::TimeAndLanguage(Some(sub_page)) => self.view_unimplemented_sub_page(sub_page), + _ => self.view_unimplemented_page(self.page), + }; - widgets.push( - scrollable(row![ - horizontal_space(Length::Fill), - content.debug(self.debug), - horizontal_space(Length::Fill), - ]) - .into(), - ); - } + widgets.push( + scrollable(row![ + horizontal_space(Length::Fill), + content.debug(self.debug), + horizontal_space(Length::Fill), + ]) + .into(), + ); + } - let content = container(row(widgets)) - .padding([0, 8, 8, 8]) - .width(Length::Fill) - .height(Length::Fill) - .into(); + let content = container(row(widgets)) + .padding([0, 8, 8, 8]) + .width(Length::Fill) + .height(Length::Fill) + .into(); - column(vec![header, content]).into() + column(vec![header, content]).into() } fn theme(&self) -> Theme { diff --git a/examples/cosmic/src/window/demo.rs b/examples/cosmic/src/window/demo.rs index 648b76e..87e39b4 100644 --- a/examples/cosmic/src/window/demo.rs +++ b/examples/cosmic/src/window/demo.rs @@ -1,13 +1,19 @@ use cosmic::{ - Element, - iced::{Alignment, Length}, iced::widget::{checkbox, pick_list, progress_bar, radio, row, slider}, - widget::{button, settings, toggler}, + iced::{Alignment, Length}, theme::{Button as ButtonTheme, Theme}, + widget::{button, segmented_button, settings, toggler}, + Element, }; use super::{Message, Page, Window}; +pub enum DemoView { + TabA, + TabB, + TabC, +} + impl Window { pub(super) fn view_demo(&self) -> Element { let choose_theme = [Theme::Light, Theme::Dark].iter().fold( @@ -24,77 +30,107 @@ impl Window { settings::view_column(vec![ self.page_title(Page::Demo), - - settings::view_section("Debug") - .add(settings::item("Debug theme", choose_theme)) - .add(settings::item( - "Debug layout", - toggler(None, self.debug, Message::Debug) - )) + segmented_button(&self.demo_tab_state) + .on_activate(Message::DemoTabActivate) .into(), - - settings::view_section("Buttons") - .add(settings::item_row(vec![ - button(ButtonTheme::Primary) - .text("Primary") - .on_press(Message::ButtonPressed) + match self.demo_tab_state.active_data() { + None => panic!("no tab is active"), + Some(DemoView::TabA) => settings::view_column(vec![ + settings::view_section("Debug") + .add(settings::item("Debug theme", choose_theme)) + .add(settings::item( + "Debug layout", + toggler(None, self.debug, Message::Debug), + )) .into(), - button(ButtonTheme::Secondary) - .text("Secondary") - .on_press(Message::ButtonPressed) + settings::view_section("Buttons") + .add(settings::item_row(vec![ + button(ButtonTheme::Primary) + .text("Primary") + .on_press(Message::ButtonPressed) + .into(), + button(ButtonTheme::Secondary) + .text("Secondary") + .on_press(Message::ButtonPressed) + .into(), + button(ButtonTheme::Positive) + .text("Positive") + .on_press(Message::ButtonPressed) + .into(), + button(ButtonTheme::Destructive) + .text("Destructive") + .on_press(Message::ButtonPressed) + .into(), + button(ButtonTheme::Text) + .text("Text") + .on_press(Message::ButtonPressed) + .into(), + ])) + .add(settings::item_row(vec![ + button(ButtonTheme::Primary).text("Primary").into(), + button(ButtonTheme::Secondary).text("Secondary").into(), + button(ButtonTheme::Positive).text("Positive").into(), + button(ButtonTheme::Destructive).text("Destructive").into(), + button(ButtonTheme::Text).text("Text").into(), + ])) .into(), - button(ButtonTheme::Positive) - .text("Positive") - .on_press(Message::ButtonPressed) + settings::view_section("Controls") + .add(settings::item( + "Toggler", + toggler(None, self.toggler_value, Message::TogglerToggled), + )) + .add(settings::item( + "Pick List (TODO)", + pick_list( + vec!["Option 1", "Option 2", "Option 3", "Option 4"], + self.pick_list_selected, + Message::PickListSelected, + ) + .padding([8, 0, 8, 16]), + )) + .add(settings::item( + "Slider", + slider(0.0..=100.0, self.slider_value, Message::SliderChanged) + .width(Length::Units(250)), + )) + .add(settings::item( + "Progress", + progress_bar(0.0..=100.0, self.slider_value) + .width(Length::Units(250)) + .height(Length::Units(4)), + )) + .add(settings::item_row(vec![checkbox( + "Checkbox", + self.checkbox_value, + Message::CheckboxToggled, + ) + .into()])) + .add(settings::item( + format!( + "Spin Button (Range {}:{})", + self.spin_button.min, self.spin_button.max + ), + self.spin_button.view(Message::SpinButton), + )) .into(), - button(ButtonTheme::Destructive) - .text("Destructive") - .on_press(Message::ButtonPressed) - .into(), - button(ButtonTheme::Text) - .text("Text") - .on_press(Message::ButtonPressed) - .into() - ])) - .add(settings::item_row(vec![ - button(ButtonTheme::Primary).text("Primary").into(), - button(ButtonTheme::Secondary).text("Secondary").into(), - button(ButtonTheme::Positive).text("Positive").into(), - button(ButtonTheme::Destructive).text("Destructive").into(), - button(ButtonTheme::Text).text("Text").into(), - ])) + ]) + .padding(0) .into(), - - settings::view_section("Controls") - .add(settings::item("Toggler", toggler(None, self.toggler_value, Message::TogglerToggled))) - .add(settings::item( - "Pick List (TODO)", - pick_list( - vec!["Option 1", "Option 2", "Option 3", "Option 4",], - self.pick_list_selected, - Message::PickListSelected - ) - .padding([8, 0, 8, 16]) - )) - .add(settings::item( - "Slider", - slider(0.0..=100.0, self.slider_value, Message::SliderChanged) - .width(Length::Units(250)) - )) - .add(settings::item( - "Progress", - progress_bar(0.0..=100.0, self.slider_value) - .width(Length::Units(250)) - .height(Length::Units(4)) - )) - .add(settings::item_row(vec![ - checkbox("Checkbox", self.checkbox_value, Message::CheckboxToggled).into() - ])) - .add(settings::item( - format!("Spin Button (Range {}:{})", self.spin_button.min, self.spin_button.max), - self.spin_button.view(Message::SpinButton), - )) - .into() + Some(DemoView::TabB) => { + settings::view_column(vec![settings::view_section("Tab B") + .add(cosmic::iced::widget::text("Nothing here yet").width(Length::Fill)) + .into()]) + .padding(0) + .into() + } + Some(DemoView::TabC) => { + settings::view_column(vec![settings::view_section("Tab C") + .add(cosmic::iced::widget::text("Nothing here yet").width(Length::Fill)) + .into()]) + .padding(0) + .into() + } + }, ]) .into() } diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 8382373..587674f 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -27,6 +27,7 @@ use iced_style::svg; use iced_style::text; use iced_style::text_input; use iced_style::toggler; +use crate::widget::segmented_button; use iced_core::{Background, Color}; @@ -864,3 +865,37 @@ impl text_input::StyleSheet for Theme { palette.primary.weak.color } } + +#[derive(Clone, Copy, Default)] +pub enum SegmentedButton { + #[default] + Default, + Custom(fn(&Theme) -> segmented_button::Appearance) +} + +impl segmented_button::StyleSheet for Theme { + type Style = SegmentedButton; + + fn appearance(&self, style: &Self::Style) -> segmented_button::Appearance { + if let SegmentedButton::Custom(func) = style { + return func(self); + } + + let cosmic = self.cosmic(); + + segmented_button::Appearance { + button_active: segmented_button::ButtonAppearance { + background: Some(Background::Color(cosmic.primary.component.base.into())), + border_bottom: Some((4.0, cosmic.accent.base.into())), + border_radius: BorderRadius::from([8.0, 8.0, 0.0, 0.0]), + text_color: cosmic.accent.base.into(), + }, + button_inactive: segmented_button::ButtonAppearance { + background: None, + border_bottom: Some((2.0, cosmic.accent.base.into())), + text_color: cosmic.primary.on.into(), + border_radius: BorderRadius::from(0.0), + } + } + } +} \ No newline at end of file diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 3a3e275..54406bf 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -22,6 +22,9 @@ pub use navigation::*; mod toggler; pub use toggler::toggler; +pub mod segmented_button; +pub use segmented_button::{SegmentedButton, segmented_button}; + pub mod settings; mod scrollable; diff --git a/src/widget/segmented_button/mod.rs b/src/widget/segmented_button/mod.rs new file mode 100644 index 0000000..dc7531d --- /dev/null +++ b/src/widget/segmented_button/mod.rs @@ -0,0 +1,245 @@ +/// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +mod state; +mod style; + +pub use self::state::{ButtonContent, Key, SecondaryState, State, WidgetState}; +pub use self::style::{Appearance, ButtonAppearance, StyleSheet}; + +use derive_setters::Setters; +use iced::{ + alignment::{Horizontal, Vertical}, + event, mouse, touch, Background, Color, Element, Event, Length, Point, Rectangle, Size, +}; +use iced_core::BorderRadius; +use iced_native::{layout, renderer, widget::Tree, Clipboard, Layout, Shell, Widget}; + +/// A linear set of options for choosing between. +#[derive(Setters)] +pub struct SegmentedButton<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, + Renderer::Theme: StyleSheet, +{ + state: &'a WidgetState, + width: Length, + height: Length, + spacing: u16, + #[setters(into)] + style: ::Style, + #[setters(skip)] + on_activate: Option Message>>, +} + +impl<'a, Message, Renderer> SegmentedButton<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, + Renderer::Theme: StyleSheet, +{ + #[must_use] + pub fn new(state: &'a WidgetState) -> Self { + Self { + state, + height: Length::Units(48), + width: Length::Fill, + spacing: 0, + style: ::Style::default(), + on_activate: None, + } + } + + #[must_use] + pub fn on_activate(mut self, on_activate: impl Fn(Key) -> Message + 'static) -> Self { + self.on_activate = Some(Box::from(on_activate)); + self + } +} + +#[must_use] +pub fn segmented_button( + state: &State, +) -> SegmentedButton +where + Renderer: iced_native::Renderer, + Renderer::Theme: StyleSheet, +{ + SegmentedButton::new(&state.inner) +} + +impl<'a, Message, Renderer> Widget for SegmentedButton<'a, Message, Renderer> +where + Renderer: iced_native::Renderer + iced_native::text::Renderer, + Renderer::Theme: StyleSheet, + Message: 'static + Clone, +{ + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + let limits = limits.width(self.width).height(self.height); + + let bounds = limits.max(); + + let size = renderer.default_size(); + + let mut width = 0.0; + let height = bounds.height; + + for (_, content) in self.state.buttons.iter() { + let (w, _) = renderer.measure(&content.text, size, Default::default(), bounds); + width += w + f32::from(self.spacing * 2); + } + + layout::Node::new(limits.resolve(Size::new(width, height))) + } + + fn on_event( + &mut self, + _tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + let bounds = layout.bounds(); + + if bounds.contains(cursor_position) { + let button_width = bounds.width / self.state.buttons.len() as f32; + for (num, (key, _)) in self.state.buttons.iter().enumerate() { + let mut bounds = bounds; + bounds.width = button_width; + bounds.x += num as f32 * button_width; + + if bounds.contains(cursor_position) { + 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; + } + } + } + } + } + + event::Status::Ignored + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor_position: iced::Point, + _viewport: &iced::Rectangle, + _renderer: &Renderer, + ) -> iced_native::mouse::Interaction { + if layout.bounds().contains(cursor_position) { + iced_native::mouse::Interaction::Pointer + } else { + iced_native::mouse::Interaction::Idle + } + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + theme: &::Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor_position: iced::Point, + _viewport: &iced::Rectangle, + ) { + let appearance = theme.appearance(&self.style); + let bounds = layout.bounds(); + let button_width = bounds.width / self.state.buttons.len() as f32; + + for (num, (key, content)) in self.state.buttons.iter().enumerate() { + let mut bounds = bounds; + bounds.width = button_width; + bounds.x += num as f32 * button_width; + + let button_appearance = if self.state.active == key { + appearance.button_active + } else { + appearance.button_inactive + }; + + let x = bounds.center_x(); + let y = bounds.center_y(); + + // Render the background of the button. + if button_appearance.background.is_some() { + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: button_appearance.border_radius, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + button_appearance + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); + } + + // Render the bottom border. + 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, + ); + } + + // Render the text. + renderer.fill_text(iced_native::text::Text { + content: &content.text, + size: f32::from(renderer.default_size()), + bounds: Rectangle { x, y, ..bounds }, + color: button_appearance.text_color, + font: Default::default(), + horizontal_alignment: Horizontal::Center, + vertical_alignment: Vertical::Center, + }); + } + } + + fn overlay<'b>( + &'b self, + _tree: &'b mut Tree, + _layout: iced_native::Layout<'_>, + _renderer: &Renderer, + ) -> Option> { + None + } +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Renderer: iced_native::Renderer + iced_native::text::Renderer + 'a, + Renderer::Theme: StyleSheet, + Message: 'static + Clone, +{ + fn from(widget: SegmentedButton<'a, Message, Renderer>) -> Self { + Self::new(widget) + } +} diff --git a/src/widget/segmented_button/state.rs b/src/widget/segmented_button/state.rs new file mode 100644 index 0000000..b46884c --- /dev/null +++ b/src/widget/segmented_button/state.rs @@ -0,0 +1,82 @@ +/// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +use slotmap::{SecondaryMap, SlotMap}; + +slotmap::new_key_type! { + pub struct Key; +} + +/// Contains all state for interacting with a [`SegmentedButton`]. +pub struct State { + pub inner: WidgetState, + pub data: SecondaryState, +} + +impl Default for State { + fn default() -> Self { + Self { + inner: WidgetState::default(), + data: SecondaryState::default(), + } + } +} + +/// State which is most useful to the widget. +#[derive(Default)] +pub struct WidgetState { + pub buttons: SlotMap, + pub active: Key, +} + +/// State which is most useful to the application. +pub type SecondaryState = SecondaryMap; + +impl State { + /// The ID of the active button. + #[must_use] + pub fn active(&self) -> Key { + self.inner.active + } + + /// Get the application data for the active button. + #[must_use] + pub fn active_data(&self) -> Option<&Data> { + self.data(self.active()) + } + + /// Get the application data for a button. + #[must_use] + pub fn data(&self, key: Key) -> Option<&Data> { + self.data.get(key) + } + + /// Insert a new button. + pub fn insert(&mut self, content: impl Into, data: Data) -> Key { + let key = self.inner.buttons.insert(content.into()); + self.data.insert(key, data); + key + } + + /// Removes a button. + pub fn remove(&mut self, key: Key) -> Option { + self.inner.buttons.remove(key); + self.data.remove(key) + } + + /// Activates this button. + pub fn activate(&mut self, key: Key) { + self.inner.active = key; + } +} + +/// Data to be drawn in a [`SegmentedButton`] button. +pub struct ButtonContent { + pub text: String, +} + +impl From for ButtonContent { + fn from(text: String) -> Self { + ButtonContent { text } + } +} diff --git a/src/widget/segmented_button/style.rs b/src/widget/segmented_button/style.rs new file mode 100644 index 0000000..36396bb --- /dev/null +++ b/src/widget/segmented_button/style.rs @@ -0,0 +1,29 @@ +/// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +use iced_core::{Background, BorderRadius, Color}; + +/// The appearance of a [`SegmentedButton`]. +#[derive(Clone, Copy)] +pub struct Appearance { + pub button_active: ButtonAppearance, + pub button_inactive: ButtonAppearance, +} + +/// The appearance of a button in the [`SegmentedButton`] +#[derive(Clone, Copy)] +pub struct ButtonAppearance { + pub background: Option, + pub border_radius: BorderRadius, + pub border_bottom: Option<(f32, Color)>, + pub text_color: Color, +} + +/// Defines the [`Appearance`] of a [`SegmentedButton`]. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// The [`Appearance`] of the [`SegmentedButton`]. + fn appearance(&self, style: &Self::Style) -> Appearance; +}