From ef05d0776e5b0e1afe447056dffa3e79e9470df9 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Thu, 2 Nov 2023 01:35:23 +0000 Subject: [PATCH] Implement HSB picker for lighting --- 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(-) create mode 100644 shalom/src/context_menus/light_control.rs create mode 100644 shalom/src/context_menus/mod.rs create mode 100644 shalom/src/widgets/colour_picker.rs 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 { + 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, - max_color_temp_kelvin: Option, - min_mireds: Option, - max_mireds: Option, + pub min_color_temp_kelvin: Option, + pub max_color_temp_kelvin: Option, + pub min_mireds: Option, + pub max_mireds: Option, #[serde(default)] - supported_color_modes: Vec, + pub supported_color_modes: Vec, #[serde(borrow)] - mode: Option>, + pub mode: Option>, #[serde(borrow)] - dynamics: Option>, + pub dynamics: Option>, #[serde(borrow)] - friendly_name: Cow<'a, str>, - color_mode: Option, - brightness: Option, - color_temp_kelvin: Option, - color_temp: Option, - xy_color: Option<(f32, f32)>, + pub friendly_name: Cow<'a, str>, + pub color_mode: Option, + pub brightness: Option, + pub color_temp_kelvin: Option, + pub color_temp: Option, + 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 { #[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, - pub media_players: Mutex>, + weather: Mutex, + media_players: Mutex>, + lights: Mutex>, entity_updates: broadcast::Sender>, } @@ -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::::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::::from(state.entity_id.as_ref()).as_ref(), + MediaPlayer::new(attr, &hass_client.base), + ); } - }) - .collect(); + StateAttributes::Light(attr) => { + lights.insert( + Intern::::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::::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, + pub max_color_temp_kelvin: Option, + pub min_mireds: Option, + pub max_mireds: Option, + pub supported_color_modes: Vec, + pub mode: Option>, + pub dynamics: Option>, + pub friendly_name: Box, + pub color_mode: Option, + pub brightness: Option, + pub color_temp_kelvin: Option, + pub color_temp: Option, + pub xy_color: Option<(f32, f32)>, +} + +impl From> 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, pub entities: Vec>, pub speaker_id: Option>, + pub lights: BTreeSet>, } impl Room { @@ -259,6 +316,19 @@ impl Room { MediaPlayer::Tv(_) => None, } } + + pub fn light_names(&self, oracle: &Oracle) -> BTreeMap<&'static str, Box> { + 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, now_playing_image: Option, + lights: BTreeMap<&'static str, Box>, } 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::>(), + ) + .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 { + hue: f32, + saturation: f32, + brightness: f32, + on_change: fn(f32, f32, f32) -> Event, +} + +impl ColourPicker { + pub fn new( + hue: f32, + saturation: f32, + brightness: f32, + on_change: fn(f32, f32, f32) -> Event, + ) -> Self { + Self { + hue, + saturation, + brightness, + on_change, + } + } +} + +impl Component for ColourPicker { + type State = (); + type Event = Message; + + fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option { + 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> for Element<'a, M, Renderer> +where + M: 'a + Clone, +{ + fn from(card: ColourPicker) -> Self { + component(card) + } +} + +#[derive(Clone)] +pub enum Message { + OnSaturationBrightnessChange(f32, f32), + OnHueChanged(f32), +} + +pub struct HueSlider { + hue: f32, + on_hue_change: fn(f32) -> Message, +} + +impl HueSlider { + fn new(hue: f32, on_hue_change: fn(f32) -> Message) -> Self { + Self { hue, on_hue_change } + } +} + +impl canvas::Program for HueSlider { + type State = HueSliderState; + + fn update( + &self, + state: &mut Self::State, + event: Event, + bounds: Rectangle, + cursor: Cursor, + ) -> (Status, Option) { + 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 { + // 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 { + hue: f32, + saturation: f32, + brightness: f32, + on_change: fn(f32, f32) -> Message, +} + +impl SaturationBrightnessPicker { + pub fn new( + hue: f32, + saturation: f32, + brightness: f32, + on_change: fn(f32, f32) -> Message, + ) -> Self { + Self { + hue, + saturation, + brightness, + on_change, + } + } +} + +impl canvas::Program for SaturationBrightnessPicker { + type State = SaturationBrightnessPickerState; + + fn update( + &self, + state: &mut Self::State, + event: Event, + bounds: Rectangle, + cursor: Cursor, + ) -> (Status, Option) { + // 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 { + // 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(name: &'static str, active: bool) -> ToggleCard { +pub fn toggle_card(name: &str, active: bool) -> ToggleCard { ToggleCard { - name, + name: Box::from(name), active, ..ToggleCard::default() } @@ -30,7 +30,7 @@ pub fn toggle_card(name: &'static str, active: bool) -> ToggleCard { pub struct ToggleCard { icon: Option, - name: &'static str, + name: Box, height: Length, width: Length, active: bool, @@ -42,7 +42,7 @@ impl Default for ToggleCard { fn default() -> Self { Self { icon: None, - name: "", + name: Box::from(""), height: Length::Shrink, width: Length::Fill, active: false, @@ -125,7 +125,7 @@ impl iced::widget::Component for ToggleCard { .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") -- libgit2 1.7.2