diff --git a/examples/application/src/main.rs b/examples/application/src/main.rs index e8d5fbe1..5f4de877 100644 --- a/examples/application/src/main.rs +++ b/examples/application/src/main.rs @@ -125,9 +125,9 @@ impl cosmic::Application for App { let text = cosmic::widget::text(page_content); - let centered = iced::widget::container(text) + let centered = cosmic::widget::container(text) .width(iced::Length::Fill) - .height(iced::Length::Fill) + .height(iced::Length::Shrink) .align_x(iced::alignment::Horizontal::Center) .align_y(iced::alignment::Vertical::Center); diff --git a/examples/cosmic-sctk/Cargo.toml b/examples/cosmic-sctk/Cargo.toml index c90d7f49..81f82ca9 100644 --- a/examples/cosmic-sctk/Cargo.toml +++ b/examples/cosmic-sctk/Cargo.toml @@ -6,5 +6,6 @@ edition = "2021" publish = false [dependencies] -libcosmic = { path = "../..", default-features = false, features = ["wayland", "tokio"] } -cosmic-time = { git = "https://github.com/pop-os/cosmic-time", rev="c4895f6", default-features = false, features = ["libcosmic", "once_cell"] } +libcosmic = { path = "../..", features = ["wayland", "tokio"] } +cosmic-time = { git = "https://github.com/pop-os/cosmic-time", branch = "icon-color", default-features = false, features = ["libcosmic", "once_cell"] } +# cosmic-time = { path = "../../../cosmic-time", default-features = false, features = ["libcosmic", "once_cell"]} \ No newline at end of file diff --git a/examples/cosmic-sctk/README.md b/examples/cosmic-sctk/README.md index c52803b3..04483068 100644 --- a/examples/cosmic-sctk/README.md +++ b/examples/cosmic-sctk/README.md @@ -1,9 +1,3 @@ -# COSMIC -An example of the COSMIC design system. +# Deprecated -All the example code is located in the __[`main`](src/main.rs)__ file. - -You can run it with `cargo run`: -``` -cargo run --package cosmic --release -``` +This example will be removed once its contents are migrated to the design demo. \ No newline at end of file diff --git a/examples/cosmic-sctk/src/window.rs b/examples/cosmic-sctk/src/window.rs index 54d27757..8156ec61 100644 --- a/examples/cosmic-sctk/src/window.rs +++ b/examples/cosmic-sctk/src/window.rs @@ -10,22 +10,21 @@ use cosmic::{ }, iced_futures::Subscription, iced_style::application, - iced_widget::text, + prelude::*, theme::{self, Theme}, widget::{ button, cosmic_container, header_bar, icon, inline_input, nav_bar, nav_bar_toggle, rectangle_tracker::{rectangle_tracker_subscription, RectangleTracker, RectangleUpdate}, scrollable, search_input, secure_input, segmented_button, segmented_selection, settings, - text_input, IconSource, + text, text_input, }, - Element, ElementExt, + Element, }; use cosmic_time::{anim, chain, id, once_cell::sync::Lazy, Instant, Timeline}; use std::{ sync::atomic::{AtomicU32, Ordering}, vec, }; -use theme::Button as ButtonTheme; static DEBUG_TOGGLER: Lazy = Lazy::new(id::Toggler::unique); static TOGGLER: Lazy = Lazy::new(id::Toggler::unique); @@ -138,7 +137,7 @@ impl Window { self.nav_bar_pages .insert() .text(page.title()) - .icon(IconSource::from(page.icon_name())) + .icon(icon::handle::from_name(page.icon_name()).icon()) .data(page) } @@ -373,15 +372,6 @@ impl Application for Window { } if !nav_bar_toggled { - let secondary = button(ButtonTheme::Secondary) - .text("Secondary") - .on_press(Message::ButtonPressed); - - let secondary = if let Some(tracker) = self.rectangle_tracker.as_ref() { - tracker.container(0, secondary).into() - } else { - secondary.into() - }; let content: Element<_> = settings::view_column(vec![ settings::view_section("Debug") .add(settings::item( @@ -396,34 +386,6 @@ impl Application for Window { )), )) .into(), - settings::view_section("Buttons") - .add(settings::item_row(vec![ - button(ButtonTheme::Primary) - .text("Primary") - .on_press(Message::ButtonPressed) - .into(), - secondary, - 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(), settings::view_section("Controls") .add(settings::item( "Toggler", @@ -567,6 +529,7 @@ impl Application for Window { fn style(&self) -> ::Style { cosmic::theme::Application::Custom(Box::new(|theme| application::Appearance { background_color: Color::TRANSPARENT, + icon_color: theme.cosmic().on_bg_color().into(), text_color: theme.cosmic().on_bg_color().into(), })) } diff --git a/examples/cosmic/Cargo.toml b/examples/cosmic/Cargo.toml index 0ca25e1b..74b7e30c 100644 --- a/examples/cosmic/Cargo.toml +++ b/examples/cosmic/Cargo.toml @@ -8,9 +8,9 @@ publish = false [dependencies] apply = "0.3.0" fraction = "0.13.0" -libcosmic = { path = "../..", default-features = false, features = ["debug", "winit"] } +libcosmic = { path = "../..", features = ["debug", "winit", "tokio"] } once_cell = "1.18" slotmap = "1.0.6" env_logger = "0.10" log = "0.4.17" -cosmic-time = { git = "https://github.com/pop-os/cosmic-time", rev="c4895f6", default-features = false, features = ["libcosmic", "once_cell"] } +cosmic-time = { git = "https://github.com/pop-os/cosmic-time", branch="icon-color", default-features = false, features = ["libcosmic", "once_cell"] } diff --git a/examples/cosmic/README.md b/examples/cosmic/README.md index c52803b3..04483068 100644 --- a/examples/cosmic/README.md +++ b/examples/cosmic/README.md @@ -1,9 +1,3 @@ -# COSMIC -An example of the COSMIC design system. +# Deprecated -All the example code is located in the __[`main`](src/main.rs)__ file. - -You can run it with `cargo run`: -``` -cargo run --package cosmic --release -``` +This example will be removed once its contents are migrated to the design demo. \ No newline at end of file diff --git a/examples/cosmic/src/window.rs b/examples/cosmic/src/window.rs index 964d2f94..446b3cb7 100644 --- a/examples/cosmic/src/window.rs +++ b/examples/cosmic/src/window.rs @@ -13,12 +13,13 @@ use cosmic::{ window::{self, close, drag, minimize, toggle_maximize}, }, keyboard_nav, + prelude::*, theme::{self, Theme}, widget::{ - header_bar, icon, list, nav_bar, nav_bar_toggle, scrollable, segmented_button, settings, - warning, IconSource, + button, header_bar, icon, list, nav_bar, nav_bar_toggle, scrollable, segmented_button, + settings, warning, }, - Element, ElementExt, + Element, }; use cosmic_time::{Instant, Timeline}; use std::{ @@ -224,7 +225,7 @@ impl Window { self.nav_bar .insert() .text(page.title()) - .icon(IconSource::from(page.icon_name())) + .icon(icon::handle::from_name(page.icon_name()).icon()) .secondary(&mut self.nav_id_to_page, page) } @@ -247,14 +248,10 @@ impl Window { ) -> Element { let page = sub_page.parent_page(); column!( - iced::widget::Button::new(row!( - icon("go-previous-symbolic", 16).style(theme::Svg::SymbolicLink), - text(page.title()).size(14), - )) - .padding(0) - .style(theme::Button::Link) - // .id(BTN.clone()) - .on_press(Message::from(page)), + button::icon(icon::handle::from_name("go-previous-symbolic").size(16)) + .label(page.title()) + .padding(0) + .on_press(Message::from(page)), row!( text(sub_page.title()).size(28), horizontal_space(Length::Fill), @@ -276,8 +273,9 @@ impl Window { iced::widget::Button::new( container( settings::item_row(vec![ - icon(sub_page.icon_name(), 20) - .style(theme::Svg::Symbolic) + icon::handle::from_name(sub_page.icon_name()) + .size(20) + .icon() .into(), column!( text(sub_page.title()).size(14), @@ -286,8 +284,9 @@ impl Window { .spacing(2) .into(), horizontal_space(iced::Length::Fill).into(), - icon("go-next-symbolic", 20) - .style(theme::Svg::Symbolic) + icon::handle::from_name("go-next-symbolic") + .size(20) + .icon() .into(), ]) .spacing(16), @@ -296,7 +295,7 @@ impl Window { .style(theme::Container::custom(list::column::style)), ) .padding(0) - .style(theme::Button::Transparent) + .style(theme::IcedButton::Transparent) .on_press(Message::from(sub_page.into_page())) // .id(BTN.clone()) .into() diff --git a/examples/cosmic/src/window/demo.rs b/examples/cosmic/src/window/demo.rs index 69be5ccd..9df671d7 100644 --- a/examples/cosmic/src/window/demo.rs +++ b/examples/cosmic/src/window/demo.rs @@ -7,8 +7,8 @@ use cosmic::{ iced::{id, Alignment, Length}, theme::{self, Button as ButtonTheme, ThemeType}, widget::{ - button, container, icon, segmented_button, segmented_selection, settings, spin_button, - toggler, view_switcher, + button, cosmic_container::container, icon, segmented_button, segmented_selection, settings, + spin_button, toggler, view_switcher, }, Element, }; @@ -186,7 +186,7 @@ impl State { Message::IconTheme(key) => { self.icon_themes.activate(key); if let Some(theme) = self.icon_themes.text(key) { - cosmic::icon_theme::set_default(theme); + cosmic::icon_theme::set_default(theme.to_owned()); } } Message::InputChanged(s) => { @@ -255,45 +255,13 @@ impl State { "Scaling Factor", spin_button(&window.scale_factor_string, Message::ScalingFactor), )) - .add(settings::item_row(vec![button(ButtonTheme::Destructive) - .on_press(Message::ToggleWarning) - .custom(vec![ - icon("dialog-warning-symbolic", 16) - .style(theme::Svg::SymbolicPrimary) - .into(), - text("Do Not Touch").into(), - ]) - .into()])) - .into(), - settings::view_section("Buttons") .add(settings::item_row(vec![ - button(ButtonTheme::Primary) - .text("Primary") - .on_press(Message::ButtonPressed) + cosmic::widget::button::destructive("Do not Touch") + .trailing_icon( + icon::handle::from_name("dialog-warning-symbolic").size(16), + ) + .on_press(Message::ToggleWarning) .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(), settings::view_section("Controls") @@ -454,9 +422,13 @@ impl State { "Primary container with some text and a couple icons testing default fallbacks" ) .size(24), - icon("microphone-sensitivity-high-symbolic-test", 24) - .style(cosmic::theme::Svg::SymbolicActive), - icon("microphone-sensitivity-high-symbolic-test", 16).default_fallbacks(false) + icon::handle::from_name("microphone-sensitivity-high-symbolic-test") + .size(24) + .icon(), + icon::handle::from_name("microphone-sensitivity-high-symbolic-test") + .size(24) + .fallback(false) + .icon(), ]) .layer(cosmic_theme::Layer::Primary) .padding(8) @@ -475,9 +447,7 @@ impl State { .iter() .enumerate() .map(|(i, c)| column![ - button(cosmic::theme::Button::Text) - .text("Delete me") - .on_press(Message::DeleteCard(i)), + button::text("Delete me").on_press(Message::DeleteCard(i)), text(c).size(24).width(Length::Fill) ] .into()) diff --git a/examples/cosmic/src/window/editor.rs b/examples/cosmic/src/window/editor.rs index 6c027c09..3c66a6e2 100644 --- a/examples/cosmic/src/window/editor.rs +++ b/examples/cosmic/src/window/editor.rs @@ -1,7 +1,7 @@ use cosmic::iced::widget::{horizontal_space, row}; use cosmic::iced::{Alignment, Length}; -use cosmic::widget::{button, segmented_button, view_switcher}; -use cosmic::{theme, Element}; +use cosmic::widget::{button, icon, segmented_button, view_switcher}; +use cosmic::{theme, Apply, Element}; use slotmap::Key; #[derive(Clone, Copy, Debug)] @@ -66,8 +66,9 @@ impl State { .on_close(Message::Close) .width(Length::Shrink); - let new_tab_button = button(theme::Button::Text) - .icon(theme::Svg::Symbolic, "tab-new-symbolic", 20) + let new_tab_button = icon::handle::from_name("tab-new-symbolic") + .size(20) + .apply(button::icon) .on_press(Message::AddNew); let tab_header = row!(tabs, new_tab_button).align_items(Alignment::Center); diff --git a/examples/cosmic/src/window/system_and_accounts.rs b/examples/cosmic/src/window/system_and_accounts.rs index 7047b196..f05a44e8 100644 --- a/examples/cosmic/src/window/system_and_accounts.rs +++ b/examples/cosmic/src/window/system_and_accounts.rs @@ -62,7 +62,7 @@ impl State { window.parent_page_button(SystemAndAccountsPage::About), row!( horizontal_space(Length::Fill), - icon("distributor-logo", 78), + icon::handle::from_name("distributor-logo").size(78).icon(), horizontal_space(Length::Fill), ) .into(), diff --git a/examples/open-dialog/src/main.rs b/examples/open-dialog/src/main.rs index 6533394a..9e6297a1 100644 --- a/examples/open-dialog/src/main.rs +++ b/examples/open-dialog/src/main.rs @@ -7,6 +7,7 @@ use apply::Apply; use cosmic::app::{Command, Core, Settings}; use cosmic::dialog::file_chooser::{self, FileFilter}; use cosmic::iced_core::Length; +use cosmic::widget::button; use cosmic::{executor, iced, ApplicationExt, Element}; use tokio::io::AsyncReadExt; use url::Url; @@ -82,10 +83,7 @@ impl cosmic::Application for App { fn header_end(&self) -> Vec> { // Places a button the header to create open dialogs. - vec![cosmic::widget::button(cosmic::theme::Button::Primary) - .text("Open") - .on_press(Message::OpenFile) - .into()] + vec![button::suggested("Open").on_press(Message::OpenFile).into()] } fn subscription(&self) -> cosmic::iced_futures::Subscription { diff --git a/iced b/iced index 2ead0da0..8b2389f1 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 2ead0da06f6da58b01e107104808b45d6fb61e85 +Subproject commit 8b2389f144966a5f9b60ab778c1073748fee5e70 diff --git a/res/external-link.svg b/res/external-link.svg new file mode 100644 index 00000000..156f00bc --- /dev/null +++ b/res/external-link.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/app/applet/mod.rs b/src/app/applet/mod.rs index 92aca686..a6a70390 100644 --- a/src/app/applet/mod.rs +++ b/src/app/applet/mod.rs @@ -127,7 +127,7 @@ impl CosmicAppletHelper { pub fn icon_button<'a, Message: 'static>( &self, icon_name: &'a str, - ) -> iced::widget::Button<'a, Message, Renderer> { + ) -> crate::widget::Button<'a, Message, Renderer> { crate::widget::button(theme::Button::Text) .icon(theme::Svg::Symbolic, icon_name, self.suggested_size().0) .padding(8) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 6758e4d0..74d15f08 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -110,6 +110,7 @@ where } else { theme::Application::Custom(Box::new(|theme| iced_style::application::Appearance { background_color: iced_core::Color::TRANSPARENT, + icon_color: theme.cosmic().on_bg_color().into(), text_color: theme.cosmic().on_bg_color().into(), })) } diff --git a/src/app/mod.rs b/src/app/mod.rs index f84d7e0e..603de483 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -41,9 +41,9 @@ pub mod message { pub use self::command::Command; pub use self::core::Core; pub use self::settings::Settings; +use crate::prelude::*; use crate::theme::THEME; use crate::widget::nav_bar; -use crate::{Element, ElementExt}; use apply::Apply; use iced::Subscription; use iced::{window, Application as IcedApplication}; @@ -261,91 +261,90 @@ impl ApplicationExt for App { } /// Creates the view for the main window. - fn view_main<'a>(&'a self) -> Element<'a, Message> { + fn view_main(&self) -> Element> { let core = self.core(); let is_condensed = core.is_condensed(); - let mut main: Vec>> = Vec::with_capacity(2); - if core.window.show_headerbar { - main.push({ - let mut header = crate::widget::header_bar() - .title(self.title()) - .on_drag(Message::Cosmic(cosmic::Message::Drag)) - .on_close(Message::Cosmic(cosmic::Message::Close)); + crate::widget::column::with_capacity(2) + .push_maybe(if core.window.show_headerbar { + Some({ + let mut header = crate::widget::header_bar() + .title(self.title()) + .on_drag(Message::Cosmic(cosmic::Message::Drag)) + .on_close(Message::Cosmic(cosmic::Message::Close)); - if self.nav_model().is_some() { - let toggle = crate::widget::nav_bar_toggle() - .active(core.nav_bar_active()) - .on_toggle(if is_condensed { - Message::Cosmic(cosmic::Message::ToggleNavBarCondensed) - } else { - Message::Cosmic(cosmic::Message::ToggleNavBar) - }); + if self.nav_model().is_some() { + let toggle = crate::widget::nav_bar_toggle() + .active(core.nav_bar_active()) + .on_toggle(if is_condensed { + Message::Cosmic(cosmic::Message::ToggleNavBarCondensed) + } else { + Message::Cosmic(cosmic::Message::ToggleNavBar) + }); - header = header.start(toggle); - } - - if core.window.show_maximize { - header = header.on_maximize(Message::Cosmic(cosmic::Message::Maximize)); - } - - if core.window.show_minimize { - header = header.on_minimize(Message::Cosmic(cosmic::Message::Minimize)); - } - - for element in self.header_start() { - header = header.start(element.map(Message::App)); - } - - for element in self.header_center() { - header = header.center(element.map(Message::App)); - } - - for element in self.header_end() { - header = header.end(element.map(Message::App)); - } - - Element::from(header).debug(core.debug) - }); - } - - // The content element contains every element beneath the header. - main.push( - iced::widget::row({ - let mut widgets = Vec::with_capacity(2); - - // Insert nav bar onto the left side of the window. - if core.nav_bar_active() { - if let Some(nav_model) = self.nav_model() { - let mut nav = crate::widget::nav_bar(nav_model, |entity| { - Message::Cosmic(cosmic::Message::NavBar(entity)) - }); - - if !is_condensed { - nav = nav.max_width(300); - } - - widgets.push(nav.apply(Element::from).debug(core.debug)); + header = header.start(toggle); } - } - if self.nav_model().is_none() || core.show_content() { - let main_content = self.view().debug(core.debug).map(Message::App); + if core.window.show_maximize { + header = header.on_maximize(Message::Cosmic(cosmic::Message::Maximize)); + } - widgets.push(main_content); - } + if core.window.show_minimize { + header = header.on_minimize(Message::Cosmic(cosmic::Message::Minimize)); + } - widgets + for element in self.header_start() { + header = header.start(element.map(Message::App)); + } + + for element in self.header_center() { + header = header.center(element.map(Message::App)); + } + + for element in self.header_end() { + header = header.end(element.map(Message::App)); + } + + header + }) + } else { + None }) - .spacing(8) - .apply(iced::widget::container) - .padding([0, 8, 8, 8]) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .style(crate::theme::Container::Background) - .into(), - ); + // The content element contains every element beneath the header. + .push( + crate::widget::row::with_children({ + let mut widgets = Vec::with_capacity(2); - iced::widget::column(main).into() + // Insert nav bar onto the left side of the window. + if core.nav_bar_active() { + if let Some(nav_model) = self.nav_model() { + let mut nav = crate::widget::nav_bar(nav_model, |entity| { + Message::Cosmic(cosmic::Message::NavBar(entity)) + }); + + if !is_condensed { + nav = nav.max_width(300); + } + + widgets.push(nav.apply(Element::from).debug(core.debug)); + } + } + + if self.nav_model().is_none() || core.show_content() { + let main_content = self.view().debug(core.debug).map(Message::App); + + widgets.push(main_content); + } + + widgets + }) + .spacing(8) + .apply(crate::widget::container) + .padding([0, 8, 8, 8]) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(crate::theme::Container::Background), + ) + .into() } } diff --git a/src/app/settings.rs b/src/app/settings.rs index 99ab8de6..1819407e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -10,6 +10,7 @@ use iced_core::Font; /// Configure a new COSMIC application. #[allow(clippy::struct_excessive_bools)] +#[must_use] #[derive(derive_setters::Setters)] pub struct Settings { /// Produces a smoother result in some widgets, at a performance cost. diff --git a/src/theme/button.rs b/src/theme/button.rs new file mode 100644 index 00000000..3aed8186 --- /dev/null +++ b/src/theme/button.rs @@ -0,0 +1,113 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use cosmic_theme::Component; +use iced_core::{Background, Color}; +use palette::{rgb::Rgb, Alpha}; + +use crate::{ + app, + widget::button::{Appearance, StyleSheet}, +}; + +#[derive(Copy, Clone, Debug, Default)] +pub enum Button { + Destructive, + Link, + Icon, + #[default] + Standard, + Suggested, + Text, +} + +pub fn appearance( + theme: &crate::Theme, + focused: bool, + style: &Button, + color: fn(&Component>) -> Color, +) -> Appearance { + let cosmic = theme.cosmic(); + let mut corner_radii = &cosmic.corner_radii.radius_xl; + let mut appearance = Appearance::new(); + + match style { + Button::Standard => { + let component = &theme.current_container().component; + appearance.background = Some(Background::Color(color(component))); + appearance.text_color = component.on.into(); + } + + Button::Icon | Button::Text => { + let component = &cosmic.text_button; + appearance.background = None; + appearance.text_color = component.on.into(); + } + + Button::Suggested => { + let component = &cosmic.accent_button; + appearance.background = Some(Background::Color(color(component))); + appearance.icon_color = Some(component.on.into()); + appearance.text_color = component.on.into(); + } + + Button::Destructive => { + let component = &cosmic.destructive_button; + appearance.background = Some(Background::Color(color(component))); + appearance.icon_color = Some(component.on.into()); + appearance.text_color = component.on.into(); + } + + Button::Link => { + appearance.background = None; + appearance.icon_color = Some(cosmic.accent.base.into()); + appearance.text_color = cosmic.accent.base.into(); + corner_radii = &cosmic.corner_radii.radius_0; + } + } + + appearance.border_radius = (*corner_radii).into(); + + if focused { + appearance.outline_width = 1.0; + appearance.outline_color = cosmic.accent.base.into(); + appearance.border_width = 2.0; + appearance.border_color = Color::TRANSPARENT; + } + + appearance +} + +impl StyleSheet for crate::Theme { + type Style = Button; + + fn active(&self, focused: bool, style: &Self::Style) -> Appearance { + appearance(self, focused, style, |component| component.base.into()) + } + + fn disabled(&self, style: &Self::Style) -> Appearance { + appearance(self, false, style, |component| { + let mut color = Color::from(component.base); + color.a *= 0.5; + color + }) + } + + fn drop_target(&self, style: &Self::Style) -> Appearance { + let mut appearance = self.active(false, style); + + appearance + } + + fn hovered(&self, focused: bool, style: &Self::Style) -> Appearance { + appearance(self, focused, style, |component| component.hover.into()) + } + + fn pressed(&self, focused: bool, style: &Self::Style) -> Appearance { + appearance(self, focused, style, |component| component.pressed.into()) + } + + fn selected(&self, focused: bool, style: &Self::Style) -> Appearance { + appearance(self, focused, style, |component| component.selected.into()) + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 16b14706..3cfa7820 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -4,17 +4,18 @@ //! Use COSMIC's themes and styles. pub mod expander; + +mod button; +pub use self::button::Button; + mod segmented_button; +pub use self::segmented_button::SegmentedButton; use std::cell::RefCell; use std::f32::consts::PI; -use std::hash::Hash; -use std::hash::Hasher; use std::rc::Rc; use std::sync::Arc; -pub use self::segmented_button::SegmentedButton; - use cosmic_config::config_subscription; use cosmic_config::CosmicConfigEntry; use cosmic_theme::composite::over; @@ -26,7 +27,7 @@ use iced_core::BorderRadius; use iced_core::Radians; use iced_futures::Subscription; use iced_style::application; -use iced_style::button; +use iced_style::button as iced_button; use iced_style::checkbox; use iced_style::container; use iced_style::menu; @@ -195,6 +196,7 @@ impl application::StyleSheet for Theme { match style { Application::Default => application::Appearance { + icon_color: cosmic.bg_color().into(), background_color: cosmic.bg_color().into(), text_color: cosmic.on_bg_color().into(), }, @@ -203,13 +205,13 @@ impl application::StyleSheet for Theme { } } -/* - * TODO: Button - */ -pub enum Button { +/// Styles for the button widget from iced-rs. +#[derive(Default)] +pub enum IcedButton { Deactivated, Destructive, Positive, + #[default] Primary, Secondary, Text, @@ -218,110 +220,104 @@ pub enum Button { Transparent, Card, Custom { - active: Box button::Appearance>, - hover: Box button::Appearance>, + active: Box iced_button::Appearance>, + hover: Box iced_button::Appearance>, }, } -impl Default for Button { - fn default() -> Self { - Self::Primary - } -} - -impl Button { +impl IcedButton { #[allow(clippy::trivially_copy_pass_by_ref)] #[allow(clippy::match_same_arms)] fn cosmic<'a>(&'a self, theme: &'a Theme) -> &CosmicComponent { let cosmic = theme.cosmic(); match self { - Button::Primary => &cosmic.accent_button, - Button::Secondary => &theme.current_container().component, - Button::Positive => &cosmic.success_button, - Button::Destructive => &cosmic.destructive_button, - Button::Text => &cosmic.text_button, - Button::Link => &cosmic.accent_button, - Button::LinkActive => &cosmic.accent_button, - Button::Transparent => &TRANSPARENT_COMPONENT, - Button::Deactivated => &theme.current_container().component, - Button::Card => &theme.current_container().component, - Button::Custom { .. } => &TRANSPARENT_COMPONENT, + IcedButton::Primary => &cosmic.accent_button, + IcedButton::Secondary => &theme.current_container().component, + IcedButton::Positive => &cosmic.success_button, + IcedButton::Destructive => &cosmic.destructive_button, + IcedButton::Text => &cosmic.text_button, + IcedButton::Link => &cosmic.accent_button, + IcedButton::LinkActive => &cosmic.accent_button, + IcedButton::Transparent => &TRANSPARENT_COMPONENT, + IcedButton::Deactivated => &theme.current_container().component, + IcedButton::Card => &theme.current_container().component, + IcedButton::Custom { .. } => &TRANSPARENT_COMPONENT, } } } -impl button::StyleSheet for Theme { - type Style = Button; +impl iced_button::StyleSheet for Theme { + type Style = IcedButton; - fn active(&self, style: &Self::Style) -> button::Appearance { - if let Button::Custom { active, .. } = style { + fn active(&self, style: &Self::Style) -> iced_button::Appearance { + if let IcedButton::Custom { active, .. } = style { return active(self); } let corner_radii = &self.cosmic().corner_radii; let component = style.cosmic(self); - button::Appearance { + iced_button::Appearance { border_radius: match style { - Button::Link => corner_radii.radius_0.into(), - Button::Card => corner_radii.radius_xs.into(), + IcedButton::Link => corner_radii.radius_0.into(), + IcedButton::Card => corner_radii.radius_xs.into(), _ => corner_radii.radius_xl.into(), }, background: match style { - Button::Link | Button::Text => None, - Button::LinkActive => Some(Background::Color(component.divider.into())), + IcedButton::Link | IcedButton::Text => None, + IcedButton::LinkActive => Some(Background::Color(component.divider.into())), _ => Some(Background::Color(component.base.into())), }, text_color: match style { - Button::Link | Button::LinkActive => component.base.into(), + IcedButton::Link | IcedButton::LinkActive => component.base.into(), _ => component.on.into(), }, - ..button::Appearance::default() + ..iced_button::Appearance::default() } } - fn hovered(&self, style: &Self::Style) -> button::Appearance { - if let Button::Custom { hover, .. } = style { + fn hovered(&self, style: &Self::Style) -> iced_button::Appearance { + if let IcedButton::Custom { hover, .. } = style { return hover(self); } let active = self.active(style); let component = style.cosmic(self); - button::Appearance { + iced_button::Appearance { background: match style { - Button::Link => None, - Button::LinkActive => Some(Background::Color(component.divider.into())), + IcedButton::Link => None, + IcedButton::LinkActive => Some(Background::Color(component.divider.into())), _ => Some(Background::Color(component.hover.into())), }, ..active } } - fn focused(&self, style: &Self::Style) -> button::Appearance { - if let Button::Custom { hover, .. } = style { + fn focused(&self, style: &Self::Style) -> iced_button::Appearance { + if let IcedButton::Custom { hover, .. } = style { return hover(self); } let active = self.active(style); let component = style.cosmic(self); - button::Appearance { + iced_button::Appearance { background: match style { - Button::Link => None, - Button::LinkActive => Some(Background::Color(component.divider.into())), + IcedButton::Link => None, + IcedButton::LinkActive => Some(Background::Color(component.divider.into())), _ => Some(Background::Color(component.hover.into())), }, ..active } } - fn disabled(&self, style: &Self::Style) -> button::Appearance { + fn disabled(&self, style: &Self::Style) -> iced_button::Appearance { let active = self.active(style); - if matches!(style, Button::Card) { + if matches!(style, IcedButton::Card) { return active; } - button::Appearance { + iced_button::Appearance { shadow_offset: iced_core::Vector::default(), background: active.background.map(|background| match background { Background::Color(color) => Background::Color(Color { @@ -563,6 +559,7 @@ impl container::StyleSheet for Theme { let palette = self.cosmic(); container::Appearance { + icon_color: Some(Color::from(palette.background.on)), text_color: Some(Color::from(palette.background.on)), background: Some(iced::Background::Color(palette.background.base.into())), border_radius: 2.0.into(), @@ -577,6 +574,7 @@ impl container::StyleSheet for Theme { header_top.alpha = 0.8; container::Appearance { + icon_color: Some(Color::from(palette.accent.base)), text_color: Some(Color::from(palette.background.on)), background: Some(iced::Background::Gradient(iced_core::Gradient::Linear( Linear::new(Radians(3.0 * PI / 2.0)) @@ -592,6 +590,7 @@ impl container::StyleSheet for Theme { let palette = self.cosmic(); container::Appearance { + icon_color: Some(Color::from(palette.primary.on)), text_color: Some(Color::from(palette.primary.on)), background: Some(iced::Background::Color(palette.primary.base.into())), border_radius: 2.0.into(), @@ -603,6 +602,7 @@ impl container::StyleSheet for Theme { let palette = self.cosmic(); container::Appearance { + icon_color: Some(Color::from(palette.secondary.on)), text_color: Some(Color::from(palette.secondary.on)), background: Some(iced::Background::Color(palette.secondary.base.into())), border_radius: 2.0.into(), @@ -615,6 +615,7 @@ impl container::StyleSheet for Theme { match self.layer { cosmic_theme::Layer::Background => container::Appearance { + icon_color: Some(Color::from(palette.background.component.on)), text_color: Some(Color::from(palette.background.component.on)), background: Some(iced::Background::Color( palette.background.component.base.into(), @@ -624,6 +625,7 @@ impl container::StyleSheet for Theme { border_color: Color::TRANSPARENT, }, cosmic_theme::Layer::Primary => container::Appearance { + icon_color: Some(Color::from(palette.primary.component.on)), text_color: Some(Color::from(palette.primary.component.on)), background: Some(iced::Background::Color( palette.primary.component.base.into(), @@ -633,6 +635,7 @@ impl container::StyleSheet for Theme { border_color: Color::TRANSPARENT, }, cosmic_theme::Layer::Secondary => container::Appearance { + icon_color: Some(Color::from(palette.secondary.component.on)), text_color: Some(Color::from(palette.secondary.component.on)), background: Some(iced::Background::Color( palette.secondary.component.base.into(), @@ -1021,29 +1024,6 @@ pub enum Svg { /// No filtering is applied #[default] Default, - /// Icon fill color will match text color - Symbolic, - /// Icon fill color will match accent color - SymbolicActive, - /// Icon fill color will match on primary color - SymbolicPrimary, - /// Icon fill color will use accent color - SymbolicLink, -} - -impl Hash for Svg { - fn hash(&self, state: &mut H) { - let id = match self { - Svg::Custom(_) => 0, - Svg::Default => 1, - Svg::Symbolic => 2, - Svg::SymbolicActive => 3, - Svg::SymbolicPrimary => 4, - Svg::SymbolicLink => 5, - }; - - id.hash(state); - } } impl Svg { @@ -1060,18 +1040,6 @@ impl svg::StyleSheet for Theme { match style { Svg::Default => svg::Appearance::default(), Svg::Custom(appearance) => appearance(self), - Svg::Symbolic => svg::Appearance { - color: Some(self.current_container().on.into()), - }, - Svg::SymbolicActive => svg::Appearance { - color: Some(self.cosmic().accent.base.into()), - }, - Svg::SymbolicPrimary => svg::Appearance { - color: Some(self.cosmic().accent.on.into()), - }, - Svg::SymbolicLink => svg::Appearance { - color: Some(self.cosmic().accent.base.into()), - }, } } } diff --git a/src/widget/button.rs b/src/widget/button.rs deleted file mode 100644 index 1bd5f106..00000000 --- a/src/widget/button.rs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2022 System76 -// SPDX-License-Identifier: MPL-2.0 - -use crate::{ - theme::{self, THEME}, - Element, Renderer, -}; -use iced::widget; - -/// A button widget with COSMIC styling -#[must_use] -pub const fn button(style: theme::Button) -> Button { - Button { - style, - message: None, - } -} - -/// A button widget with COSMIC styling -pub struct Button { - style: theme::Button, - message: Option, -} - -impl Button { - /// The message to emit on button press. - #[must_use] - pub fn on_press(mut self, message: Message) -> Self { - self.message = Some(message); - self - } - - /// A button with an icon. - pub fn icon( - self, - style: theme::Svg, - icon: &str, - size: u16, - ) -> widget::Button { - self.custom(vec![super::icon(icon, size).style(style).into()]) - } - - /// A button with text. - pub fn text(self, text: &str) -> widget::Button { - self.custom(vec![text.into()]) - } - - /// A custom button that has the desired default spacing and padding. - pub fn custom(self, children: Vec>) -> widget::Button { - let theme = THEME.with(|t| t.borrow().clone()); - let theme = theme.cosmic(); - let button = widget::button(widget::row(children).spacing(8)) - .style(self.style) - .padding([theme.space_xxs(), theme.space_s()]); - - if let Some(message) = self.message { - button.on_press(message) - } else { - button - } - } -} diff --git a/src/widget/button/hyperlink.rs b/src/widget/button/hyperlink.rs new file mode 100644 index 00000000..472d3162 --- /dev/null +++ b/src/widget/button/hyperlink.rs @@ -0,0 +1,50 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use super::Builder; +use super::Style; +use crate::widget::icon::{self, Handle}; +use iced_core::{font::Weight, widget::Id, Length, Padding}; +use std::borrow::Cow; + +pub type Button<'a, Message> = Builder<'a, Message, Hyperlink>; + +pub struct Hyperlink { + trailing_icon: bool, +} + +pub fn hyperlink<'a, Message>() -> Button<'a, Message> { + Button::new(Hyperlink { + trailing_icon: false, + }) +} + +impl<'a, Message> Button<'a, Message> { + pub fn new(link: Hyperlink) -> Self { + Self { + id: Id::unique(), + label: Cow::Borrowed(""), + on_press: None, + width: Length::Shrink, + height: Length::Shrink, + padding: Padding::from(0), + spacing: 0, + icon_size: 16, + line_height: 20, + font_size: 14, + font_weight: Weight::Normal, + style: Style::Link, + variant: link, + } + } + + pub const fn with_icon(mut self) -> Self { + self.variant.trailing_icon = true; + self + } +} + +pub fn icon() -> Handle { + icon::handle::from_svg_bytes(&include_bytes!("../../../res/external-link.svg")[..]) + .symbolic(true) +} diff --git a/src/widget/button/icon.rs b/src/widget/button/icon.rs new file mode 100644 index 00000000..c03aaba9 --- /dev/null +++ b/src/widget/button/icon.rs @@ -0,0 +1,179 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use super::{button, Builder, Style}; +use crate::{widget::icon::Handle, Element}; +use apply::Apply; +use iced_core::{font::Weight, text::LineHeight, widget::Id, Alignment, Length, Padding}; +use std::borrow::Cow; + +pub type Button<'a, Message> = Builder<'a, Message, Icon>; + +pub struct Icon { + handle: Handle, + selected: bool, + vertical: bool, +} + +pub fn icon(handle: impl Into) -> Button<'static, Message> { + Button::new(Icon { + handle: handle.into(), + selected: false, + vertical: false, + }) +} + +impl<'a, Message> Button<'a, Message> { + pub fn new(icon: Icon) -> Self { + crate::theme::THEME.with(|theme_cell| { + let theme = theme_cell.borrow(); + let theme = theme.cosmic(); + let padding = theme.space_xxs(); + + Self { + id: Id::unique(), + label: Cow::Borrowed(""), + on_press: None, + width: Length::Shrink, + height: Length::Fixed(46.0), + padding: Padding::from(padding), + spacing: theme.space_xxxs(), + icon_size: 16, + line_height: 20, + font_size: 14, + font_weight: Weight::Normal, + style: Style::Icon, + variant: icon, + } + }) + } + + /// Applies the **Extra Small** button size preset. + pub fn extra_small(mut self) -> Self { + crate::theme::THEME.with(|theme_cell| { + let theme = theme_cell.borrow(); + let theme = theme.cosmic(); + + self.font_size = 14; + self.font_weight = Weight::Normal; + self.icon_size = 16; + self.line_height = 20; + self.height = Length::Fixed(36.0); + self.padding = Padding::from(theme.space_xxs()); + self.spacing = theme.space_xxxs(); + }); + + self + } + + /// Applies the **Medium** button size preset. + pub fn medium(mut self) -> Self { + crate::theme::THEME.with(|theme_cell| { + let theme = theme_cell.borrow(); + let theme = theme.cosmic(); + + self.font_size = 24; + self.font_weight = Weight::Normal; + self.icon_size = 32; + self.line_height = 32; + self.height = Length::Fixed(56.0); + self.padding = Padding::from(theme.space_xs()); + self.spacing = theme.space_xxs(); + }); + + self + } + + /// Applies the **Large** button size preset. + pub fn large(mut self) -> Self { + crate::theme::THEME.with(|theme_cell| { + let theme = theme_cell.borrow(); + let theme = theme.cosmic(); + + self.font_size = 28; + self.font_weight = Weight::Light; + self.icon_size = 40; + self.line_height = 36; + self.height = Length::Fixed(64.0); + self.padding = Padding::from(theme.space_xs()); + self.spacing = theme.space_xxs(); + }); + + self + } + + /// Applies the **Extra Large** button size preset. + pub fn extra_large(mut self) -> Self { + crate::theme::THEME.with(|theme_cell| { + let theme = theme_cell.borrow(); + let theme = theme.cosmic(); + let padding = theme.space_xs(); + + self.font_size = 32; + self.font_weight = Weight::Light; + self.icon_size = 56; + self.line_height = 44; + self.height = Length::Fixed(80.0); + self.padding = Padding::from(padding); + self.spacing = theme.space_xxs(); + }); + + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.variant.selected = selected; + self + } +} + +impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { + fn from(builder: Button<'a, Message>) -> Element<'a, Message> { + let mut content = Vec::with_capacity(2); + + content.push( + crate::widget::icon(builder.variant.handle.clone()) + .size(builder.icon_size) + .into(), + ); + + if !builder.label.is_empty() { + content.push( + crate::widget::text(builder.label) + .size(builder.font_size) + .line_height(LineHeight::Absolute(builder.line_height.into())) + .font({ + let mut font = crate::font::DEFAULT; + font.weight = builder.font_weight; + font + }) + .into(), + ); + } + + let button = if builder.variant.vertical { + crate::widget::column::with_children(content) + .padding(builder.padding) + .width(builder.width) + .height(builder.height) + .spacing(builder.spacing) + .align_items(Alignment::Center) + .apply(button) + } else { + crate::widget::row::with_children(content) + .padding(builder.padding) + .width(builder.width) + .height(builder.height) + .spacing(builder.spacing) + .align_items(Alignment::Center) + .apply(button) + }; + + button + .padding(0) + .id(builder.id) + .on_press_maybe(builder.on_press) + .style(builder.style) + .into() + } +} diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs new file mode 100644 index 00000000..853d6c67 --- /dev/null +++ b/src/widget/button/mod.rs @@ -0,0 +1,113 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +pub mod hyperlink; +pub use hyperlink::Button as LinkButton; + +mod icon; +pub use icon::icon; +pub use icon::Button as IconButton; + +mod style; +pub use style::{Appearance, StyleSheet}; + +mod text; +pub use text::Button as TextButton; +pub use text::{destructive, standard, suggested, text}; + +mod widget; +pub use widget::{draw, focus, layout, mouse_interaction, Button}; + +pub use crate::theme::Button as Style; +use crate::Element; +use iced_core::font::Weight; +use iced_core::widget::Id; +use iced_core::{Length, Padding}; +use std::borrow::Cow; + +pub fn button<'a, Message>( + content: impl Into>, +) -> Button<'a, Message, crate::Renderer> { + Button::new(content) +} + +#[must_use] +pub struct Builder<'a, Message, Variant> { + id: Id, + label: Cow<'a, str>, + // tooltip: Cow<'a, str>, + on_press: Option, + width: Length, + height: Length, + padding: Padding, + spacing: u16, + icon_size: u16, + line_height: u16, + font_size: u16, + font_weight: Weight, + style: Style, + variant: Variant, +} + +// /// A [`Button`] with an icon, which may be used in place of text. +// pub const fn icon<'a>(selected: bool) -> Button<'a> { +// Builder::new(Standard::Icon { selected }) +// } + +impl<'a, Message, Variant> Builder<'a, Message, Variant> { + /// Sets the [`Id`] of the [`Button`]. + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } + + pub fn label(mut self, label: impl Into>) -> Self { + self.label = label.into(); + self + } + + /// Sets the width of the [`Button`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Button`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the [`Padding`] of the [`Button`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// Unless `on_press` is called, the [`Button`] will be disabled. + pub fn on_press(mut self, on_press: Message) -> Self { + self.on_press = Some(on_press); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed, + /// if `Some`. + /// + /// If `None`, the [`Button`] will be disabled. + pub fn on_press_maybe(mut self, on_press: Option) -> Self { + self.on_press = on_press; + self + } + + pub fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + + pub fn tooltip(mut self, label: impl Into>) -> Self { + self.label = label.into(); + self + } +} diff --git a/src/widget/button/style.rs b/src/widget/button/style.rs new file mode 100644 index 00000000..2304ff15 --- /dev/null +++ b/src/widget/button/style.rs @@ -0,0 +1,90 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Change the apperance of a button. +use iced_core::{Background, BorderRadius, Color, Vector}; + +/// The appearance of a button. +#[must_use] +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The amount of offset to apply to the shadow of the button. + pub shadow_offset: Vector, + + /// The [`Background`] of the button. + pub background: Option, + + /// The border radius of the button. + pub border_radius: BorderRadius, + + /// The border width of the button. + pub border_width: f32, + + /// The border [`Color`] of the button. + pub border_color: Color, + + /// Opacity of the button. + pub opacity: f32, + + /// An outline placed around the border. + pub outline_width: f32, + + /// The [`Color`] of the outline. + pub outline_color: Color, + + /// The icon [`Color`] of the button. + pub icon_color: Option, + + /// The text [`Color`] of the button. + pub text_color: Color, +} + +impl Appearance { + // TODO: `BorderRadius` is not `const fn` compatible. + pub fn new() -> Self { + Self { + shadow_offset: Vector::new(0.0, 0.0), + background: None, + border_radius: BorderRadius::from(0.0), + border_width: 0.0, + border_color: Color::TRANSPARENT, + opacity: 1.0, + outline_width: 0.0, + outline_color: Color::TRANSPARENT, + icon_color: None, + text_color: Color::BLACK, + } + } +} + +impl std::default::Default for Appearance { + fn default() -> Self { + Self::new() + } +} + +/// A set of rules that dictate the style of a button. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// Produces the active [`Appearance`] of a button. + fn active(&self, focused: bool, style: &Self::Style) -> Appearance; + + /// Produces the disabled [`Appearance`] of a button. + fn disabled(&self, style: &Self::Style) -> Appearance; + + /// [`Appearance`] when the button is the target of a DND operation. + fn drop_target(&self, style: &Self::Style) -> Appearance { + self.hovered(false, style) + } + + /// Produces the hovered [`Appearance`] of a button. + fn hovered(&self, focused: bool, style: &Self::Style) -> Appearance; + + /// Produces the pressed [`Appearance`] of a button. + fn pressed(&self, focused: bool, style: &Self::Style) -> Appearance; + + /// [`Appearance`] when a button is selected. + fn selected(&self, focused: bool, style: &Self::Style) -> Appearance; +} diff --git a/src/widget/button/text.rs b/src/widget/button/text.rs new file mode 100644 index 00000000..5d6a9fc9 --- /dev/null +++ b/src/widget/button/text.rs @@ -0,0 +1,123 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use super::{button, Builder, Style}; +use crate::widget::{self, icon::Handle, row}; +use crate::{ext::CollectionWidget, Element}; +use apply::Apply; +use iced_core::{font::Weight, text::LineHeight, widget::Id, Alignment, Length, Padding}; +use std::borrow::Cow; + +/// A [`Button`] with the highest level of attention. +/// +/// There should only be one primary button used per page. +pub type Button<'a, Message> = Builder<'a, Message, Text>; + +pub fn destructive<'a, Message>(label: impl Into>) -> Button<'a, Message> { + Button::new(Text::new()) + .label(label) + .style(Style::Destructive) +} + +pub fn suggested<'a, Message>(label: impl Into>) -> Button<'a, Message> { + Button::new(Text::new()) + .label(label) + .style(Style::Suggested) +} + +pub fn standard<'a, Message>(label: impl Into>) -> Button<'a, Message> { + Button::new(Text::new()).label(label) +} + +pub fn text<'a, Message>(label: impl Into>) -> Button<'a, Message> { + Button::new(Text::new()).label(label).style(Style::Text) +} + +pub struct Text { + pub(super) leading_icon: Option, + pub(super) trailing_icon: Option, +} + +impl Text { + pub const fn new() -> Self { + Self { + leading_icon: None, + trailing_icon: None, + } + } +} + +impl<'a, Message> Button<'a, Message> { + pub fn new(text: Text) -> Self { + crate::theme::THEME.with(|theme_cell| { + let theme = theme_cell.borrow(); + let theme = theme.cosmic(); + Self { + id: Id::unique(), + label: Cow::Borrowed(""), + on_press: None, + width: Length::Shrink, + height: Length::Fixed(theme.space_l().into()), + padding: Padding::from([0, theme.space_s()]), + spacing: theme.space_xxxs(), + icon_size: 16, + line_height: 20, + font_size: 14, + font_weight: Weight::Normal, + style: Style::Standard, + variant: text, + } + }) + } + + pub fn leading_icon(mut self, icon: impl Into) -> Self { + self.variant.leading_icon = Some(icon.into()); + self + } + + pub fn trailing_icon(mut self, icon: impl Into) -> Self { + self.variant.trailing_icon = Some(icon.into()); + self + } +} + +impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { + fn from(mut b: Button<'a, Message>) -> Element<'a, Message> { + // TODO: Determine why this needs to be set before the label to prevent lifetime conflict. + let trailing_icon = b + .variant + .trailing_icon + .map(|i| Element::from(widget::icon(i).size(b.icon_size))); + + row::with_capacity(3) + // Optional icon to place before label. + .push_maybe( + b.variant + .leading_icon + .map(|i| widget::icon(i).size(b.icon_size)), + ) + // Optional label between icons. + .push_maybe((!b.label.is_empty()).then(|| { + let mut font = crate::font::DEFAULT; + font.weight = b.font_weight; + + crate::widget::text(b.label) + .size(b.font_size) + .line_height(LineHeight::Absolute(b.line_height.into())) + .font(font) + })) + // Optional icon to place behind the label. + .push_maybe(trailing_icon) + .padding(b.padding) + .width(b.width) + .height(b.height) + .spacing(b.spacing) + .align_items(Alignment::Center) + .apply(button) + .padding(0) + .id(b.id) + .on_press_maybe(b.on_press.take()) + .style(b.style) + .into() + } +} diff --git a/src/widget/button/widget.rs b/src/widget/button/widget.rs new file mode 100644 index 00000000..94691f7c --- /dev/null +++ b/src/widget/button/widget.rs @@ -0,0 +1,677 @@ +// Copyright 2019 H�ctor Ram�n, Iced contributors +// Copyright 2023 System76 +// SPDX-License-Identifier: MIT + +//! Allow your users to perform actions by pressing a button. +//! +//! A [`Button`] has some local [`State`]. +use iced_runtime::core::widget::Id; +use iced_runtime::{keyboard, Command}; + +use iced_core::event::{self, Event}; +use iced_core::layout; +use iced_core::mouse; +use iced_core::overlay; +use iced_core::renderer; +use iced_core::touch; +use iced_core::widget::tree::{self, Tree}; +use iced_core::widget::Operation; +use iced_core::{ + Background, Clipboard, Color, Element, Layout, Length, Padding, Point, Rectangle, Shell, + Vector, Widget, +}; +use iced_renderer::core::widget::{operation, OperationOutputWrapper}; + +pub use super::style::{Appearance, StyleSheet}; + +/// A generic widget that produces a message when pressed. +/// +/// ```no_run +/// # type Button<'a, Message> = +/// # iced_widget::Button<'a, Message, iced_widget::renderer::Renderer>; +/// # +/// #[derive(Clone)] +/// enum Message { +/// ButtonPressed, +/// } +/// +/// let button = Button::new("Press me!").on_press(Message::ButtonPressed); +/// ``` +/// +/// If a [`Button::on_press`] handler is not set, the resulting [`Button`] will +/// be disabled: +/// +/// ``` +/// # type Button<'a, Message> = +/// # iced_widget::Button<'a, Message, iced_widget::renderer::Renderer>; +/// # +/// #[derive(Clone)] +/// enum Message { +/// ButtonPressed, +/// } +/// +/// fn disabled_button<'a>() -> Button<'a, Message> { +/// Button::new("I'm disabled!") +/// } +/// +/// fn enabled_button<'a>() -> Button<'a, Message> { +/// disabled_button().on_press(Message::ButtonPressed) +/// } +/// ``` +#[allow(missing_debug_implementations)] +#[must_use] +pub struct Button<'a, Message, Renderer> +where + Renderer: iced_core::Renderer, + Renderer::Theme: StyleSheet, +{ + id: Id, + #[cfg(feature = "a11y")] + name: Option>, + #[cfg(feature = "a11y")] + description: Option>, + #[cfg(feature = "a11y")] + label: Option>, + content: Element<'a, Message, Renderer>, + on_press: Option, + width: Length, + height: Length, + padding: Padding, + style: ::Style, +} + +impl<'a, Message, Renderer> Button<'a, Message, Renderer> +where + Renderer: iced_core::Renderer, + Renderer::Theme: StyleSheet, +{ + /// Creates a new [`Button`] with the given content. + pub fn new(content: impl Into>) -> Self { + Button { + id: Id::unique(), + #[cfg(feature = "a11y")] + name: None, + #[cfg(feature = "a11y")] + description: None, + #[cfg(feature = "a11y")] + label: None, + content: content.into(), + on_press: None, + width: Length::Shrink, + height: Length::Shrink, + padding: Padding::new(5.0), + style: ::Style::default(), + } + } + + /// Sets the width of the [`Button`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Button`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the [`Padding`] of the [`Button`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// Unless `on_press` is called, the [`Button`] will be disabled. + pub fn on_press(mut self, on_press: Message) -> Self { + self.on_press = Some(on_press); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed, + /// if `Some`. + /// + /// If `None`, the [`Button`] will be disabled. + pub fn on_press_maybe(mut self, on_press: Option) -> Self { + self.on_press = on_press; + self + } + + /// Sets the style variant of this [`Button`]. + pub fn style(mut self, style: ::Style) -> Self { + self.style = style; + self + } + + /// Sets the [`Id`] of the [`Button`]. + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } + + #[cfg(feature = "a11y")] + /// Sets the name of the [`Button`]. + pub fn name(mut self, name: impl Into>) -> Self { + self.name = Some(name.into()); + self + } + + #[cfg(feature = "a11y")] + /// Sets the description of the [`Button`]. + pub fn description_widget(mut self, description: &T) -> Self { + self.description = Some(iced_accessibility::Description::Id( + description.description(), + )); + self + } + + #[cfg(feature = "a11y")] + /// Sets the description of the [`Button`]. + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(iced_accessibility::Description::Text(description.into())); + self + } + + #[cfg(feature = "a11y")] + /// Sets the label of the [`Button`]. + pub fn label(mut self, label: &dyn iced_accessibility::Labels) -> Self { + self.label = Some(label.label().into_iter().map(|l| l.into()).collect()); + self + } +} + +impl<'a, Message, Renderer> Widget for Button<'a, Message, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + iced_core::Renderer, + Renderer::Theme: StyleSheet, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::new()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&mut self, tree: &mut Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + layout( + renderer, + limits, + self.width, + self.height, + self.padding, + |renderer, limits| self.content.as_widget().layout(renderer, limits), + ) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation>, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.content.as_widget().operate( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + operation, + ); + }); + let state = tree.state.downcast_mut::(); + operation.focusable(state, Some(&self.id)); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + if let event::Status::Captured = self.content.as_widget_mut().on_event( + &mut tree.children[0], + event.clone(), + layout.children().next().unwrap(), + cursor, + renderer, + clipboard, + shell, + viewport, + ) { + return event::Status::Captured; + } + + update( + self.id.clone(), + event, + layout, + cursor, + shell, + &self.on_press, + || tree.state.downcast_mut::(), + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let content_layout = layout.children().next().unwrap(); + + let styling = draw( + renderer, + bounds, + cursor, + self.on_press.is_some(), + theme, + &self.style, + || tree.state.downcast_ref::(), + ); + + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + &renderer::Style { + icon_color: styling.icon_color.unwrap_or(renderer_style.icon_color), + text_color: styling.text_color, + scale_factor: renderer_style.scale_factor, + }, + content_layout, + cursor, + &bounds, + ); + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + mouse_interaction(layout, cursor, self.on_press.is_some()) + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + ) + } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + p: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + use iced_accessibility::{ + accesskit::{Action, DefaultActionVerb, NodeBuilder, NodeId, Rect, Role}, + A11yNode, A11yTree, + }; + + let child_layout = layout.children().next().unwrap(); + let child_tree = &state.children[0]; + let child_tree = self + .content + .as_widget() + .a11y_nodes(child_layout, &child_tree, p); + + let Rectangle { + x, + y, + width, + height, + } = layout.bounds(); + let bounds = Rect::new(x as f64, y as f64, (x + width) as f64, (y + height) as f64); + let is_hovered = state.state.downcast_ref::().is_hovered; + + let mut node = NodeBuilder::new(Role::Button); + node.add_action(Action::Focus); + node.add_action(Action::Default); + node.set_bounds(bounds); + if let Some(name) = self.name.as_ref() { + node.set_name(name.clone()); + } + match self.description.as_ref() { + Some(iced_accessibility::Description::Id(id)) => { + node.set_described_by( + id.iter() + .cloned() + .map(|id| NodeId::from(id)) + .collect::>(), + ); + } + Some(iced_accessibility::Description::Text(text)) => { + node.set_description(text.clone()); + } + None => {} + } + + if let Some(label) = self.label.as_ref() { + node.set_labelled_by(label.clone()); + } + + if self.on_press.is_none() { + node.set_disabled() + } + if is_hovered { + node.set_hovered() + } + node.set_default_action_verb(DefaultActionVerb::Click); + + A11yTree::node_with_child_tree(A11yNode::new(node, self.id.clone()), child_tree) + } + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn set_id(&mut self, id: Id) { + self.id = id; + } +} + +impl<'a, Message, Renderer> From> for Element<'a, Message, Renderer> +where + Message: Clone + 'a, + Renderer: iced_core::Renderer + 'a, + Renderer::Theme: StyleSheet, +{ + fn from(button: Button<'a, Message, Renderer>) -> Self { + Self::new(button) + } +} + +/// The local state of a [`Button`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct State { + is_hovered: bool, + is_pressed: bool, + is_focused: bool, +} + +impl State { + /// Creates a new [`State`]. + pub fn new() -> State { + State::default() + } + + /// Returns whether the [`Button`] is currently focused or not. + pub fn is_focused(self) -> bool { + self.is_focused + } + + /// Returns whether the [`Button`] is currently hovered or not. + pub fn is_hovered(self) -> bool { + self.is_hovered + } + + /// Focuses the [`Button`]. + pub fn focus(&mut self) { + self.is_focused = true; + } + + /// Unfocuses the [`Button`]. + pub fn unfocus(&mut self) { + self.is_focused = false; + } +} + +/// Processes the given [`Event`] and updates the [`State`] of a [`Button`] +/// accordingly. +#[allow(clippy::needless_pass_by_value)] +pub fn update<'a, Message: Clone>( + _id: Id, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + shell: &mut Shell<'_, Message>, + on_press: &Option, + state: impl FnOnce() -> &'a mut State, +) -> event::Status { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if on_press.is_some() { + let bounds = layout.bounds(); + + if cursor.is_over(bounds) { + let state = state(); + + state.is_pressed = true; + + return event::Status::Captured; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) => { + if let Some(on_press) = on_press.clone() { + let state = state(); + + if state.is_pressed { + state.is_pressed = false; + + let bounds = layout.bounds(); + + if cursor.is_over(bounds) { + shell.publish(on_press); + } + + return event::Status::Captured; + } + } + } + #[cfg(feature = "a11y")] + Event::A11y(event_id, iced_accessibility::accesskit::ActionRequest { action, .. }) => { + let state = state(); + if let Some(Some(on_press)) = (id == event_id + && matches!(action, iced_accessibility::accesskit::Action::Default)) + .then(|| on_press.clone()) + { + state.is_pressed = false; + shell.publish(on_press); + } + return event::Status::Captured; + } + Event::Keyboard(keyboard::Event::KeyPressed { key_code, .. }) => { + if let Some(on_press) = on_press.clone() { + let state = state(); + if state.is_focused && key_code == keyboard::KeyCode::Enter { + state.is_pressed = true; + shell.publish(on_press); + return event::Status::Captured; + } + } + } + Event::Touch(touch::Event::FingerLost { .. }) | Event::Mouse(mouse::Event::CursorLeft) => { + let state = state(); + state.is_hovered = false; + state.is_pressed = false; + } + _ => {} + } + + event::Status::Ignored +} + +/// Draws a [`Button`]. +pub fn draw<'a, Renderer: iced_core::Renderer>( + renderer: &mut Renderer, + bounds: Rectangle, + cursor: mouse::Cursor, + is_enabled: bool, + style_sheet: &dyn StyleSheet