🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2023-12-31 17:51:34.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-12-31 17:51:34.0 +00:00:00
commit
a729bbf6b8a35ee55451e1e327b03d856e4fd1d5 [patch]
tree
5ce32d08ca44e1079c39c31455e1115cac4a57c0
parent
05593d27b747151b0b5f11971fe4d990e3ce7055
download
a729bbf6b8a35ee55451e1e327b03d856e4fd1d5.tar.gz

Add room sidebar & blurred image background



Diff

 assets/icons/hvac.svg                  |   1 +-
 assets/icons/speaker-full.svg          |   1 +-
 assets/images/sunset-blur.jpg          |   3 +-
 shalom/src/hass_client.rs              |   2 +-
 shalom/src/main.rs                     |  79 +------------
 shalom/src/oracle.rs                   |   2 +-
 shalom/src/pages/room.rs               |  48 ++++++--
 shalom/src/theme.rs                    |   6 +-
 shalom/src/widgets/image_background.rs | 202 ++++++++++++++++++++++++++++++++-
 shalom/src/widgets/media_player.rs     |  40 +++---
 shalom/src/widgets/mod.rs              |   2 +-
 shalom/src/widgets/room_navigation.rs  | 214 ++++++++++++++++++++++++++++++++++-
 shalom/src/widgets/track_card.rs       |   8 +-
 13 files changed, 503 insertions(+), 105 deletions(-)

