From d17e455e3e400c202a05dcb36a3897a41f2a5d56 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Sat, 13 Jan 2024 23:15:50 +0000 Subject: [PATCH] Add toast implementation --- shalom/src/main.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++------ shalom/src/widgets/floating_element.rs | 449 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ shalom/src/widgets/mod.rs | 2 ++ shalom/src/widgets/toast.rs | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 745 insertions(+), 6 deletions(-) create mode 100644 shalom/src/widgets/floating_element.rs create mode 100644 shalom/src/widgets/toast.rs diff --git a/shalom/src/main.rs b/shalom/src/main.rs index e723102..80531d5 100644 --- a/shalom/src/main.rs +++ b/shalom/src/main.rs @@ -11,11 +11,15 @@ mod subscriptions; mod theme; mod widgets; -use std::sync::Arc; +use std::{ + collections::BTreeMap, + sync::Arc, + time::{Duration, Instant}, +}; use iced::{ alignment::{Horizontal, Vertical}, - widget::{container, Column}, + widget::container, window, Application, Command, Element, Length, Renderer, Settings, Size, Subscription, Theme, }; @@ -23,7 +27,12 @@ use crate::{ config::Config, oracle::Oracle, theme::Image, - widgets::{context_menu::ContextMenu, spinner::CupertinoSpinner}, + widgets::{ + context_menu::ContextMenu, + floating_element::{Anchor, FloatingElement}, + spinner::CupertinoSpinner, + toast::{Toast, ToastElement}, + }, }; pub struct Shalom { @@ -33,9 +42,20 @@ pub struct Shalom { home_room: Option<&'static str>, theme: Theme, config: Option>, + toast: BTreeMap, } impl Shalom { + fn push_toast(&mut self, toast: Toast) { + let highest_key = self + .toast + .last_key_value() + .map(|(i, _)| *i) + .unwrap_or_default(); + + self.toast.insert(highest_key, toast); + } + fn build_home_route(&self) -> ActivePage { self.home_room.map_or_else( || self.build_omni_route(), @@ -95,7 +115,7 @@ impl Shalom { } } - fn handle_listen_event(&self, event: pages::room::listen::Event) -> Command { + fn handle_listen_event(&mut self, event: pages::room::listen::Event) -> Command { match event { pages::room::listen::Event::SetSpeakerVolume(id, new) => { let oracle = self.oracle.as_ref().unwrap().clone(); @@ -171,6 +191,12 @@ impl Shalom { pages::room::listen::Event::PlayTrack(id, uri) => { let oracle = self.oracle.as_ref().unwrap().clone(); + self.push_toast(Toast { + text: "Song added to queue".to_string(), + start: Instant::now(), + ttl: Duration::from_secs(5), + }); + Command::perform( async move { oracle.speaker(id).play_track(uri).await }, Message::PlayTrackResult, @@ -194,6 +220,7 @@ impl Application for Shalom { home_room: Some("living_room"), theme: Theme::default(), config: None, + toast: BTreeMap::new(), }; // this is only best-effort to try and prevent blocking when loading @@ -264,6 +291,10 @@ impl Application for Shalom { None => Command::none(), } } + (Message::ToastTtlExpired(k), _, _) => { + self.toast.remove(&k); + Command::none() + } _ => Command::none(), } } @@ -281,7 +312,19 @@ impl Application for Shalom { ActivePage::Omni(omni) => omni.view().map(Message::OmniEvent), }; - let content = Column::new().push(page_content); + let mut content = Element::from(page_content); + + for (i, (idx, toast)) in self.toast.iter().enumerate() { + let offs = f32::from(u8::try_from(i).unwrap_or(u8::MAX)); + + content = FloatingElement::new( + content, + ToastElement::new(toast).on_expiry(Message::ToastTtlExpired(*idx)), + ) + .anchor(Anchor::SouthEast) + .offset([20.0, 20.0 + (80.0 * offs)]) + .into(); + } if let Some(context_menu) = &self.context_menu { let context_menu = match context_menu { @@ -292,7 +335,7 @@ impl Application for Shalom { .on_close(Message::CloseContextMenu) .into() } else { - content.into() + content } } @@ -321,6 +364,7 @@ pub enum Message { LightControlMenu(context_menus::light_control::Message), UpdateLightResult(()), PlayTrackResult(()), + ToastTtlExpired(u8), } #[derive(Debug)] diff --git a/shalom/src/widgets/floating_element.rs b/shalom/src/widgets/floating_element.rs new file mode 100644 index 0000000..9c13d7f --- /dev/null +++ b/shalom/src/widgets/floating_element.rs @@ -0,0 +1,449 @@ +//! Adapted from + +use iced::{ + advanced, + advanced::{ + layout, + layout::{Limits, Node}, + mouse::{self, Cursor}, + overlay, renderer, + widget::{Operation, Tree}, + Clipboard, Layout, Shell, Widget, + }, + event, Element, Event, Length, Point, Rectangle, Size, Vector, +}; + +/// A floating element floating over some content. +/// +/// # Example +/// ```ignore +/// # use iced::widget::{button, Button, Column, Text}; +/// # use iced_aw::native::FloatingElement; +/// # +/// #[derive(Debug, Clone)] +/// enum Message { +/// ButtonPressed, +/// } +/// +/// let content = Column::new(); +/// let floating_element = FloatingElement::new( +/// content, +/// || Button::new(Text::new("Press Me!")) +/// .on_press(Message::ButtonPressed) +/// .into() +/// ); +/// ``` +#[allow(missing_debug_implementations)] +pub struct FloatingElement<'a, Message, Renderer = crate::Renderer> +where + Renderer: advanced::Renderer, +{ + /// The anchor of the element. + anchor: Anchor, + /// The offset of the element. + offset: Offset, + /// The visibility of the element. + hidden: bool, + /// The underlying element. + underlay: Element<'a, Message, Renderer>, + /// The floating element of the [`FloatingElementOverlay`](FloatingElementOverlay). + element: Element<'a, Message, Renderer>, +} + +impl<'a, Message, Renderer> FloatingElement<'a, Message, Renderer> +where + Renderer: advanced::Renderer, +{ + /// Creates a new [`FloatingElement`](FloatingElement) over some content, + /// showing the given [`Element`]. + /// + /// It expects: + /// * the underlay [`Element`] on which this [`FloatingElement`](FloatingElement) will be + /// wrapped around. + /// * a function that will lazily create the [`Element`] for the overlay. + pub fn new(underlay: U, element: B) -> Self + where + U: Into>, + B: Into>, + { + FloatingElement { + anchor: Anchor::SouthEast, + offset: 5.0.into(), + hidden: false, + underlay: underlay.into(), + element: element.into(), + } + } + + /// Sets the [`Anchor`](Anchor) of the [`FloatingElement`](FloatingElement). + #[must_use] + pub fn anchor(mut self, anchor: Anchor) -> Self { + self.anchor = anchor; + self + } + + /// Sets the [`Offset`](Offset) of the [`FloatingElement`](FloatingElement). + #[must_use] + pub fn offset(mut self, offset: O) -> Self + where + O: Into, + { + self.offset = offset.into(); + self + } + + /// Hide or unhide the [`Element`] on the [`FloatingElement`](FloatingElement). + #[must_use] + pub fn hide(mut self, hide: bool) -> Self { + self.hidden = hide; + self + } +} + +impl<'a, Message, Renderer> Widget for FloatingElement<'a, Message, Renderer> +where + Message: 'a, + Renderer: advanced::Renderer, +{ + fn children(&self) -> Vec { + vec![Tree::new(&self.underlay), Tree::new(&self.element)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&[&self.underlay, &self.element]); + } + + fn size(&self) -> Size { + self.underlay.as_widget().size() + } + + fn layout(&self, state: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node { + self.underlay + .as_widget() + .layout(&mut state.children[0], renderer, limits) + } + + fn on_event( + &mut self, + state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + self.underlay.as_widget_mut().on_event( + &mut state.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor: Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.underlay.as_widget().mouse_interaction( + &state.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + state: &Tree, + renderer: &mut Renderer, + theme: &Renderer::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: Cursor, + viewport: &Rectangle, + ) { + self.underlay.as_widget().draw( + &state.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn operate<'b>( + &'b self, + state: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.underlay + .as_widget() + .operate(&mut state.children[0], layout, renderer, operation); + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option> { + let [first, second] = &mut state.children[..] else { + panic!(); + }; + + let bounds = layout.bounds(); + + let mut group = overlay::Group::new(); + + if let Some(overlay) = self + .underlay + .as_widget_mut() + .overlay(first, layout, renderer) + { + group = group.push(overlay); + } + + if !self.hidden { + group = group.push(overlay::Element::new( + bounds.position(), + Box::new(FloatingElementOverlay::new( + second, + &mut self.element, + &self.anchor, + &self.offset, + bounds, + )), + )); + } + + Some(group.into()) + } +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Message: 'a, + Renderer: 'a + advanced::Renderer, +{ + fn from(floating_element: FloatingElement<'a, Message, Renderer>) -> Self { + Element::new(floating_element) + } +} + +/// The internal overlay of a [`FloatingElement`](crate::FloatingElement) for +/// rendering a [`Element`](iced_widget::core::Element) as an overlay. +#[allow(missing_debug_implementations, clippy::module_name_repetitions)] +pub struct FloatingElementOverlay<'a, 'b, Message, Renderer: advanced::Renderer> { + /// The state of the element. + state: &'b mut Tree, + /// The floating element + element: &'b mut Element<'a, Message, Renderer>, + /// The anchor of the element. + anchor: &'b Anchor, + /// The offset of the element. + offset: &'b Offset, + /// The bounds of the underlay element. + underlay_bounds: Rectangle, +} + +impl<'a, 'b, Message, Renderer> FloatingElementOverlay<'a, 'b, Message, Renderer> +where + Renderer: advanced::Renderer, +{ + /// Creates a new [`FloatingElementOverlay`] containing the given + /// [`Element`](iced_widget::core::Element). + pub fn new( + state: &'b mut Tree, + element: &'b mut Element<'a, Message, Renderer>, + anchor: &'b Anchor, + offset: &'b Offset, + underlay_bounds: Rectangle, + ) -> Self { + FloatingElementOverlay { + state, + element, + anchor, + offset, + underlay_bounds, + } + } +} + +impl<'a, 'b, Message, Renderer> advanced::Overlay + for FloatingElementOverlay<'a, 'b, Message, Renderer> +where + Renderer: advanced::Renderer, +{ + fn layout( + &mut self, + renderer: &Renderer, + _bounds: Size, + position: Point, + _translation: Vector, + ) -> layout::Node { + // Constrain overlay to fit inside the underlay's bounds + let limits = layout::Limits::new(Size::ZERO, self.underlay_bounds.size()) + .width(Length::Fill) + .height(Length::Fill); + let node = self + .element + .as_widget() + .layout(self.state, renderer, &limits); + + let position = match self.anchor { + Anchor::NorthWest => Point::new(position.x + self.offset.x, position.y + self.offset.y), + Anchor::NorthEast => Point::new( + position.x + self.underlay_bounds.width - node.bounds().width - self.offset.x, + position.y + self.offset.y, + ), + Anchor::SouthWest => Point::new( + position.x + self.offset.x, + position.y + self.underlay_bounds.height - node.bounds().height - self.offset.y, + ), + Anchor::SouthEast => Point::new( + position.x + self.underlay_bounds.width - node.bounds().width - self.offset.x, + position.y + self.underlay_bounds.height - node.bounds().height - self.offset.y, + ), + Anchor::North => Point::new( + position.x + self.underlay_bounds.width / 2.0 - node.bounds().width / 2.0 + + self.offset.x, + position.y + self.offset.y, + ), + Anchor::East => Point::new( + position.x + self.underlay_bounds.width - node.bounds().width - self.offset.x, + position.y + self.underlay_bounds.height / 2.0 - node.bounds().height / 2.0 + + self.offset.y, + ), + Anchor::South => Point::new( + position.x + self.underlay_bounds.width / 2.0 - node.bounds().width / 2.0 + + self.offset.x, + position.y + self.underlay_bounds.height - node.bounds().height - self.offset.y, + ), + Anchor::West => Point::new( + position.x + self.offset.x, + position.y + self.underlay_bounds.height / 2.0 - node.bounds().height / 2.0 + + self.offset.y, + ), + }; + + node.move_to(position) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor: Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell, + ) -> event::Status { + self.element.as_widget_mut().on_event( + self.state, + event, + layout, + cursor, + renderer, + clipboard, + shell, + &layout.bounds(), + ) + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.element + .as_widget() + .mouse_interaction(self.state, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Renderer::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: Cursor, + ) { + let bounds = layout.bounds(); + self.element + .as_widget() + .draw(self.state, renderer, theme, style, layout, cursor, &bounds); + } + + fn overlay<'c>( + &'c mut self, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option> { + self.element + .as_widget_mut() + .overlay(self.state, layout, renderer) + } +} + +#[derive(Copy, Clone, Debug, Hash)] +pub enum Anchor { + NorthWest, + NorthEast, + SouthWest, + SouthEast, + North, + East, + South, + West, +} + +#[derive(Copy, Clone, Debug)] +pub struct Offset { + pub x: f32, + pub y: f32, +} + +impl From for Offset { + fn from(float: f32) -> Self { + Self { x: float, y: float } + } +} + +impl From<[f32; 2]> for Offset { + fn from(array: [f32; 2]) -> Self { + Self { + x: array[0], + y: array[1], + } + } +} + +impl From for Point { + fn from(offset: Offset) -> Self { + Self::new(offset.x, offset.y) + } +} + +impl From<&Offset> for Point { + fn from(offset: &Offset) -> Self { + Self::new(offset.x, offset.y) + } +} diff --git a/shalom/src/widgets/mod.rs b/shalom/src/widgets/mod.rs index d463c58..8920105 100644 --- a/shalom/src/widgets/mod.rs +++ b/shalom/src/widgets/mod.rs @@ -2,11 +2,13 @@ pub mod blackhole_event; pub mod cards; pub mod colour_picker; pub mod context_menu; +pub mod floating_element; pub mod forced_rounded; pub mod image_background; pub mod image_card; pub mod media_player; pub mod room_navigation; pub mod spinner; +pub mod toast; pub mod toggle_card; pub mod track_card; diff --git a/shalom/src/widgets/toast.rs b/shalom/src/widgets/toast.rs new file mode 100644 index 0000000..47a3945 --- /dev/null +++ b/shalom/src/widgets/toast.rs @@ -0,0 +1,244 @@ +use std::time::{Duration, Instant}; + +use iced::{ + advanced::{ + graphics::text::Paragraph, + layout, + layout::{Limits, Node}, + renderer::{Quad, Style}, + text::{LineHeight, Shaping}, + widget::{tree::Tag, Tree}, + Clipboard, Layout, Renderer as RendererTrait, Shell, Widget, + }, + alignment::{Horizontal, Vertical}, + event::Status, + font::Weight, + mouse::Cursor, + widget::{text, text::Appearance}, + window, + window::RedrawRequest, + Background, Color, Element, Event, Font, Length, Rectangle, Renderer, Size, Theme, +}; +use keyframe::{functions::EaseOutQuint, keyframes, AnimationSequence}; + +use crate::theme::colours::SYSTEM_GRAY6; + +pub struct Toast { + pub text: String, + pub start: Instant, + pub ttl: Duration, +} + +#[allow(clippy::module_name_repetitions)] +pub struct ToastElement<'a, M> { + toast: &'a Toast, + on_expiry: Option, +} + +impl<'a, M: Clone> ToastElement<'a, M> { + pub fn new(toast: &'a Toast) -> Self { + Self { + toast, + on_expiry: None, + } + } + + pub fn on_expiry(mut self, msg: M) -> Self { + self.on_expiry = Some(msg); + self + } + + fn advance_closing_state(&self, shell: &mut Shell<'_, M>, state: &mut State) { + match &mut state.state { + TickerState::Closing(last_tick, v) => { + if v.finished() { + if let Some(msg) = self.on_expiry.clone() { + shell.publish(msg); + } + state.state = TickerState::Closed; + } else { + v.advance_by(last_tick.elapsed().as_secs_f64()); + *last_tick = Instant::now(); + shell.request_redraw(RedrawRequest::NextFrame); + } + } + TickerState::Ticking => { + state.state = TickerState::Closing( + Instant::now(), + keyframes![(1.0, 0.0, EaseOutQuint), (0.0, 0.5)], + ); + shell.request_redraw(RedrawRequest::NextFrame); + } + TickerState::Closed => {} + } + } +} + +impl<'a, M: Clone> Widget for ToastElement<'a, M> { + fn on_event( + &mut self, + state: &mut Tree, + event: Event, + _layout: Layout<'_>, + _cursor: Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, M>, + _viewport: &Rectangle, + ) -> Status { + if let Event::Window(_, window::Event::RedrawRequested(_)) = event { + if self.toast.start.elapsed() <= self.toast.ttl { + shell.request_redraw(RedrawRequest::NextFrame); + } else { + let state = state.state.downcast_mut::(); + self.advance_closing_state(shell, state); + } + + Status::Captured + } else { + Status::Ignored + } + } + + fn size(&self) -> Size { + Size::new(Length::Shrink, Length::Shrink) + } + + fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node { + let local_state = tree.state.downcast_mut::(); + + layout::padded( + limits, + self.size().width, + self.size().height, + [20, 20, 20, 20], + |limits| { + text::layout( + &mut local_state.content, + renderer, + limits, + Length::Shrink, + Length::Shrink, + &self.toast.text, + LineHeight::default(), + None, + Some(Font { + weight: Weight::Normal, + ..Font::with_name("Helvetica Neue") + }), + Horizontal::Center, + Vertical::Center, + Shaping::Basic, + ) + }, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _theme: &Theme, + style: &Style, + layout: Layout<'_>, + _cursor: Cursor, + viewport: &Rectangle, + ) { + let local_state = tree.state.downcast_ref::(); + + renderer.fill_quad( + Quad { + bounds: layout.bounds(), + border_radius: 20.0.into(), + border_width: 0.0, + border_color: Color::default(), + }, + Background::Color(Color { + a: 0.7 * local_state.state.alpha_mut(), + ..SYSTEM_GRAY6 + }), + ); + + let remaining_pct = (1.0 + - self.toast.start.elapsed().as_secs_f32() / self.toast.ttl.as_secs_f32()) + .max(0.0); + if remaining_pct > 0.0 { + let base = layout.bounds(); + let timeout_bounds = Rectangle { + x: base.x + 20.0, + y: base.y + base.height - 2.0, + width: (base.width - 20.0) * remaining_pct, + height: 2.0, + }; + renderer.fill_quad( + Quad { + bounds: timeout_bounds, + border_radius: 20.0.into(), + border_width: 0.0, + border_color: Color::default(), + }, + Background::Color(Color { + a: 0.7, + ..Color::WHITE + }), + ); + } + + let mut children = layout.children(); + + text::draw( + renderer, + style, + children.next().unwrap(), + &local_state.content, + Appearance { + color: Some(Color { + a: 1.0 * local_state.state.alpha_mut(), + ..Color::WHITE + }), + }, + viewport, + ); + } + + fn state(&self) -> iced::advanced::widget::tree::State { + iced::advanced::widget::tree::State::new(State::default()) + } + + fn tag(&self) -> Tag { + Tag::of::() + } +} + +#[derive(Default)] +pub struct State { + content: text::State, + state: TickerState, +} + +#[derive(Default)] +pub enum TickerState { + #[default] + Ticking, + Closing(Instant, AnimationSequence), + Closed, +} + +impl TickerState { + pub fn alpha_mut(&self) -> f32 { + match self { + Self::Ticking => 1.0, + Self::Closing(_, v) => v.now(), + Self::Closed => 0.0, + } + } +} + +impl<'a, M> From> for Element<'a, M> +where + M: 'a + Clone, +{ + fn from(modal: ToastElement<'a, M>) -> Self { + Element::new(modal) + } +} -- libgit2 1.7.2