🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2023-11-02 1:35:23.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-11-02 1:35:23.0 +00:00:00
commit
ef05d0776e5b0e1afe447056dffa3e79e9470df9 [patch]
tree
d5c5268c6ff9e4bfe4ab5019e208ab006d8f8dd8
parent
a395bfe61ae59e7003427436f852cb146c7a738f
download
ef05d0776e5b0e1afe447056dffa3e79e9470df9.tar.gz

Implement HSB picker for lighting



Diff

 Cargo.lock                                |  63 +++++-
 shalom/Cargo.toml                         |   2 +-
 shalom/src/context_menus/light_control.rs |  68 +++++-
 shalom/src/context_menus/mod.rs           |   1 +-
 shalom/src/hass_client.rs                 |  26 +-
 shalom/src/main.rs                        |  44 +---
 shalom/src/oracle.rs                      | 102 ++++++--
 shalom/src/pages/room.rs                  |  24 +-
 shalom/src/widgets/colour_picker.rs       | 384 +++++++++++++++++++++++++++++++-
 shalom/src/widgets/mod.rs                 |   1 +-
 shalom/src/widgets/toggle_card.rs         |  10 +-
 11 files changed, 661 insertions(+), 64 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 40c7dc4..fb53a26 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -688,6 +688,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"

[[package]]
name = "float_next_after"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fc612c5837986b7104a87a0df74a5460931f1c5274be12f8d0f40aa2f30d632"
dependencies = [
 "num-traits",
]

