🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2024-01-13 23:15:50.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2024-01-13 23:19:45.0 +00:00:00
commit
d17e455e3e400c202a05dcb36a3897a41f2a5d56 [patch]
tree
932d13c4e702b1874b3ad136ce85e14998f2e5e7
parent
56bd27e2428475c1ea025bc5b7d9cfc47e6cae82
download
d17e455e3e400c202a05dcb36a3897a41f2a5d56.tar.gz

Add toast implementation



Diff

 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(-)

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<Arc<Config>>,
    toast: BTreeMap<u8, Toast>,
}

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<Message> {
    fn handle_listen_event(&mut self, event: pages::room::listen::Event) -> Command<Message> {
        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 <https://docs.rs/iced_aw/latest/src/iced_aw/native/overlay/floating_element.rs.html>

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<U, B>(underlay: U, element: B) -> Self
    where
        U: Into<Element<'a, Message, Renderer>>,
        B: Into<Element<'a, Message, Renderer>>,
    {
        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<O>(mut self, offset: O) -> Self
    where
        O: Into<Offset>,
    {
        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<Message, Renderer> for FloatingElement<'a, Message, Renderer>
where
    Message: 'a,
    Renderer: advanced::Renderer,
{
    fn children(&self) -> Vec<Tree> {
        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<Length> {
        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<Message>,
    ) {
        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<overlay::Element<'b, Message, Renderer>> {
        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<FloatingElement<'a, Message, Renderer>>
    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<Message, Renderer>
    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<Message>,
    ) -> 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<overlay::Element<'c, Message, Renderer>> {
        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<f32> 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<Offset> 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<M>,
}

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<M, Renderer> 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::<State>();
                self.advance_closing_state(shell, state);
            }

            Status::Captured
        } else {
            Status::Ignored
        }
    }

    fn size(&self) -> Size<Length> {
        Size::new(Length::Shrink, Length::Shrink)
    }

    fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node {
        let local_state = tree.state.downcast_mut::<State>();

        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::<State>();

        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::<State>()
    }
}

#[derive(Default)]
pub struct State {
    content: text::State<Paragraph>,
    state: TickerState,
}

#[derive(Default)]
pub enum TickerState {
    #[default]
    Ticking,
    Closing(Instant, AnimationSequence<f32>),
    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<ToastElement<'a, M>> for Element<'a, M>
where
    M: 'a + Clone,
{
    fn from(modal: ToastElement<'a, M>) -> Self {
        Element::new(modal)
    }
}