fjordgard

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

commit 6ca27597fa317c48f6c1c51482e86416519f4c53
parent 62b40c4708ff290352f6eee2b8af65a6cc7fba31
Author: Sylvia Ivory <git@sivory.net>
Date:   Thu, 19 Jun 2025 19:15:17 -0700

Pull images from unsplash collection

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcrates/unsplash/src/lib.rs | 1+
Msrc/background.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/config.rs | 2++
Msrc/settings.rs | 40+++++++++++++++++++++++++++++++++++++---
6 files changed, 200 insertions(+), 7 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1285,6 +1285,7 @@ version = "0.1.0" dependencies = [ "chrono", "env_logger", + "fjordgard-unsplash", "fjordgard-weather", "iced", "log", diff --git a/Cargo.toml b/Cargo.toml @@ -12,6 +12,7 @@ edition = "2024" [dependencies] chrono = "0.4.41" env_logger = "0.11.8" +fjordgard-unsplash = { version = "0.1.0", path = "crates/unsplash" } fjordgard-weather = { version = "0.1.0", path = "crates/weather" } iced = { version = "0.13.1", features = ["tokio", "canvas", "image", "svg"] } log = "0.4.27" diff --git a/crates/unsplash/src/lib.rs b/crates/unsplash/src/lib.rs @@ -16,6 +16,7 @@ pub mod model; const USER_AGENT: &str = concat!("fjordgard/", env!("CARGO_PKG_VERSION")); const UNSPLASH_API_HOST: &str = "https://api.unsplash.com/"; +#[derive(Clone)] pub struct UnsplashClient { client: Client, } diff --git a/src/background.rs b/src/background.rs @@ -1,3 +1,7 @@ +use fjordgard_unsplash::{ + UnsplashClient, + model::{Collection, CollectionPhotos, CollectionPhotosOptions, Format, PhotoFetchOptions}, +}; use iced::{ Color, ContentFit, Element, Length, Point, Renderer, Size, Task, Theme, mouse, widget::{canvas, container, image, stack, text}, @@ -32,16 +36,33 @@ impl<Message> canvas::Program<Message> for Solid { } } +pub struct UnsplashState { + collection: String, + current: usize, + total: usize, + paused: bool, + + current_page_photos: Option<CollectionPhotos>, + current_page: usize, +} + pub struct BackgroundHandle { pub mode: BackgroundMode, background: String, image_handle: Option<image::Handle>, + + unsplash_key: Option<String>, + unsplash_client: Option<UnsplashClient>, + unsplash_state: Option<UnsplashState>, } #[derive(Debug, Clone)] pub enum Message { BackgroundRead(Result<Vec<u8>, String>), + UnsplashCollection(Result<Collection, String>), + UnsplashCollectionPhotos(Result<CollectionPhotos, String>), + RequestUnsplash(isize), } impl BackgroundHandle { @@ -50,21 +71,31 @@ impl BackgroundHandle { mode: config.background_mode, background: config.background.clone(), image_handle: None, + + unsplash_key: config.unsplash_key.clone(), + unsplash_client: None, + unsplash_state: None, }; - let task = handle.refresh(); + let task = handle.refresh(true); - return (handle, task); + (handle, task) } pub fn load_config(&mut self, config: &Config) -> Task<Message> { self.mode = config.background_mode; self.background = config.background.clone(); - self.refresh() + if self.unsplash_key != config.unsplash_key { + self.unsplash_key = config.unsplash_key.clone(); + self.unsplash_state = None; + self.refresh(true) + } else { + self.refresh(false) + } } - fn refresh(&mut self) -> Task<Message> { + fn refresh(&mut self, refresh_unsplash: bool) -> Task<Message> { debug!( "refreshing background (mode={}, background={})", self.mode, &self.background @@ -77,6 +108,30 @@ impl BackgroundHandle { Task::future(async move { fs::read(&path).await }) .map(|r| Message::BackgroundRead(r.map_err(|e| e.to_string()))) } + BackgroundMode::Unsplash => { + if !refresh_unsplash { + return Task::none(); + } + + if let Some(key) = &self.unsplash_key { + self.unsplash_client = match UnsplashClient::new(key) { + Ok(c) => Some(c), + Err(e) => { + error!("failed to create Unsplash client: {e}"); + + return Task::none(); + } + }; + + let collection = self.background.clone(); + let client = self.unsplash_client.clone().unwrap(); + + Task::future(async move { client.collection(&collection).await }) + .map(|r| Message::UnsplashCollection(r.map_err(|e| e.to_string()))) + } else { + Task::none() + } + } _ => Task::none(), } } @@ -93,6 +148,105 @@ impl BackgroundHandle { Task::none() } }, + Message::UnsplashCollection(res) => match res { + Err(e) => { + error!("failed to fetch collection: {e}"); + Task::none() + } + Ok(collection) => { + self.unsplash_state = Some(UnsplashState { + collection: collection.id, + current: 0, + total: collection.total_photos, + paused: false, + + current_page: 0, + current_page_photos: None, + }); + + Task::done(Message::RequestUnsplash(0)) + } + }, + Message::RequestUnsplash(direction) => { + match (&self.unsplash_client, &mut self.unsplash_state) { + (Some(client), Some(state)) => { + if state.paused { + return Task::none(); + } + + state.current = (direction + (state.current as isize)) + .clamp(0, state.total as isize) + as usize; + + let page = (state.current / 10) + 1; + + if page == state.current_page && state.current_page_photos.is_some() { + return Task::done(Message::UnsplashCollectionPhotos(Ok(state + .current_page_photos + .as_ref() + .unwrap() + .clone()))); + } + + let collection = state.collection.clone(); + let client = client.clone(); + + Task::future(async move { + client + .collection_photos( + &collection, + Some(CollectionPhotosOptions { + page: Some(page), + per_page: Some(10), + ..Default::default() + }), + ) + .await + }) + .map(|r| Message::UnsplashCollectionPhotos(r.map_err(|e| e.to_string()))) + } + _ => Task::none(), + } + } + Message::UnsplashCollectionPhotos(res) => match res { + Err(e) => { + error!("failed to fetch collection photos: {e}"); + Task::none() + } + Ok(photos) => match (&self.unsplash_client, &mut self.unsplash_state) { + (Some(client), Some(state)) => { + state.current_page_photos = Some(photos.clone()); + state.current_page = (state.current / 10) + 1; + + let idx = state.current % 10; + let photo = match photos.photos.get(idx) { + Some(photo) => photo, + None => { + error!("photo not found, current={}", state.current); + return Task::none(); + } + }; + + let client = client.clone(); + let photo = photo.clone(); + + Task::future(async move { + client + .download_photo( + &photo, + Some(PhotoFetchOptions { + fm: Some(Format::Png), + ..Default::default() + }), + ) + .await + .map(|b| b.to_vec()) + }) + .map(|r| Message::BackgroundRead(r.map_err(|e| e.to_string()))) + } + _ => Task::none(), + }, + }, } } diff --git a/src/config.rs b/src/config.rs @@ -36,6 +36,7 @@ pub struct Config { pub time_format: String, pub background_mode: BackgroundMode, pub background: String, + pub unsplash_key: Option<String>, pub location: Option<Location>, } @@ -45,6 +46,7 @@ impl Default for Config { time_format: String::from("%-I:%M:%S"), background_mode: BackgroundMode::Solid, background: BackgroundMode::Solid.default_background().to_string(), + unsplash_key: None, location: None, } } diff --git a/src/settings.rs b/src/settings.rs @@ -36,6 +36,7 @@ pub struct Settings { time_format: String, background_mode: BackgroundMode, background: String, + unsplash_key: String, location: WeatherLocation, name: String, @@ -51,6 +52,7 @@ pub enum Message { TimeFormat(String), BackgroundMode(BackgroundMode), Background(String), + UnsplashKey(String), Location(WeatherLocation), Name(String), NameSubmitted, @@ -102,6 +104,7 @@ impl Settings { time_format: original_config.time_format, background_mode: original_config.background_mode, background: original_config.background, + unsplash_key: original_config.unsplash_key.unwrap_or_default(), location, latitude, @@ -128,6 +131,10 @@ impl Settings { self.background = background; Task::none() } + Message::UnsplashKey(key) => { + self.unsplash_key = key; + Task::none() + } Message::Location(location) => { self.location = location; Task::none() @@ -214,8 +221,13 @@ impl Settings { let mut config = self.config.borrow_mut(); config.time_format = self.time_format.clone(); - config.background_mode = self.background_mode.clone(); + config.background_mode = self.background_mode; config.background = self.background.clone(); + config.unsplash_key = if self.unsplash_key.is_empty() { + None + } else { + Some(self.unsplash_key.clone()) + }; match self.location { WeatherLocation::Disabled => config.location = None, @@ -250,8 +262,9 @@ impl Settings { let mut save_message = Some(Message::Save); - let color_style = if self.background_mode == BackgroundMode::Solid - && Color::parse(&self.background).is_none() + let color_style = if (self.background_mode == BackgroundMode::Solid + && Color::parse(&self.background).is_none()) + || (self.background_mode == BackgroundMode::Unsplash && self.background.is_empty()) { save_message = None; text_input_error @@ -259,6 +272,20 @@ impl Settings { text_input::default }; + let unsplash_style = + if self.background_mode == BackgroundMode::Unsplash && self.unsplash_key.is_empty() { + save_message = None; + text_input_error + } else { + text_input::default + }; + + let unsplash_key = if self.background_mode == BackgroundMode::Unsplash { + Some(Message::UnsplashKey) + } else { + None + }; + let latitude_style = if self.latitude.parse::<f64>().is_err() && matches!( self.location, @@ -368,6 +395,13 @@ impl Settings { ], background_mode_row, row![ + text("Unsplash API Key").width(Length::FillPortion(1)), + text_input("", &self.unsplash_key) + .width(Length::FillPortion(2)) + .on_input_maybe(unsplash_key) + .style(unsplash_style) + ], + row![ text("Weather Location").width(Length::FillPortion(1)), combo_box(&self.locations, "", Some(&self.location), Message::Location) .width(Length::FillPortion(2))