[[package]]
name = "flume"
version = "0.10.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1194,6 +1203,7 @@ dependencies = [
 "image",
 "kamadak-exif",
 "log",
 "lyon_path",
 "raw-window-handle",
 "thiserror",
]
@@ -1267,6 +1277,7 @@ dependencies = [
 "guillotiere",
 "iced_graphics",
 "log",
 "lyon",
 "once_cell",
 "raw-window-handle",
 "resvg",
@@ -1568,6 +1579,58 @@ dependencies = [
]

[[package]]
name = "lyon"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7f9cda98b5430809e63ca5197b06c7d191bf7e26dfc467d5a3f0290e2a74f"
dependencies = [
 "lyon_algorithms",
 "lyon_tessellation",
]

[[package]]
name = "lyon_algorithms"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00a0349cd8f0270781bb93a824b63df6178e3b4a27794e7be3ce3763f5a44d6e"
dependencies = [
 "lyon_path",
 "num-traits",
]

[[package]]
name = "lyon_geom"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74df1ff0a0147282eb10699537a03baa7d31972b58984a1d44ce0624043fe8ad"
dependencies = [
 "arrayvec",
 "euclid",
 "num-traits",
]

[[package]]
name = "lyon_path"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca507745ba7ccbc76e5c44e7b63b1a29d2b0d6126f375806a5bbaf657c7d6c45"
dependencies = [
 "lyon_geom",
 "num-traits",
]

[[package]]
name = "lyon_tessellation"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d2124218d5428149f9e09520b9acc024334a607e671f032d06567b61008977c"
dependencies = [
 "float_next_after",
 "lyon_path",
 "thiserror",
]

[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/shalom/Cargo.toml b/shalom/Cargo.toml
index 41c58ff..51d1762 100644
--- a/shalom/Cargo.toml
+++ b/shalom/Cargo.toml
@@ -6,7 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
iced = { version = "0.10", features = ["tokio", "svg", "lazy", "advanced", "image"] }
iced = { version = "0.10", features = ["tokio", "svg", "lazy", "advanced", "image", "canvas"] }
image = "0.24"
once_cell = "1.18"
internment = "0.7.4"
diff --git a/shalom/src/context_menus/light_control.rs b/shalom/src/context_menus/light_control.rs
new file mode 100644
index 0000000..61f4689
--- /dev/null
+++ b/shalom/src/context_menus/light_control.rs
@@ -0,0 +1,68 @@
use iced::{
    font::{Stretch, Weight},
    widget::{column, container, row, text},
    Alignment, Element, Font, Length, Renderer,
};

use crate::widgets::colour_picker::ColourPicker;

#[derive(Debug, Clone)]
pub struct LightControl {
    id: &'static str,
    hue: f32,
    saturation: f32,
    brightness: f32,
}

impl LightControl {
    pub fn new(id: &'static str) -> Self {
        Self {
            id,
            hue: 0.0,
            saturation: 0.0,
            brightness: 0.0,
        }
    }

    #[allow(clippy::needless_pass_by_value)]
    pub fn update(&mut self, event: Message) -> Option<Event> {
        match event {
            Message::OnColourChange(hue, saturation, brightness) => {
                self.hue = hue;
                self.saturation = saturation;
                self.brightness = brightness;
                None
            }
        }
    }

    pub fn view(&self) -> Element<'_, Message, Renderer> {
        let colour_picker = ColourPicker::new(
            self.hue,
            self.saturation,
            self.brightness,
            Message::OnColourChange,
        );

        container(column![
            text(self.id).size(40).font(Font {
                weight: Weight::Bold,
                stretch: Stretch::Condensed,
                ..Font::with_name("Helvetica Neue")
            }),
            row![colour_picker,]
                .align_items(Alignment::Center)
                .spacing(20)
        ])
        .width(Length::Fill)
        .padding(40)
        .into()
    }
}

pub enum Event {}

#[derive(Clone, Debug)]
pub enum Message {
    OnColourChange(f32, f32, f32),
}
diff --git a/shalom/src/context_menus/mod.rs b/shalom/src/context_menus/mod.rs
new file mode 100644
index 0000000..4bd50f9
--- /dev/null
+++ b/shalom/src/context_menus/mod.rs
@@ -0,0 +1 @@
pub mod light_control;
diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs
index bfab17a..08ecfb2 100644
--- a/shalom/src/hass_client.rs
+++ b/shalom/src/hass_client.rs
@@ -619,23 +619,23 @@ pub mod responses {

    #[derive(Deserialize, Debug, Clone)]
    pub struct StateLightAttributes<'a> {
        min_color_temp_kelvin: Option<u16>,
        max_color_temp_kelvin: Option<u16>,
        min_mireds: Option<u16>,
        max_mireds: Option<u16>,
        pub min_color_temp_kelvin: Option<u16>,
        pub max_color_temp_kelvin: Option<u16>,
        pub min_mireds: Option<u16>,
        pub max_mireds: Option<u16>,
        #[serde(default)]
        supported_color_modes: Vec<ColorMode>,
        pub supported_color_modes: Vec<ColorMode>,
        #[serde(borrow)]
        mode: Option<Cow<'a, str>>,
        pub mode: Option<Cow<'a, str>>,
        #[serde(borrow)]
        dynamics: Option<Cow<'a, str>>,
        pub dynamics: Option<Cow<'a, str>>,
        #[serde(borrow)]
        friendly_name: Cow<'a, str>,
        color_mode: Option<ColorMode>,
        brightness: Option<f32>,
        color_temp_kelvin: Option<u16>,
        color_temp: Option<u16>,
        xy_color: Option<(f32, f32)>,
        pub friendly_name: Cow<'a, str>,
        pub color_mode: Option<ColorMode>,
        pub brightness: Option<f32>,
        pub color_temp_kelvin: Option<u16>,
        pub color_temp: Option<u16>,
        pub xy_color: Option<(f32, f32)>,
    }

    #[derive(Deserialize, Debug, Clone, Copy)]
diff --git a/shalom/src/main.rs b/shalom/src/main.rs
index 1086acf..6bf0aee 100644
--- a/shalom/src/main.rs
+++ b/shalom/src/main.rs
@@ -1,6 +1,7 @@
#![deny(clippy::pedantic)]

mod config;
mod context_menus;
mod hass_client;
mod oracle;
mod pages;
@@ -12,10 +13,9 @@ use std::sync::Arc;

use iced::{
    alignment::{Horizontal, Vertical},
    font::{Stretch, Weight},
    widget::{column, container, row, scrollable, svg, text, vertical_slider, Column},
    window, Alignment, Application, Command, ContentFit, Element, Font, Length, Renderer, Settings,
    Subscription, Theme,
    widget::{column, container, row, scrollable, svg, Column},
    window, Application, Command, ContentFit, Element, Length, Renderer, Settings, Subscription,
    Theme,
};

use crate::{
@@ -66,21 +66,21 @@ impl Application for Shalom {

    fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
        #[allow(clippy::single_match)]
        match (message, &mut self.page) {
            (Message::Loaded(oracle), _) => {
        match (message, &mut self.page, &mut self.context_menu) {
            (Message::Loaded(oracle), _, _) => {
                self.oracle = Some(oracle);
                self.page = ActivePage::Room(pages::room::Room::new(
                    "living_room",
                    self.oracle.clone().unwrap(),
                ));
            }
            (Message::CloseContextMenu, _) => {
            (Message::CloseContextMenu, _, _) => {
                self.context_menu = None;
            }
            (Message::OpenOmniPage, _) => {
            (Message::OpenOmniPage, _, _) => {
                self.page = ActivePage::Omni(pages::omni::Omni::new(self.oracle.clone().unwrap()));
            }
            (Message::OmniEvent(e), ActivePage::Omni(r)) => match r.update(e) {
            (Message::OmniEvent(e), ActivePage::Omni(r), _) => match r.update(e) {
                Some(pages::omni::Event::OpenRoom(room)) => {
                    self.page = ActivePage::Room(pages::room::Room::new(
                        room,
@@ -89,12 +89,19 @@ impl Application for Shalom {
                }
                None => {}
            },
            (Message::RoomEvent(e), ActivePage::Room(r)) => match r.update(e) {
            (Message::RoomEvent(e), ActivePage::Room(r), _) => match r.update(e) {
                Some(pages::room::Event::OpenLightContextMenu(light)) => {
                    self.context_menu = Some(ActiveContextMenu::LightOptions(light));
                    self.context_menu = Some(ActiveContextMenu::LightControl(
                        context_menus::light_control::LightControl::new(light),
                    ));
                }
                None => {}
            },
            (Message::LightControlMenu(e), _, Some(ActiveContextMenu::LightControl(menu))) => {
                match menu.update(e) {
                    Some(_) | None => {}
                }
            }
            _ => {}
        }

@@ -164,17 +171,7 @@ impl Application for Shalom {

        if let Some(context_menu) = &self.context_menu {
            let context_menu = match context_menu {
                ActiveContextMenu::LightOptions(name) => container(column![
                    text(name).size(40).font(Font {
                        weight: Weight::Bold,
                        stretch: Stretch::Condensed,
                        ..Font::with_name("Helvetica Neue")
                    }),
                    row![vertical_slider(0..=100, 0, |_v| Message::CloseContextMenu).height(200)]
                        .align_items(Alignment::Center)
                ])
                .width(Length::Fill)
                .padding(40),
                ActiveContextMenu::LightControl(menu) => menu.view().map(Message::LightControlMenu),
            };

            ContextMenu::new(content, context_menu)
@@ -206,6 +203,7 @@ pub enum Message {
    OpenOmniPage,
    OmniEvent(pages::omni::Message),
    RoomEvent(pages::room::Message),
    LightControlMenu(context_menus::light_control::Message),
}

#[derive(Debug)]
@@ -218,7 +216,7 @@ pub enum ActivePage {

#[derive(Clone, Debug)]
pub enum ActiveContextMenu {
    LightOptions(&'static str),
    LightControl(context_menus::light_control::LightControl),
}

fn main() {
diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs
index 50369c4..bb16b0c 100644
--- a/shalom/src/oracle.rs
+++ b/shalom/src/oracle.rs
@@ -1,5 +1,6 @@
use std::{
    collections::{BTreeMap, HashMap},
    borrow::Cow,
    collections::{BTreeMap, BTreeSet, HashMap},
    str::FromStr,
    sync::{Arc, Mutex},
    time::Duration,
@@ -14,8 +15,9 @@ use url::Url;

use crate::hass_client::{
    responses::{
        Area, AreaRegistryList, DeviceRegistryList, Entity, EntityRegistryList, StateAttributes,
        StateMediaPlayerAttributes, StateWeatherAttributes, StatesList, WeatherCondition,
        Area, AreaRegistryList, ColorMode, DeviceRegistryList, Entity, EntityRegistryList,
        StateAttributes, StateLightAttributes, StateMediaPlayerAttributes, StateWeatherAttributes,
        StatesList, WeatherCondition,
    },
    Event, HassRequestKind,
};
@@ -25,8 +27,9 @@ use crate::hass_client::{
pub struct Oracle {
    client: crate::hass_client::Client,
    rooms: BTreeMap<&'static str, Room>,
    pub weather: Mutex<Weather>,
    pub media_players: Mutex<BTreeMap<&'static str, MediaPlayer>>,
    weather: Mutex<Weather>,
    media_players: Mutex<BTreeMap<&'static str, MediaPlayer>>,
    lights: Mutex<BTreeMap<&'static str, Light>>,
    entity_updates: broadcast::Sender<Arc<str>>,
}

@@ -61,18 +64,26 @@ impl Oracle {

        eprintln!("{rooms:#?}");

        let media_players = states
            .0
            .iter()
            .filter_map(|state| {
                if let StateAttributes::MediaPlayer(attr) = &state.attributes {
                    let kind = MediaPlayer::new(attr, &hass_client.base);
                    Some((Intern::<str>::from(state.entity_id.as_ref()).as_ref(), kind))
                } else {
                    None
        let mut media_players = BTreeMap::new();
        let mut lights = BTreeMap::new();

        for state in &states.0 {
            match &state.attributes {
                StateAttributes::MediaPlayer(attr) => {
                    media_players.insert(
                        Intern::<str>::from(state.entity_id.as_ref()).as_ref(),
                        MediaPlayer::new(attr, &hass_client.base),
                    );
                }
            })
            .collect();
                StateAttributes::Light(attr) => {
                    lights.insert(
                        Intern::<str>::from(state.entity_id.as_ref()).as_ref(),
                        Light::from(attr.clone()),
                    );
                }
                _ => {}
            }
        }

        let (entity_updates, _) = broadcast::channel(10);

@@ -81,6 +92,7 @@ impl Oracle {
            rooms,
            weather: Mutex::new(Weather::parse_from_states(states)),
            media_players: Mutex::new(media_players),
            lights: Mutex::new(lights),
            entity_updates: entity_updates.clone(),
        });

@@ -178,11 +190,18 @@ fn build_room(
        .find(|v| v.starts_with("media_player."))
        .copied();

    let lights = entities
        .iter()
        .filter(|v| v.starts_with("light."))
        .copied()
        .collect();

    let area = Intern::<str>::from(room.area_id.as_ref()).as_ref();
    let room = Room {
        name: Intern::from(room.name.as_ref()),
        entities,
        speaker_id,
        lights,
    };

    (area, room)
@@ -221,6 +240,43 @@ impl MediaPlayer {
}

#[derive(Debug, Clone)]
pub struct Light {
    pub min_color_temp_kelvin: Option<u16>,
    pub max_color_temp_kelvin: Option<u16>,
    pub min_mireds: Option<u16>,
    pub max_mireds: Option<u16>,
    pub supported_color_modes: Vec<ColorMode>,
    pub mode: Option<Box<str>>,
    pub dynamics: Option<Box<str>>,
    pub friendly_name: Box<str>,
    pub color_mode: Option<ColorMode>,
    pub brightness: Option<f32>,
    pub color_temp_kelvin: Option<u16>,
    pub color_temp: Option<u16>,
    pub xy_color: Option<(f32, f32)>,
}

impl From<StateLightAttributes<'_>> for Light {
    fn from(value: StateLightAttributes<'_>) -> Self {
        Self {
            min_color_temp_kelvin: value.min_color_temp_kelvin,
            max_color_temp_kelvin: value.max_color_temp_kelvin,
            min_mireds: value.min_mireds,
            max_mireds: value.max_mireds,
            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()),
            color_mode: value.color_mode,
            brightness: value.brightness,
            color_temp_kelvin: value.color_temp_kelvin,
            color_temp: value.color_temp,
            xy_color: value.xy_color,
        }
    }
}

#[derive(Debug, Clone)]
pub struct MediaPlayerSpeaker {
    pub volume: f32,
    pub muted: bool,
@@ -243,6 +299,7 @@ pub struct Room {
    pub name: Intern<str>,
    pub entities: Vec<Intern<str>>,
    pub speaker_id: Option<Intern<str>>,
    pub lights: BTreeSet<Intern<str>>,
}

impl Room {
@@ -259,6 +316,19 @@ impl Room {
            MediaPlayer::Tv(_) => None,
        }
    }

    pub fn light_names(&self, oracle: &Oracle) -> BTreeMap<&'static str, Box<str>> {
        let lights = oracle.lights.lock().unwrap();

        self.lights
            .iter()
            .filter_map(|v| Some((*v).as_ref()).zip(lights.get(v.as_ref())))
            .map(|(id, light)| {
                eprintln!("{light:?}");
                (id, light.friendly_name.clone())
            })
            .collect()
    }
}

#[derive(Debug, Copy, Clone)]
diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs
index 79995c1..9052cb4 100644
--- a/shalom/src/pages/room.rs
+++ b/shalom/src/pages/room.rs
@@ -1,11 +1,11 @@
use std::sync::Arc;
use std::{collections::BTreeMap, sync::Arc};

use iced::{
    advanced::graphics::core::Element,
    font::{Stretch, Weight},
    futures::StreamExt,
    subscription,
    widget::{container, image::Handle, row, text, Column},
    widget::{container, image::Handle, text, Column, Row},
    Font, Renderer, Subscription,
};
use internment::Intern;
@@ -24,6 +24,7 @@ pub struct Room {
    room: crate::oracle::Room,
    speaker: Option<MediaPlayerSpeaker>,
    now_playing_image: Option<Handle>,
    lights: BTreeMap<&'static str, Box<str>>,
}

impl Room {
@@ -31,11 +32,14 @@ impl Room {
        let room = oracle.room(id).clone();
        let speaker = room.speaker(&oracle);

        let lights = room.light_names(&oracle);

        Self {
            oracle,
            room,
            speaker,
            now_playing_image: None,
            lights,
        }
    }

@@ -98,11 +102,11 @@ impl Room {
            ..Font::with_name("Helvetica Neue")
        });

        let light = |name| {
        let light = |id, name| {
            widgets::toggle_card::toggle_card(name, false)
                .icon(Icon::Bulb)
                .on_press(Message::LightToggle(name))
                .on_long_press(Message::OpenLightOptions(name))
                .on_press(Message::LightToggle(id))
                .on_long_press(Message::OpenLightOptions(id))
        };

        let mut col = Column::new().spacing(20).padding(40).push(header);
@@ -117,7 +121,15 @@ impl Room {
            );
        }

        col = col.push(row![light("Main"), light("Lamp"), light("TV")].spacing(10));
        let lights = Row::with_children(
            self.lights
                .iter()
                .map(|(id, name)| light(*id, name))
                .map(Element::from)
                .collect::<Vec<_>>(),
        )
        .spacing(10);
        col = col.push(lights);

        col.into()
    }
diff --git a/shalom/src/widgets/colour_picker.rs b/shalom/src/widgets/colour_picker.rs
new file mode 100644
index 0000000..cb4579a
--- /dev/null
+++ b/shalom/src/widgets/colour_picker.rs
@@ -0,0 +1,384 @@
use iced::{
    advanced::graphics::core::Element,
    event::Status,
    mouse,
    mouse::{Button, Cursor},
    touch,
    widget::{
        canvas,
        canvas::{Cache, Event, Frame, Geometry, Path, Stroke, Style},
        component, Column, Component,
    },
    Color, Point, Rectangle, Renderer, Size, Theme,
};

pub struct ColourPicker<Event> {
    hue: f32,
    saturation: f32,
    brightness: f32,
    on_change: fn(f32, f32, f32) -> Event,
}

impl<Event> ColourPicker<Event> {
    pub fn new(
        hue: f32,
        saturation: f32,
        brightness: f32,
        on_change: fn(f32, f32, f32) -> Event,
    ) -> Self {
        Self {
            hue,
            saturation,
            brightness,
            on_change,
        }
    }
}

impl<Event> Component<Event, Renderer> for ColourPicker<Event> {
    type State = ();
    type Event = Message;

    fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<Event> {
        match event {
            Message::OnSaturationBrightnessChange(saturation, brightness) => {
                Some((self.on_change)(self.hue, saturation, brightness))
            }
            Message::OnHueChanged(hue) => {
                Some((self.on_change)(hue, self.saturation, self.brightness))
            }
        }
    }

    fn view(&self, _state: &Self::State) -> Element<'_, Self::Event, Renderer> {
        let saturation_brightness_picker = canvas(SaturationBrightnessPicker::new(
            self.hue,
            self.saturation,
            self.brightness,
            Message::OnSaturationBrightnessChange,
        ))
        .height(192)
        .width(192);

        let hue_slider = canvas(HueSlider::new(self.hue, Message::OnHueChanged))
            .height(24)
            .width(192);

        Column::new()
            .push(saturation_brightness_picker)
            .push(hue_slider)
            .spacing(4)
            .into()
    }
}

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

#[derive(Clone)]
pub enum Message {
    OnSaturationBrightnessChange(f32, f32),
    OnHueChanged(f32),
}

pub struct HueSlider<Message> {
    hue: f32,
    on_hue_change: fn(f32) -> Message,
}

impl<Message> HueSlider<Message> {
    fn new(hue: f32, on_hue_change: fn(f32) -> Message) -> Self {
        Self { hue, on_hue_change }
    }
}

impl<Message> canvas::Program<Message> for HueSlider<Message> {
    type State = HueSliderState;

    fn update(
        &self,
        state: &mut Self::State,
        event: Event,
        bounds: Rectangle,
        cursor: Cursor,
    ) -> (Status, Option<Message>) {
        let update = match event {
            Event::Mouse(mouse::Event::ButtonPressed(Button::Left))
            | Event::Touch(touch::Event::FingerPressed { .. })
                if cursor.is_over(bounds) =>
            {
                state.is_dragging = true;
                true
            }
            Event::Mouse(mouse::Event::ButtonReleased(Button::Left))
            | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => {
                state.is_dragging = false;
                false
            }
            Event::Mouse(mouse::Event::CursorMoved { .. })
            | Event::Touch(touch::Event::FingerMoved { .. })
                if state.is_dragging =>
            {
                true
            }
            _ => false,
        };

        if update {
            if let Some(position) = cursor.position_in(bounds) {
                state.arrow_cache.clear();

                let hue = (position.x / bounds.width) * 360.;
                (Status::Captured, Some((self.on_hue_change)(hue)))
            } else {
                (Status::Captured, None)
            }
        } else {
            (Status::Ignored, None)
        }
    }

    fn draw(
        &self,
        state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: Cursor,
    ) -> Vec<Geometry> {
        // Draw the hue gradient
        let content = state
            .preview_cache
            .draw(renderer, bounds.size(), |frame: &mut Frame| {
                let size = frame.size();

                #[allow(
                    clippy::cast_possible_truncation,
                    clippy::cast_sign_loss,
                    clippy::cast_precision_loss
                )]
                for x in 0..size.width as u32 {
                    let hue = (x as f32 / size.width) * 360.0;
                    let color = colour_from_hsb(hue, 1.0, 1.0);
                    frame.fill_rectangle(
                        Point::new(x as f32, 0.0),
                        Size::new(1.0, size.height),
                        color,
                    );
                }
            });

        // Draw the user's selection on the gradient
        let arrow = state
            .arrow_cache
            .draw(renderer, bounds.size(), |frame: &mut Frame| {
                let size = frame.size();

                let arrow_width = 10.0;
                let arrow_height = 10.0;
                let arrow_x = (self.hue / 360.0) * size.width - (arrow_width / 2.0);
                let arrow_y = size.height - arrow_height;

                let arrow = Path::new(|p| {
                    p.move_to(Point::new(arrow_x, arrow_y));
                    p.line_to(Point::new(arrow_x + arrow_width, arrow_y));
                    p.line_to(Point::new(
                        arrow_x + (arrow_width / 2.0),
                        arrow_y + arrow_height,
                    ));
                    p.line_to(Point::new(arrow_x, arrow_y));
                    p.close();
                });

                frame.fill(&arrow, Color::BLACK);
            });

        vec![content, arrow]
    }
}

#[derive(Default)]
pub struct HueSliderState {
    is_dragging: bool,
    preview_cache: Cache,
    arrow_cache: Cache,
}

pub struct SaturationBrightnessPicker<Message> {
    hue: f32,
    saturation: f32,
    brightness: f32,
    on_change: fn(f32, f32) -> Message,
}

impl<Message> SaturationBrightnessPicker<Message> {
    pub fn new(
        hue: f32,
        saturation: f32,
        brightness: f32,
        on_change: fn(f32, f32) -> Message,
    ) -> Self {
        Self {
            hue,
            saturation,
            brightness,
            on_change,
        }
    }
}

impl<Message> canvas::Program<Message> for SaturationBrightnessPicker<Message> {
    type State = SaturationBrightnessPickerState;

    fn update(
        &self,
        state: &mut Self::State,
        event: Event,
        bounds: Rectangle,
        cursor: Cursor,
    ) -> (Status, Option<Message>) {
        // copy hue from self to state to figure out if the box needs to be
        // rerendered
        #[allow(clippy::float_cmp)]
        if self.hue != state.hue {
            state.hue = self.hue;
            state.content_cache.clear();
        }

        let update = match event {
            Event::Mouse(mouse::Event::ButtonPressed(Button::Left))
            | Event::Touch(touch::Event::FingerPressed { .. })
                if cursor.is_over(bounds) =>
            {
                state.is_dragging = true;
                true
            }
            Event::Mouse(mouse::Event::ButtonReleased(Button::Left))
            | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => {
                state.is_dragging = false;
                false
            }
            Event::Mouse(mouse::Event::CursorMoved { .. })
            | Event::Touch(touch::Event::FingerMoved { .. })
                if state.is_dragging =>
            {
                true
            }
            _ => false,
        };

        if update {
            if let Some(position) = cursor.position_in(bounds) {
                state.circle_cache.clear();

                let saturation = position.x / bounds.width;
                let brightness = 1.0 - (position.y / bounds.height);

                (
                    Status::Captured,
                    Some((self.on_change)(saturation, brightness)),
                )
            } else {
                (Status::Ignored, None)
            }
        } else {
            (Status::Ignored, None)
        }
    }

    fn draw(
        &self,
        state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: Cursor,
    ) -> Vec<Geometry> {
        // Draw the saturation-brightness box
        let content = state
            .content_cache
            .draw(renderer, bounds.size(), |frame: &mut Frame| {
                let size = frame.size();

                #[allow(
                    clippy::cast_possible_truncation,
                    clippy::cast_sign_loss,
                    clippy::cast_precision_loss
                )]
                for x in 0..size.width as u32 {
                    for y in 0..size.height as u32 {
                        let saturation = x as f32 / size.width;
                        let brightness = 1.0 - (y as f32 / size.height);
                        let color = colour_from_hsb(self.hue, saturation, brightness);

                        frame.fill_rectangle(
                            Point::new(x as f32, y as f32),
                            Size::new(1.0, 1.0),
                            color,
                        );
                    }
                }
            });

        // Draw the user's selection on the box
        let circle = state
            .circle_cache
            .draw(renderer, bounds.size(), |frame: &mut Frame| {
                let size = frame.size();

                let circle_x = self.saturation * size.width;
                let circle_y = (1.0 - self.brightness) * size.height;
                let circle_radius = 5.0;

                let circle = Path::circle(Point::new(circle_x, circle_y), circle_radius);

                frame.stroke(
                    &circle,
                    Stroke {
                        style: Style::Solid(Color::BLACK),
                        width: 1.,
                        ..Stroke::default()
                    },
                );
            });

        vec![content, circle]
    }
}

#[derive(Default)]
pub struct SaturationBrightnessPickerState {
    is_dragging: bool,
    content_cache: Cache,
    circle_cache: Cache,
    hue: f32,
}

fn colour_from_hsb(hue: f32, saturation: f32, brightness: f32) -> Color {
    let chroma = brightness * saturation;
    let hue_prime = hue / 60.0;
    let second_largest_component = chroma * (1.0 - (hue_prime % 2.0 - 1.0).abs());
    let match_value = brightness - chroma;

    let (red, green, blue) = if hue < 60.0 {
        (chroma, second_largest_component, 0.0)
    } else if hue < 120.0 {
        (second_largest_component, chroma, 0.0)
    } else if hue < 180.0 {
        (0.0, chroma, second_largest_component)
    } else if hue < 240.0 {
        (0.0, second_largest_component, chroma)
    } else if hue < 300.0 {
        (second_largest_component, 0.0, chroma)
    } else {
        (chroma, 0.0, second_largest_component)
    };

    Color::from_rgb(red + match_value, green + match_value, blue + match_value)
}
diff --git a/shalom/src/widgets/mod.rs b/shalom/src/widgets/mod.rs
index e2450e3..44f9171 100644
--- a/shalom/src/widgets/mod.rs
+++ b/shalom/src/widgets/mod.rs
@@ -1,4 +1,5 @@
pub mod cards;
pub mod colour_picker;
pub mod context_menu;
pub mod image_card;
pub mod media_player;
diff --git a/shalom/src/widgets/toggle_card.rs b/shalom/src/widgets/toggle_card.rs
index 930d402..2cacfb0 100644
--- a/shalom/src/widgets/toggle_card.rs
+++ b/shalom/src/widgets/toggle_card.rs
@@ -20,9 +20,9 @@ use crate::{

pub const LONG_PRESS_LENGTH: Duration = Duration::from_millis(350);

pub fn toggle_card<M>(name: &'static str, active: bool) -> ToggleCard<M> {
pub fn toggle_card<M>(name: &str, active: bool) -> ToggleCard<M> {
    ToggleCard {
        name,
        name: Box::from(name),
        active,
        ..ToggleCard::default()
    }
@@ -30,7 +30,7 @@ pub fn toggle_card<M>(name: &'static str, active: bool) -> ToggleCard<M> {

pub struct ToggleCard<M> {
    icon: Option<Icon>,
    name: &'static str,
    name: Box<str>,
    height: Length,
    width: Length,
    active: bool,
@@ -42,7 +42,7 @@ impl<M> Default for ToggleCard<M> {
    fn default() -> Self {
        Self {
            icon: None,
            name: "",
            name: Box::from(""),
            height: Length::Shrink,
            width: Length::Fill,
            active: false,
@@ -125,7 +125,7 @@ impl<M: Clone> iced::widget::Component<M, Renderer> for ToggleCard<M> {
                .style(Svg::Custom(Box::new(style)))
        });

        let name = text(self.name).size(14).font(Font {
        let name = text(&self.name).size(14).font(Font {
            weight: Weight::Bold,
            stretch: Stretch::Condensed,
            ..Font::with_name("Helvetica Neue")