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(-)
@@ -11,11 +11,15 @@
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 @@
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 @@
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 @@
}
}
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 @@
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 @@
home_room: Some("living_room"),
theme: Theme::default(),
config: None,
toast: BTreeMap::new(),
};
@@ -263,6 +290,10 @@
}
None => Command::none(),
}
}
(Message::ToastTtlExpired(k), _, _) => {
self.toast.remove(&k);
Command::none()
}
_ => Command::none(),
}
@@ -281,7 +312,19 @@
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 @@
.on_close(Message::CloseContextMenu)
.into()
} else {
content.into()
content
}
}
@@ -321,6 +364,7 @@
LightControlMenu(context_menus::light_control::Message),
UpdateLightResult(()),
PlayTrackResult(()),
ToastTtlExpired(u8),
}
#[derive(Debug)]
@@ -1,0 +1,449 @@
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,
};
#[allow(missing_debug_implementations)]
pub struct FloatingElement<'a, Message, Renderer = crate::Renderer>
where
Renderer: advanced::Renderer,
{
anchor: Anchor,
offset: Offset,
hidden: bool,
underlay: Element<'a, Message, Renderer>,
element: Element<'a, Message, Renderer>,
}
impl<'a, Message, Renderer> FloatingElement<'a, Message, Renderer>
where
Renderer: advanced::Renderer,
{
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(),
}
}
#[must_use]
pub fn anchor(mut self, anchor: Anchor) -> Self {
self.anchor = anchor;
self
}
#[must_use]
pub fn offset<O>(mut self, offset: O) -> Self
where
O: Into<Offset>,
{
self.offset = offset.into();
self
}
#[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)
}
}
#[allow(missing_debug_implementations, clippy::module_name_repetitions)]
pub struct FloatingElementOverlay<'a, 'b, Message, Renderer: advanced::Renderer> {
state: &'b mut Tree,
element: &'b mut Element<'a, Message, Renderer>,
anchor: &'b Anchor,
offset: &'b Offset,
underlay_bounds: Rectangle,
}
impl<'a, 'b, Message, Renderer> FloatingElementOverlay<'a, 'b, Message, Renderer>
where
Renderer: advanced::Renderer,
{
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 {
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)
}
}
@@ -1,12 +1,14 @@
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;
@@ -1,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)
}
}