diff --git a/assets/icons/hvac.svg b/assets/icons/hvac.svg
new file mode 100644
index 0000000..5442b33
--- /dev/null
+++ b/assets/icons/hvac.svg
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="40" viewBox="0 -960 960 960" width="40"><path d="M480.157-253.334q94.51 0 160.509-66.156 66-66.157 66-160.667T640.51-640.666q-66.157-66-160.667-66T319.334-640.51q-66 66.157-66 160.667t66.156 160.509q66.157 66 160.667 66ZM480-320q-29 0-56-10.5T375-360h210q-22 19-49 29.5T480-320Zm-138-80q-8-14-13-29t-7-31h316q-2 16-7 31t-13 29H342Zm-20-100q2-16 7-31t13-29h276q8 14 13 29t7 31H322Zm53-100q22-19 49-29.5t56-10.5q29 0 56 10.5t49 29.5H375ZM186.666-120q-27.5 0-47.083-19.583T120-186.666v-586.668q0-27.5 19.583-47.083T186.666-840h586.668q27.5 0 47.083 19.583T840-773.334v586.668q0 27.5-19.583 47.083T773.334-120H186.666Z"/></svg>
\ No newline at end of file
diff --git a/assets/icons/speaker-full.svg b/assets/icons/speaker-full.svg
new file mode 100644
index 0000000..47f7d00
--- /dev/null
+++ b/assets/icons/speaker-full.svg
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="40" viewBox="0 -960 960 960" width="40"><path d="M693.334-80H266.666q-27.5 0-47.083-19.583T200-146.666v-666.668q0-27.5 19.583-47.083T266.666-880h426.668q27.5 0 47.083 19.583T760-813.334v666.668q0 27.5-19.583 47.083T693.334-80ZM480.667-602.667q32.333 0 54.833-22.5T558-680q0-32.333-22.5-54.833t-54.833-22.5q-32.334 0-54.834 22.5T403.333-680q0 32.333 22.5 54.833t54.834 22.5ZM480-197.333q68 0 115.333-47.334Q642.667-292 642.667-360t-47.334-115.333Q548-522.667 480-522.667t-115.333 47.334Q317.333-428 317.333-360t47.334 115.333Q412-197.333 480-197.333ZM479.953-264q-39.62 0-67.787-28.214Q384-320.428 384-360.047q0-39.62 28.214-67.787Q440.428-456 480.047-456q39.62 0 67.787 28.214Q576-399.572 576-359.953q0 39.62-28.214 67.787Q519.572-264 479.953-264Z"/></svg>
\ No newline at end of file
diff --git a/assets/images/sunset-blur.jpg b/assets/images/sunset-blur.jpg
new file mode 100644
index 0000000..9fa3195
--- /dev/null
+++ b/assets/images/sunset-blur.jpg
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdebb781c73a42e84e0884ceb1c1ee7bd460490786e7dd50658fdaf425a0f099
size 33479
diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs
index 9f8df6d..2304128 100644
--- a/shalom/src/hass_client.rs
+++ b/shalom/src/hass_client.rs
@@ -760,7 +760,7 @@ pub mod responses {
        #[serde(borrow)]
        pub dynamics: Option<Cow<'a, str>>,
        #[serde(borrow)]
        pub friendly_name: Cow<'a, str>,
        pub friendly_name: Option<Cow<'a, str>>,
        pub color_mode: Option<ColorMode>,
        pub brightness: Option<f32>,
        pub color_temp_kelvin: Option<u16>,
diff --git a/shalom/src/main.rs b/shalom/src/main.rs
index b417c5b..ef847f7 100644
--- a/shalom/src/main.rs
+++ b/shalom/src/main.rs
@@ -12,18 +12,11 @@ mod widgets;
use std::sync::Arc;

use iced::{
    alignment::{Horizontal, Vertical},
    widget::{column, container, row, scrollable, svg, Column},
    window, Application, Command, ContentFit, Element, Length, Renderer, Settings, Subscription,
    Theme,
    widget::{column, Column},
    window, Application, Command, Element, Renderer, Settings, Subscription, Theme,
};

use crate::{
    config::Config,
    oracle::Oracle,
    theme::{Icon, Image},
    widgets::{context_menu::ContextMenu, mouse_area::mouse_area},
};
use crate::{config::Config, oracle::Oracle, theme::Image, widgets::context_menu::ContextMenu};

pub struct Shalom {
    page: ActivePage,
@@ -33,14 +26,6 @@ pub struct Shalom {
}

impl Shalom {
    fn is_on_home_page(&self) -> bool {
        match (&self.page, self.home_room) {
            (ActivePage::Omni(_), None) => true,
            (ActivePage::Room(r), Some(id)) if r.room_id() == id => true,
            _ => false,
        }
    }

    fn build_home_route(&self) -> ActivePage {
        self.home_room.map_or_else(
            || self.build_omni_route(),
@@ -213,6 +198,10 @@ impl Application for Shalom {
                        Message::UpdateLightResult,
                    )
                }
                Some(pages::room::Event::Exit) => {
                    self.page = self.build_omni_route();
                    Command::none()
                }
                None => Command::none(),
            },
            (Message::LightControlMenu(e), _, Some(ActiveContextMenu::LightControl(menu))) => {
@@ -244,59 +233,7 @@ impl Application for Shalom {
            ActivePage::Omni(omni) => omni.view().map(Message::OmniEvent),
        };

        let mut content = Column::new().push(scrollable(page_content));

        let (show_back, show_home) = match &self.page {
            _ if self.is_on_home_page() => (true, false),
            ActivePage::Loading => (false, false),
            ActivePage::Omni(_) => (false, true),
            ActivePage::Room(_) => (true, true),
        };

        let back = mouse_area(
            svg(Icon::Back)
                .height(32)
                .width(32)
                .content_fit(ContentFit::None),
        )
        .on_press(Message::OpenOmniPage);
        let home = mouse_area(
            svg(Icon::Home)
                .height(32)
                .width(32)
                .content_fit(ContentFit::None),
        )
        .on_press(Message::OpenHomePage);

        let navigation = match (show_back, show_home) {
            (true, true) => Some(Element::from(
                row![
                    back,
                    container(home)
                        .width(Length::Fill)
                        .align_x(Horizontal::Right),
                ]
                .height(32),
            )),
            (false, true) => Some(Element::from(
                row![container(home)
                    .width(Length::Fill)
                    .align_x(Horizontal::Right),]
                .height(32),
            )),
            (true, false) => Some(Element::from(back)),
            (false, false) => None,
        };

        if let Some(navigation) = navigation {
            content = content.push(
                container(navigation)
                    .height(Length::Fill)
                    .width(Length::Fill)
                    .align_y(Vertical::Bottom)
                    .padding(40),
            );
        }
        let content = Column::new().push(page_content);

        if let Some(context_menu) = &self.context_menu {
            let context_menu = match context_menu {
diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs
index 409e7d5..82a719b 100644
--- a/shalom/src/oracle.rs
+++ b/shalom/src/oracle.rs
@@ -616,7 +616,7 @@ impl From<(StateLightAttributes<'_>, &str)> for Light {
            supported_color_modes: value.supported_color_modes.clone(),
            mode: value.mode.map(Cow::into_owned).map(Box::from),
            dynamics: value.dynamics.map(Cow::into_owned).map(Box::from),
            friendly_name: Box::from(value.friendly_name.as_ref()),
            friendly_name: Box::from(value.friendly_name.as_deref().unwrap_or("unknown")),
            color_mode: value.color_mode,
            brightness: value.brightness,
            color_temp_kelvin: value.color_temp_kelvin,
diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs
index 0f3f245..f3d5e69 100644
--- a/shalom/src/pages/room.rs
+++ b/shalom/src/pages/room.rs
@@ -4,9 +4,9 @@ use iced::{
    advanced::graphics::core::Element,
    font::{Stretch, Weight},
    futures::StreamExt,
    subscription,
    widget::{container, image::Handle, text, Column, Row},
    Font, Renderer, Subscription,
    subscription, theme,
    widget::{container, image::Handle, row, text, Column, Row},
    Color, Font, Length, Renderer, Subscription,
};
use url::Url;

@@ -16,7 +16,11 @@ use crate::{
    subscriptions::download_image,
    theme::Icon,
    widgets,
    widgets::colour_picker::colour_from_hsb,
    widgets::{
        colour_picker::colour_from_hsb,
        image_background::image_background,
        room_navigation::{Page, RoomNavigation},
    },
};

#[derive(Debug)]
@@ -27,6 +31,7 @@ pub struct Room {
    speaker: Option<(&'static str, MediaPlayerSpeaker)>,
    now_playing_image: Option<Handle>,
    lights: BTreeMap<&'static str, Light>,
    current_page: Page,
}

impl Room {
@@ -43,6 +48,7 @@ impl Room {
            speaker,
            now_playing_image: None,
            lights,
            current_page: Page::Listen,
        }
    }

@@ -137,15 +143,23 @@ impl Room {
                speaker.shuffle = new;
                Some(Event::SetSpeakerShuffle(id, new))
            }
            Message::ChangePage(page) => {
                self.current_page = page;
                None
            }
            Message::Exit => Some(Event::Exit),
        }
    }

    pub fn view(&self) -> Element<'_, Message, Renderer> {
        let header = text(self.room.name.as_ref()).size(60).font(Font {
            weight: Weight::Bold,
            stretch: Stretch::Condensed,
            ..Font::with_name("Helvetica Neue")
        });
        let header = text(self.room.name.as_ref())
            .size(60)
            .font(Font {
                weight: Weight::Bold,
                stretch: Stretch::Condensed,
                ..Font::with_name("Helvetica Neue")
            })
            .style(theme::Text::Color(Color::WHITE));

        let light = |id, light: &Light| {
            let mut toggle_card = widgets::toggle_card::toggle_card(
@@ -199,7 +213,18 @@ impl Room {
        .spacing(10);
        col = col.push(lights);

        col.into()
        row![
            RoomNavigation::new(self.current_page)
                .width(Length::FillPortion(2))
                .on_change(Message::ChangePage)
                .on_exit(Message::Exit),
            image_background(crate::theme::Image::Sunset, col.width(Length::Fill).into())
                .width(Length::FillPortion(15))
                .height(Length::Fill),
        ]
        .height(Length::Fill)
        .width(Length::Fill)
        .into()
    }

    pub fn subscription(&self) -> Subscription<Message> {
@@ -256,6 +281,7 @@ pub enum Event {
    SetSpeakerRepeat(&'static str, MediaPlayerRepeat),
    SpeakerNextTrack(&'static str),
    SpeakerPreviousTrack(&'static str),
    Exit,
}

#[derive(Clone, Debug)]
@@ -273,4 +299,6 @@ pub enum Message {
    OnSpeakerRepeatChange(MediaPlayerRepeat),
    OnSpeakerNextTrack,
    OnSpeakerPreviousTrack,
    ChangePage(Page),
    Exit,
}
diff --git a/shalom/src/theme.rs b/shalom/src/theme.rs
index 76b475b..e2d00f1 100644
--- a/shalom/src/theme.rs
+++ b/shalom/src/theme.rs
@@ -57,7 +57,9 @@ pub enum Icon {
    Snow,
    ClearDay,
    Wind,
    Hvac,
    Shuffle,
    SpeakerFull,
}

impl Icon {
@@ -77,6 +79,7 @@ impl Icon {
            Self::Hamburger => image!("hamburger"),
            Self::Speaker => image!("speaker"),
            Self::SpeakerMuted => image!("speaker-muted"),
            Self::SpeakerFull => image!("speaker-full"),
            Self::Backward => image!("backward"),
            Self::Forward => image!("forward"),
            Self::Play => image!("play"),
@@ -94,6 +97,7 @@ impl Icon {
            Self::Rain => image!("rain"),
            Self::Snow => image!("snow"),
            Self::ClearDay => image!("clear-day"),
            Self::Hvac => image!("hvac"),
            Self::Wind => image!("wind"),
            Self::Shuffle => image!("shuffle"),
            Self::Repeat1 => image!("repeat-1"),
@@ -114,6 +118,7 @@ pub enum Image {
    Bathroom,
    Bedroom,
    DiningRoom,
    Sunset,
}

impl Image {
@@ -137,6 +142,7 @@ impl Image {
            Image::Bathroom => image!("../../assets/images/bathroom.jpg"),
            Image::Bedroom => image!("../../assets/images/bedroom.jpg"),
            Image::DiningRoom => image!("../../assets/images/dining_room.jpg"),
            Image::Sunset => image!("../../assets/images/sunset-blur.jpg"),
        }
    }

diff --git a/shalom/src/widgets/image_background.rs b/shalom/src/widgets/image_background.rs
new file mode 100644
index 0000000..3de7cfb
--- /dev/null
+++ b/shalom/src/widgets/image_background.rs
@@ -0,0 +1,202 @@
use iced::{
    advanced::{
        image::Data,
        layout::{Limits, Node},
        overlay,
        renderer::Style,
        widget::Tree,
        Clipboard, Layout, Shell, Widget,
    },
    event::Status,
    mouse::Cursor,
    widget::image,
    Alignment, ContentFit, Element, Event, Length, Point, Rectangle, Size, Vector,
};

pub fn image_background<'a, M: 'a, R>(
    handle: impl Into<image::Handle>,
    el: Element<'a, M, R>,
) -> ImageBackground<'a, M, R> {
    let image_handle = handle.into();

    ImageBackground {
        image_handle: image_handle.clone(),
        el,
        width: Length::FillPortion(1),
        height: Length::Fixed(128.0),
    }
}

pub struct ImageBackground<'a, M, R> {
    image_handle: image::Handle,
    el: Element<'a, M, R>,
    width: Length,
    height: Length,
}

impl<'a, M, R> ImageBackground<'a, M, R> {
    pub fn height(mut self, height: Length) -> Self {
        self.height = height;
        self
    }

    pub fn width(mut self, width: Length) -> Self {
        self.width = width;
        self
    }
}

impl<
        'a,
        M: Clone,
        R: iced::advanced::Renderer
            + iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>,
    > Widget<M, R> for ImageBackground<'a, M, R>
{
    fn width(&self) -> Length {
        self.width
    }

    fn height(&self) -> Length {
        self.height
    }

    fn layout(&self, _renderer: &R, limits: &Limits) -> Node {
        let limits = limits.width(self.width).height(self.height);
        let size = limits.resolve(Size::ZERO);

        Node::new(size)
    }

    fn draw(
        &self,
        _state: &Tree,
        renderer: &mut R,
        _theme: &<R as iced::advanced::Renderer>::Theme,
        _style: &Style,
        layout: Layout<'_>,
        _cursor: Cursor,
        _viewport: &Rectangle,
    ) {
        let bounds = layout.bounds();

        // The raw w/h of the underlying image, renderer.dimensions is _really_
        // slow so enforce the use of preparsed images from `theme`.
        #[allow(clippy::cast_precision_loss)]
        let image_size = match self.image_handle.data() {
            Data::Rgba { width, height, .. } => Size::new(*width as f32, *height as f32),
            Data::Path(_) | Data::Bytes(_) => panic!("only parsed images are supported"),
        };

        let adjusted_fit = ContentFit::Cover.fit(image_size, bounds.size());

        renderer.with_layer(bounds, |renderer| {
            let offset = Vector::new(
                (bounds.width - adjusted_fit.width).min(0.0) / 1.5,
                (bounds.height - adjusted_fit.height).min(0.0) / 1.5,
            );

            let drawing_bounds = Rectangle {
                width: adjusted_fit.width,
                height: adjusted_fit.height,
                ..bounds
            };

            renderer.draw(self.image_handle.clone(), drawing_bounds + offset);
        });
    }

    fn children(&self) -> Vec<Tree> {
        vec![Tree::new(&self.el)]
    }

    fn diff(&self, tree: &mut Tree) {
        tree.diff_children(&[&self.el]);
    }

    fn overlay<'b>(
        &'b mut self,
        state: &'b mut Tree,
        layout: Layout<'_>,
        _renderer: &R,
    ) -> Option<iced::advanced::overlay::Element<'b, M, R>> {
        Some(
            overlay::Group::with_children(vec![overlay::Element::new(
                layout.position(),
                Box::new(Overlay {
                    el: &mut self.el,
                    tree: &mut state.children[0],
                    size: layout.bounds().size(),
                }),
            )])
            .overlay(),
        )
    }
}

struct Overlay<'a, 'b, M, R> {
    el: &'b mut Element<'a, M, R>,
    tree: &'b mut Tree,
    size: Size,
}

impl<'a, 'b, M: Clone, R: iced::advanced::Renderer> overlay::Overlay<M, R>
    for Overlay<'a, 'b, M, R>
{
    fn layout(&self, renderer: &R, _bounds: Size, position: Point) -> Node {
        let limits = Limits::new(Size::ZERO, self.size).pad([0, 0, 10, 0].into());

        let mut child = self.el.as_widget().layout(renderer, &limits);
        child.align(Alignment::Start, Alignment::Start, limits.max());

        let mut node = Node::with_children(self.size, vec![child]);
        node.move_to(position);

        node
    }

    fn draw(
        &self,
        renderer: &mut R,
        theme: &<R as iced::advanced::Renderer>::Theme,
        style: &Style,
        layout: Layout<'_>,
        cursor: Cursor,
    ) {
        self.el.as_widget().draw(
            self.tree,
            renderer,
            theme,
            style,
            layout.children().next().unwrap(),
            cursor,
            &layout.bounds(),
        );
    }

    fn on_event(
        &mut self,
        _event: Event,
        _layout: Layout<'_>,
        _cursor: Cursor,
        _renderer: &R,
        _clipboard: &mut dyn Clipboard,
        _shell: &mut Shell<'_, M>,
    ) -> Status {
        Status::Ignored
        // self.el.as_widget_mut().on_event(self.tree, event, layout, cursor, renderer, clipboard,
        // shell, &layout.children().next().unwrap().bounds())
    }
}

impl<'a, M, R> From<ImageBackground<'a, M, R>> for Element<'a, M, R>
where
    M: 'a + Clone,
    R: iced::advanced::Renderer
        + iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>
        + 'a,
{
    fn from(modal: ImageBackground<'a, M, R>) -> Self {
        Element::new(modal)
    }
}
diff --git a/shalom/src/widgets/media_player.rs b/shalom/src/widgets/media_player.rs
index c8be440..3a34173 100644
--- a/shalom/src/widgets/media_player.rs
+++ b/shalom/src/widgets/media_player.rs
@@ -5,18 +5,18 @@ use std::{

use iced::{
    advanced::graphics::core::Element,
    theme::{Slider, Svg, Text},
    theme::{Container, Slider, Svg, Text},
    widget::{
        column as icolumn, component, container, image::Handle, row, slider, svg, text, Component,
    },
    Alignment, Color, Length, Renderer, Theme,
    Alignment, Background, Color, Length, Renderer, Theme,
};

use crate::{
    hass_client::MediaPlayerRepeat,
    oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState},
    theme::{
        colours::{SKY_500, SLATE_400, SLATE_600},
        colours::{SKY_500, SLATE_400},
        Icon,
    },
    widgets::mouse_area::mouse_area,
@@ -274,7 +274,8 @@ impl<M: Clone> Component<M, Renderer> for MediaPlayer<M> {
        .width(self.width)
        .center_x()
        .center_y()
        // .style(Container::Custom(Box::new(Style::Inactive)))
        .style(Container::Custom(Box::new(Style::Inactive)))
        .padding(20)
        .into()
    }
}
@@ -376,19 +377,22 @@ pub enum Style {
    Inactive,
}

// impl container::StyleSheet for Style {
//     type Style = Theme;
//
//     fn appearance(&self, style: &Self::Style) -> container::Appearance {
//         container::Appearance {
//             text_color: None,
//             background: Some(Background::Color(SLATE_200)),
//             border_radius: Default::default(),
//             border_width: 0.0,
//             border_color: Default::default(),
//         }
//     }
// }
impl container::StyleSheet for Style {
    type Style = Theme;

    fn appearance(&self, _style: &Self::Style) -> container::Appearance {
        container::Appearance {
            text_color: None,
            background: Some(Background::Color(Color {
                a: 0.8,
                ..Color::BLACK
            })),
            border_radius: 10.0.into(),
            border_width: 0.,
            border_color: Color::default(),
        }
    }
}

impl svg::StyleSheet for Style {
    type Style = Theme;
@@ -396,7 +400,7 @@ impl svg::StyleSheet for Style {
    fn appearance(&self, _style: &Self::Style) -> svg::Appearance {
        let color = match self {
            Self::Active => SKY_500,
            Self::Inactive => SLATE_600,
            Self::Inactive => Color::WHITE,
        };

        svg::Appearance { color: Some(color) }
diff --git a/shalom/src/widgets/mod.rs b/shalom/src/widgets/mod.rs
index 16108ee..e04b2f8 100644
--- a/shalom/src/widgets/mod.rs
+++ b/shalom/src/widgets/mod.rs
@@ -2,8 +2,10 @@ pub mod cards;
pub mod colour_picker;
pub mod context_menu;
pub mod forced_rounded;
pub mod image_background;
pub mod image_card;
pub mod media_player;
pub mod mouse_area;
pub mod room_navigation;
pub mod toggle_card;
pub mod track_card;
diff --git a/shalom/src/widgets/room_navigation.rs b/shalom/src/widgets/room_navigation.rs
new file mode 100644
index 0000000..d71dfba
--- /dev/null
+++ b/shalom/src/widgets/room_navigation.rs
@@ -0,0 +1,214 @@
use iced::{
    advanced::graphics::core::Element,
    alignment::Vertical,
    font::{Stretch, Weight},
    theme,
    widget::{column, component, container, horizontal_rule, rule, svg, text, Component},
    Alignment, Background, Color, ContentFit, Font, Length, Renderer, Theme,
};

use super::mouse_area::mouse_area;
use crate::theme::{
    colours::{SKY_500, SLATE_200},
    Icon,
};

pub struct RoomNavigation<M> {
    _phantom: std::marker::PhantomData<M>,
    width: Length,
    current: Page,
    on_change: Option<fn(Page) -> M>,
    on_exit: Option<M>,
}

impl<M> RoomNavigation<M> {
    pub fn new(current: Page) -> Self {
        Self {
            _phantom: std::marker::PhantomData,
            width: Length::Fill,
            current,
            on_change: None,
            on_exit: None,
        }
    }

    pub fn width(mut self, width: Length) -> Self {
        self.width = width;
        self
    }

    pub fn on_change(mut self, on_change: fn(Page) -> M) -> Self {
        self.on_change = Some(on_change);
        self
    }

    pub fn on_exit(mut self, event: M) -> Self {
        self.on_exit = Some(event);
        self
    }
}

impl<M: Clone> Component<M, Renderer> for RoomNavigation<M> {
    type State = ();
    type Event = Event;

    fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<M> {
        match event {
            Event::Change(page) => self.on_change.map(|v| v(page)),
            Event::Exit => self.on_exit.clone(),
        }
    }

    fn view(&self, _state: &Self::State) -> Element<'_, Self::Event, Renderer> {
        let section = |icon: Icon, t: &'static str, state: Style, page| {
            mouse_area(
                container(
                    column![
                        svg(icon)
                            .height(Length::Fixed(64.))
                            .width(Length::Fixed(64.))
                            .style(theme::Svg::Custom(Box::new(state))),
                        text(t).size(18.).font(Font {
                            weight: Weight::Bold,
                            stretch: Stretch::Condensed,
                            ..Font::with_name("Helvetica Neue")
                        }),
                    ]
                    .width(Length::Fill)
                    .align_items(Alignment::Center)
                    .padding(12.),
                )
                .style(theme::Container::Custom(Box::new(state)))
                .width(Length::Fill),
            )
            .on_press(Event::Change(page))
        };

        let s = |p: &[Page]| {
            if p.contains(&self.current) {
                Style::Active
            } else {
                Style::Inactive
            }
        };

        let exit = container(
            mouse_area(
                svg(Icon::Back)
                    .height(32)
                    .width(32)
                    .content_fit(ContentFit::None),
            )
            .on_press(Event::Exit),
        )
        .height(Length::Fill)
        .width(Length::Fill)
        .align_y(Vertical::Bottom)
        .padding(40);

        column![
            section(Icon::Speaker, "Listen", s(&[Page::Listen]), Page::Listen),
            horizontal_rule(1).style(theme::Rule::Custom(Box::new(s(&[
                Page::Listen,
                Page::Climate
            ])))),
            section(Icon::Hvac, "Climate", s(&[Page::Climate]), Page::Climate),
            horizontal_rule(1).style(theme::Rule::Custom(Box::new(s(&[
                Page::Climate,
                Page::Lights
            ])))),
            section(Icon::Bulb, "Lights", s(&[Page::Lights]), Page::Lights),
            exit,
        ]
        .width(self.width)
        .height(Length::Fill)
        .into()
    }
}

#[derive(Copy, Clone)]
pub enum Event {
    Change(Page),
    Exit,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Page {
    Listen,
    Climate,
    Lights,
}

impl<'a, M> From<RoomNavigation<M>> for Element<'a, M, Renderer>
where
    M: 'a + Clone,
{
    fn from(card: RoomNavigation<M>) -> Self {
        component(card)
    }
}

#[derive(Copy, Clone)]
pub enum Style {
    Active,
    Inactive,
}

impl container::StyleSheet for Style {
    type Style = Theme;

    fn appearance(&self, _style: &Self::Style) -> container::Appearance {
        match self {
            Self::Active => container::Appearance {
                text_color: Some(Color::WHITE),
                background: Some(Background::Color(SKY_500)),
                border_radius: 0.0.into(),
                border_width: 0.0,
                border_color: Color::default(),
            },
            Self::Inactive => container::Appearance {
                text_color: Some(Color::BLACK),
                background: Some(Background::Color(Color::WHITE)),
                border_radius: 0.0.into(),
                border_width: 0.0,
                border_color: Color::default(),
            },
        }
    }
}

impl svg::StyleSheet for Style {
    type Style = Theme;

    fn appearance(&self, _style: &Self::Style) -> svg::Appearance {
        match self {
            Self::Active => svg::Appearance {
                color: Some(Color::WHITE),
            },
            Self::Inactive => svg::Appearance {
                color: Some(Color::BLACK),
            },
        }
    }
}

impl rule::StyleSheet for Style {
    type Style = Theme;

    fn appearance(&self, _style: &Self::Style) -> rule::Appearance {
        match self {
            Self::Active => rule::Appearance {
                color: Color::WHITE,
                width: 1,
                radius: 0.0.into(),
                fill_mode: rule::FillMode::Full,
            },
            Self::Inactive => rule::Appearance {
                color: SLATE_200,
                width: 1,
                radius: 0.0.into(),
                fill_mode: rule::FillMode::Full,
            },
        }
    }
}
diff --git a/shalom/src/widgets/track_card.rs b/shalom/src/widgets/track_card.rs
index fcda827..d186b97 100644
--- a/shalom/src/widgets/track_card.rs
+++ b/shalom/src/widgets/track_card.rs
@@ -6,10 +6,10 @@ use iced::{
        image::{self, Image},
        row, text, vertical_space, Component,
    },
    Alignment, Background, Renderer, Theme,
    Alignment, Background, Color, Renderer, Theme,
};

use crate::theme::colours::SLATE_400;
use crate::theme::colours::{SLATE_200, SLATE_400};

pub fn track_card(artist: String, song: String, image: Option<image::Handle>) -> TrackCard {
    TrackCard {
@@ -40,7 +40,7 @@ impl<M> Component<M, Renderer> for TrackCard {
            } else {
                Element::from(container(vertical_space(0)).width(64).height(64).style(
                    |_t: &Theme| container::Appearance {
                        background: Some(Background::Color(SLATE_400)),
                        background: Some(Background::Color(SLATE_200)),
                        ..container::Appearance::default()
                    },
                ))
@@ -49,7 +49,7 @@ impl<M> Component<M, Renderer> for TrackCard {
        row![
            image,
            icolumn![
                text(&self.song).size(14),
                text(&self.song).size(14).style(Text::Color(Color::WHITE)),
                text(&self.artist).style(Text::Color(SLATE_400)).size(14)
            ]
        ]