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:
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))