fjordgard

A desktop clock application
Log | Files | Refs | README | LICENSE

settings.rs (17488B)


      1 use std::{cell::RefCell, rc::Rc, sync::Arc};
      2 
      3 use fjordgard_weather::{MeteoClient, model::Location};
      4 use iced::{
      5     Background, Border, Color, Element, Length, Task, Theme,
      6     widget::{button, column, combo_box, container, row, scrollable, text, text_input, tooltip},
      7 };
      8 use log::error;
      9 #[cfg(not(target_arch = "wasm32"))]
     10 use rfd::{AsyncFileDialog, FileHandle};
     11 use strum::VariantArray;
     12 
     13 use crate::config::{self, BackgroundMode, Config};
     14 
     15 #[derive(Debug, Clone, PartialEq, strum::Display, strum::VariantArray)]
     16 pub enum WeatherLocation {
     17     Disabled,
     18     #[strum(to_string = "Location name")]
     19     LocationName,
     20     Coordinates,
     21 }
     22 
     23 #[derive(Debug, Clone)]
     24 pub struct LocationRow {
     25     name: String,
     26     latitude: f64,
     27     longitude: f64,
     28 }
     29 
     30 pub struct Settings {
     31     config: Rc<RefCell<Config>>,
     32     meteo: Arc<MeteoClient>,
     33     backgrounds: combo_box::State<BackgroundMode>,
     34     locations: combo_box::State<WeatherLocation>,
     35     #[cfg(not(target_arch = "wasm32"))]
     36     file_selector_open: bool,
     37 
     38     time_format: String,
     39     background_mode: BackgroundMode,
     40     background: String,
     41     unsplash_key: String,
     42 
     43     location: WeatherLocation,
     44     name: String,
     45     latitude: String,
     46     longitude: String,
     47 
     48     location_results: Vec<LocationRow>,
     49     location_fetch_error: Option<String>,
     50 }
     51 
     52 #[derive(Debug, Clone)]
     53 pub enum Message {
     54     TimeFormat(String),
     55     BackgroundMode(BackgroundMode),
     56     Background(String),
     57     UnsplashKey(String),
     58     Location(WeatherLocation),
     59     Name(String),
     60     NameSubmitted,
     61     Geocode(Result<Vec<Location>, String>),
     62     LocationSelected(LocationRow),
     63     Latitude(String),
     64     Longitude(String),
     65     #[cfg(not(target_arch = "wasm32"))]
     66     FileSelector,
     67     #[cfg(not(target_arch = "wasm32"))]
     68     FileSelected(Option<FileHandle>),
     69     Save,
     70     CloseSettings,
     71 
     72     Committed,
     73     Saved(Result<(), String>),
     74 
     75     #[cfg(target_arch = "wasm32")]
     76     ToBackground(crate::background::Message),
     77 }
     78 
     79 impl Settings {
     80     pub fn new(config: Rc<RefCell<Config>>, meteo: Arc<MeteoClient>) -> Self {
     81         let original_config = config.borrow().clone();
     82         let location = original_config.location;
     83 
     84         let latitude = location
     85             .as_ref()
     86             .map(|l| l.latitude.to_string())
     87             .unwrap_or_default();
     88         let longitude = location
     89             .as_ref()
     90             .map(|l| l.longitude.to_string())
     91             .unwrap_or_default();
     92         let name = location
     93             .as_ref()
     94             .and_then(|l| l.name.clone())
     95             .unwrap_or_default();
     96         let location = location
     97             .as_ref()
     98             .map(|l| {
     99                 l.name
    100                     .as_ref()
    101                     .map(|_| WeatherLocation::LocationName)
    102                     .unwrap_or(WeatherLocation::Coordinates)
    103             })
    104             .unwrap_or(WeatherLocation::Disabled);
    105 
    106         Self {
    107             config,
    108             meteo,
    109             backgrounds: combo_box::State::new(BackgroundMode::VARIANTS.to_vec()),
    110             locations: combo_box::State::new(WeatherLocation::VARIANTS.to_vec()),
    111             #[cfg(not(target_arch = "wasm32"))]
    112             file_selector_open: false,
    113 
    114             time_format: original_config.time_format,
    115             background_mode: original_config.background_mode,
    116             background: original_config.background,
    117             unsplash_key: original_config.unsplash_key.unwrap_or_default(),
    118 
    119             location,
    120             latitude,
    121             longitude,
    122             name,
    123 
    124             location_results: vec![],
    125             location_fetch_error: None,
    126         }
    127     }
    128 
    129     pub fn update(&mut self, msg: Message) -> Task<Message> {
    130         match msg {
    131             Message::TimeFormat(format) => {
    132                 self.time_format = format;
    133                 Task::none()
    134             }
    135             Message::BackgroundMode(mode) => {
    136                 self.background = mode.default_background().to_string();
    137                 self.background_mode = mode;
    138                 Task::none()
    139             }
    140             Message::Background(background) => {
    141                 self.background = background;
    142                 Task::none()
    143             }
    144             Message::UnsplashKey(key) => {
    145                 self.unsplash_key = key;
    146                 Task::none()
    147             }
    148             Message::Location(location) => {
    149                 self.location = location;
    150                 Task::none()
    151             }
    152             Message::Name(name) => {
    153                 self.name = name;
    154                 Task::none()
    155             }
    156             Message::NameSubmitted => {
    157                 self.location_fetch_error = None;
    158                 let meteo = self.meteo.clone();
    159                 let name = self.name.clone();
    160 
    161                 Task::future(async move { meteo.geocode(&name, None).await })
    162                     .map(|r| Message::Geocode(r.map_err(|e| e.to_string())))
    163             }
    164             Message::Geocode(locations) => {
    165                 match locations {
    166                     Err(e) => {
    167                         error!("failed to fetch geocode: {e}");
    168                         self.location_fetch_error = Some(e);
    169                     }
    170                     Ok(res) => {
    171                         self.location_results = res
    172                             .iter()
    173                             .map(|l| {
    174                                 let level1 = if let Some(admin1) = &l.admin1 {
    175                                     format!(", {admin1}")
    176                                 } else {
    177                                     String::new()
    178                                 };
    179 
    180                                 LocationRow {
    181                                     name: format!("{}{level1}, {}", l.name, l.country),
    182                                     latitude: l.latitude,
    183                                     longitude: l.longitude,
    184                                 }
    185                             })
    186                             .collect()
    187                     }
    188                 };
    189 
    190                 Task::none()
    191             }
    192             Message::LocationSelected(loc) => {
    193                 self.name = loc.name;
    194                 self.latitude = loc.latitude.to_string();
    195                 self.longitude = loc.longitude.to_string();
    196 
    197                 Task::none()
    198             }
    199             Message::Latitude(latitude) => {
    200                 self.latitude = latitude;
    201                 Task::none()
    202             }
    203             Message::Longitude(longitude) => {
    204                 self.longitude = longitude;
    205                 Task::none()
    206             }
    207             #[cfg(not(target_arch = "wasm32"))]
    208             Message::FileSelector => {
    209                 if self.file_selector_open {
    210                     return Task::none();
    211                 }
    212 
    213                 self.file_selector_open = true;
    214 
    215                 let file_task = AsyncFileDialog::new()
    216                     .add_filter("image", &["png", "jpeg", "jpg"])
    217                     .pick_file();
    218 
    219                 Task::future(file_task).map(Message::FileSelected)
    220             }
    221             #[cfg(not(target_arch = "wasm32"))]
    222             Message::FileSelected(file) => {
    223                 self.file_selector_open = false;
    224 
    225                 if let Some(file) = file {
    226                     self.background = file.path().to_string_lossy().to_string();
    227                 }
    228 
    229                 Task::none()
    230             }
    231             Message::Save => {
    232                 let mut config = self.config.borrow_mut();
    233 
    234                 config.time_format = self.time_format.clone();
    235                 config.background_mode = self.background_mode;
    236                 config.background = self.background.clone();
    237                 config.unsplash_key = if self.unsplash_key.is_empty() {
    238                     None
    239                 } else {
    240                     Some(self.unsplash_key.clone())
    241                 };
    242 
    243                 match self.location {
    244                     WeatherLocation::Disabled => config.location = None,
    245                     _ => {
    246                         config.location = Some(config::Location {
    247                             // this *should* be safe if we're at this point
    248                             longitude: self.longitude.parse().unwrap(),
    249                             latitude: self.latitude.parse().unwrap(),
    250                             name: if self.location == WeatherLocation::LocationName {
    251                                 Some(self.name.clone())
    252                             } else {
    253                                 None
    254                             },
    255                         })
    256                     }
    257                 }
    258 
    259                 let cloned = config.clone();
    260 
    261                 Task::batch([
    262                     Task::done(Message::Committed),
    263                     Task::future(async move { cloned.save().await })
    264                         .map(|r| Message::Saved(r.map_err(|e| e.to_string()))),
    265                 ])
    266             }
    267             Message::Saved(res) => match res {
    268                 Err(e) => {
    269                     error!("failed to save config: {e}");
    270                     Task::none()
    271                 }
    272                 Ok(()) => Task::none(),
    273             },
    274             _ => Task::none(),
    275         }
    276     }
    277 
    278     pub fn view(&self) -> Element<Message> {
    279         let (latitude, longitude, name) = match self.location {
    280             WeatherLocation::Disabled => (None, None, None),
    281             WeatherLocation::LocationName => (None, None, Some(Message::Name)),
    282             WeatherLocation::Coordinates => {
    283                 (Some(Message::Latitude), Some(Message::Longitude), None)
    284             }
    285         };
    286 
    287         let mut save_message = Some(Message::Save);
    288 
    289         let color_style = if (self.background_mode == BackgroundMode::Solid
    290             && Color::parse(&self.background).is_none())
    291             || (self.background_mode == BackgroundMode::Unsplash && self.background.is_empty())
    292         {
    293             save_message = None;
    294             text_input_error
    295         } else {
    296             text_input::default
    297         };
    298 
    299         let unsplash_style =
    300             if self.background_mode == BackgroundMode::Unsplash && self.unsplash_key.is_empty() {
    301                 save_message = None;
    302                 text_input_error
    303             } else {
    304                 text_input::default
    305             };
    306 
    307         let unsplash_key = if self.background_mode == BackgroundMode::Unsplash {
    308             Some(Message::UnsplashKey)
    309         } else {
    310             None
    311         };
    312 
    313         let latitude_style = if self.latitude.parse::<f64>().is_err()
    314             && matches!(
    315                 self.location,
    316                 WeatherLocation::LocationName | WeatherLocation::Coordinates
    317             ) {
    318             save_message = None;
    319             text_input_error
    320         } else {
    321             text_input::default
    322         };
    323 
    324         let longitude_style = if self.longitude.parse::<f64>().is_err()
    325             && matches!(
    326                 self.location,
    327                 WeatherLocation::LocationName | WeatherLocation::Coordinates
    328             ) {
    329             save_message = None;
    330             text_input_error
    331         } else {
    332             text_input::default
    333         };
    334 
    335         let mut background_mode_row =
    336             row![text(self.background_mode.edit_text()).width(Length::FillPortion(1))];
    337 
    338         match self.background_mode {
    339             #[cfg(not(target_arch = "wasm32"))]
    340             BackgroundMode::Local => {
    341                 let text = if self.background.is_empty() {
    342                     save_message = None;
    343                     "Select file..."
    344                 } else {
    345                     &self.background
    346                 };
    347 
    348                 background_mode_row = background_mode_row.push(
    349                     button(text)
    350                         .on_press(Message::FileSelector)
    351                         .width(Length::FillPortion(2)),
    352                 );
    353             }
    354             _ => {
    355                 background_mode_row = background_mode_row.push(
    356                     text_input(self.background_mode.default_background(), &self.background)
    357                         .on_input(Message::Background)
    358                         .width(Length::FillPortion(2))
    359                         .style(color_style),
    360                 );
    361             }
    362         }
    363 
    364         let mut results = column![];
    365 
    366         for res in self.location_results.iter() {
    367             results = results.push(
    368                 button(text(format!(
    369                     "{} ({}, {})",
    370                     res.name, res.latitude, res.longitude
    371                 )))
    372                 .style(button::text)
    373                 .on_press_with(|| Message::LocationSelected(res.clone())),
    374             )
    375         }
    376 
    377         let location_style = if self.location_fetch_error.is_some() {
    378             save_message = None;
    379             text_input_error
    380         } else {
    381             text_input::default
    382         };
    383 
    384         let mut location_row: Element<Message> = row![
    385             text("Location").width(Length::FillPortion(1)),
    386             text_input("", &self.name)
    387                 .width(Length::FillPortion(2))
    388                 .on_input_maybe(name)
    389                 .on_submit(Message::NameSubmitted)
    390                 .style(location_style)
    391         ]
    392         .into();
    393 
    394         if let Some(err) = &self.location_fetch_error {
    395             location_row = tooltip(
    396                 location_row,
    397                 container(err.as_ref())
    398                     .padding(5)
    399                     .style(container::rounded_box),
    400                 tooltip::Position::Top,
    401             )
    402             .into()
    403         };
    404 
    405         scrollable(
    406             container(
    407                 column![
    408                     row![
    409                         text("Time format").width(Length::FillPortion(1)),
    410                         text_input("", &self.time_format)
    411                             .width(Length::FillPortion(2))
    412                             .on_input(Message::TimeFormat)
    413                     ],
    414                     row![
    415                         text("Background mode").width(Length::FillPortion(1)),
    416                         combo_box(
    417                             &self.backgrounds,
    418                             "",
    419                             Some(&self.background_mode),
    420                             Message::BackgroundMode
    421                         )
    422                         .width(Length::FillPortion(2))
    423                     ],
    424                     background_mode_row,
    425                     row![
    426                         text("Unsplash API Key").width(Length::FillPortion(1)),
    427                         text_input("", &self.unsplash_key)
    428                             .width(Length::FillPortion(2))
    429                             .on_input_maybe(unsplash_key)
    430                             .style(unsplash_style)
    431                     ],
    432                     row![
    433                         text("Weather Location").width(Length::FillPortion(1)),
    434                         combo_box(&self.locations, "", Some(&self.location), Message::Location)
    435                             .width(Length::FillPortion(2))
    436                     ],
    437                     row![
    438                         text("Latitude").width(Length::FillPortion(1)),
    439                         text_input("", &self.latitude)
    440                             .width(Length::FillPortion(2))
    441                             .on_input_maybe(latitude)
    442                             .style(latitude_style)
    443                     ],
    444                     row![
    445                         text("Longitude").width(Length::FillPortion(1)),
    446                         text_input("", &self.longitude)
    447                             .width(Length::FillPortion(2))
    448                             .on_input_maybe(longitude)
    449                             .style(longitude_style)
    450                     ],
    451                     location_row,
    452                     scrollable(results)
    453                         .height(Length::Fixed(
    454                             64.0 * (self.location_results.len().clamp(0, 1) as f32)
    455                         ))
    456                         .width(Length::Fill),
    457                     row![
    458                         button("Save").on_press_maybe(save_message),
    459                         button("Close").on_press(Message::CloseSettings),
    460                     ]
    461                     .spacing(5)
    462                 ]
    463                 .spacing(10),
    464             )
    465             .padding(15),
    466         )
    467         .into()
    468     }
    469 }
    470 
    471 fn text_input_error(theme: &Theme, status: text_input::Status) -> text_input::Style {
    472     let palette = theme.extended_palette();
    473 
    474     let active = text_input::Style {
    475         background: Background::Color(palette.danger.weak.color),
    476         border: Border {
    477             radius: 2.0.into(),
    478             width: 1.0,
    479             color: palette.danger.strong.color,
    480         },
    481         icon: palette.danger.weak.text,
    482         placeholder: palette.danger.strong.color,
    483         value: palette.danger.weak.text,
    484         selection: palette.danger.strong.color,
    485     };
    486 
    487     match status {
    488         text_input::Status::Active => active,
    489         text_input::Status::Hovered => text_input::Style {
    490             border: Border {
    491                 color: palette.danger.base.text,
    492                 ..active.border
    493             },
    494             ..active
    495         },
    496         text_input::Status::Focused => text_input::Style {
    497             border: Border {
    498                 color: palette.background.strong.color,
    499                 ..active.border
    500             },
    501             ..active
    502         },
    503         text_input::Status::Disabled => text_input::Style {
    504             background: Background::Color(palette.danger.weak.color),
    505             value: active.placeholder,
    506             ..active
    507         },
    508     }
    509 }