use std::{
fmt::{Display, Formatter, Result as FmtResult},
fs,
path::{Path, PathBuf},
str::FromStr,
time::Duration,
};
use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize};
use crate::error::Error;
const SUPPORTED_EXTENSIONS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "bmp", "tga", "tiff", "webp", "pnm", "farbfeld", "svg", "jxl",
"avif",
];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CyclingMode {
#[default]
Sequential,
Shuffle,
}
impl Display for CyclingMode {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.write_str(self.as_str())
}
}
impl CyclingMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Sequential => "sequential",
Self::Shuffle => "shuffle",
}
}
}
impl FromStr for CyclingMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"sequential" => Ok(Self::Sequential),
"shuffle" => Ok(Self::Shuffle),
_ => Err(format!("Invalid cycling mode: {s}")),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CyclingConfig {
pub directory: PathBuf,
pub images: Vec<PathBuf>,
pub mode: CyclingMode,
pub interval: Duration,
}
impl CyclingConfig {
pub fn new(directory: PathBuf, mode: CyclingMode, interval: Duration) -> Result<Self, Error> {
if !directory.exists() {
return Err(Error::DirectoryNotFound(directory));
}
let mut images = scan_directory_for_images(&directory)?;
if images.is_empty() {
return Err(Error::NoImagesFound(directory));
}
match mode {
CyclingMode::Sequential => images.sort(),
CyclingMode::Shuffle => {
let mut rng = rand::rng();
images.shuffle(&mut rng);
}
}
Ok(Self {
directory,
images,
mode,
interval,
})
}
pub fn image_at(&self, index: usize) -> Option<&PathBuf> {
if self.images.is_empty() {
return None;
}
self.images.get(index % self.images.len())
}
pub fn image_count(&self) -> usize {
self.images.len()
}
pub fn refresh(&mut self) -> Result<(), Error> {
let mut new_images = scan_directory_for_images(&self.directory)?;
if new_images.is_empty() {
self.images.clear();
return Ok(());
}
match self.mode {
CyclingMode::Sequential => new_images.sort(),
CyclingMode::Shuffle => {
let mut rng = rand::rng();
new_images.shuffle(&mut rng);
}
}
self.images = new_images;
Ok(())
}
}
fn scan_directory_for_images(directory: &Path) -> Result<Vec<PathBuf>, Error> {
let entries = fs::read_dir(directory)?;
let images: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| SUPPORTED_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
})
.collect();
Ok(images)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config(image_count: usize) -> CyclingConfig {
let images: Vec<PathBuf> = (0..image_count)
.map(|i| PathBuf::from(format!("image_{i}.png")))
.collect();
CyclingConfig {
directory: PathBuf::from("/test"),
images,
mode: CyclingMode::Sequential,
interval: Duration::from_secs(10),
}
}
#[test]
fn image_at_returns_correct_image() {
let config = make_config(3);
assert_eq!(config.image_at(0), Some(&PathBuf::from("image_0.png")));
assert_eq!(config.image_at(1), Some(&PathBuf::from("image_1.png")));
assert_eq!(config.image_at(2), Some(&PathBuf::from("image_2.png")));
}
#[test]
fn image_at_wraps_around() {
let config = make_config(3);
assert_eq!(config.image_at(3), Some(&PathBuf::from("image_0.png")));
assert_eq!(config.image_at(4), Some(&PathBuf::from("image_1.png")));
assert_eq!(config.image_at(5), Some(&PathBuf::from("image_2.png")));
}
#[test]
fn image_at_returns_none_for_empty() {
let config = CyclingConfig {
directory: PathBuf::from("/test"),
images: vec![],
mode: CyclingMode::Sequential,
interval: Duration::from_secs(10),
};
assert_eq!(config.image_at(0), None);
}
#[test]
fn image_count_returns_correct_count() {
let config = make_config(5);
assert_eq!(config.image_count(), 5);
}
}