use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub path: PathBuf,
pub prompt: String,
pub seed: Option<u64>,
pub saved: bool,
}
pub enum Slot {
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
Queued,
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
Generating(Option<image::DynamicImage>),
Done(Entry),
}
impl Slot {
pub fn done(&self) -> Option<&Entry> {
match self {
Slot::Done(e) => Some(e),
_ => None,
}
}
fn done_mut(&mut self) -> Option<&mut Entry> {
match self {
Slot::Done(e) => Some(e),
_ => None,
}
}
}
pub fn default_saved_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(".pixl")
.join("saved")
}
pub fn save_dest(saved_dir: &Path, entry: &Entry, exists: impl Fn(&Path) -> bool) -> PathBuf {
let stem = entry
.path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("img");
let ext = entry
.path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("png");
let direct = saved_dir.join(format!("{stem}.{ext}"));
if !exists(&direct) {
return direct;
}
if let Some(seed) = entry.seed {
let with_seed = saved_dir.join(format!("{stem}_{seed}.{ext}"));
if !exists(&with_seed) {
return with_seed;
}
}
let mut n = 1u32;
loop {
let cand = saved_dir.join(format!("{stem}_{n}.{ext}"));
if !exists(&cand) {
return cand;
}
n += 1;
}
}
#[derive(Default)]
pub struct Gallery {
pub slots: Vec<(usize, Slot)>,
pub current: usize,
follow_edge: bool,
}
impl Gallery {
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
pub fn live() -> Self {
Self {
slots: Vec::new(),
current: 0,
follow_edge: true,
}
}
pub fn fixed(entries: Vec<Entry>) -> Self {
Self {
slots: entries
.into_iter()
.enumerate()
.map(|(i, e)| (i, Slot::Done(e)))
.collect(),
current: 0,
follow_edge: false,
}
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
pub fn current_slot(&self) -> Option<&Slot> {
self.slots.get(self.current).map(|(_, s)| s)
}
pub fn current_done(&self) -> Option<&Entry> {
self.slots.get(self.current).and_then(|(_, s)| s.done())
}
pub fn current_done_mut(&mut self) -> Option<&mut Entry> {
self.slots
.get_mut(self.current)
.and_then(|(_, s)| s.done_mut())
}
fn pos_of(&self, id: usize) -> Option<usize> {
self.slots.iter().position(|(i, _)| *i == id)
}
fn at_edge(&self) -> bool {
self.current + 1 >= self.slots.len()
}
pub fn next(&mut self) {
if self.current + 1 < self.slots.len() {
self.current += 1;
}
self.follow_edge = self.at_edge();
}
pub fn prev(&mut self) {
self.current = self.current.saturating_sub(1);
self.follow_edge = false;
}
pub fn first(&mut self) {
self.current = 0;
self.follow_edge = self.slots.len() <= 1;
}
pub fn last(&mut self) {
self.current = self.slots.len().saturating_sub(1);
self.follow_edge = true;
}
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
pub fn push_queued(&mut self, start: usize, n: u32) {
for i in 0..n as usize {
self.slots.push((start + i, Slot::Queued));
}
}
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
pub fn start(&mut self, id: usize) {
if let Some(pos) = self.pos_of(id) {
self.slots[pos].1 = Slot::Generating(None);
if self.follow_edge {
self.current = pos;
}
}
}
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
pub fn set_preview(&mut self, id: usize, img: image::DynamicImage) {
if let Some(pos) = self.pos_of(id) {
if let (_, Slot::Generating(p)) = &mut self.slots[pos] {
*p = Some(img);
}
}
}
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
pub fn finish(&mut self, id: usize, entry: Entry) {
if let Some(pos) = self.pos_of(id) {
self.slots[pos].1 = Slot::Done(entry);
}
}
#[cfg_attr(not(feature = "gen"), allow(dead_code))]
pub fn remove_current(&mut self) -> Option<(usize, Slot)> {
if self.current >= self.slots.len() {
return None;
}
let removed = self.slots.remove(self.current);
if self.current >= self.slots.len() {
self.current = self.slots.len().saturating_sub(1);
}
self.follow_edge = self.at_edge();
Some(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, seed: Option<u64>) -> Entry {
Entry {
path: PathBuf::from(format!("/run/{name}.png")),
prompt: "p".into(),
seed,
saved: false,
}
}
#[test]
fn queued_then_generates_then_done() {
let mut g = Gallery::live();
g.push_queued(0, 3);
assert_eq!(g.len(), 3);
assert!(matches!(g.current_slot(), Some(Slot::Queued)));
g.start(0);
assert_eq!(g.current, 0);
assert!(matches!(g.current_slot(), Some(Slot::Generating(None))));
g.set_preview(0, image::DynamicImage::new_rgb8(1, 1));
assert!(matches!(g.current_slot(), Some(Slot::Generating(Some(_)))));
g.finish(0, entry("a", Some(0)));
assert!(matches!(g.current_slot(), Some(Slot::Done(_))));
g.start(1);
assert_eq!(g.current, 1);
}
#[test]
fn navigating_back_holds_position() {
let mut g = Gallery::live();
g.push_queued(0, 3);
g.start(0);
g.finish(0, entry("a", None));
g.start(1);
assert_eq!(g.current, 1);
g.prev();
assert_eq!(g.current, 0, "stepped back to inspect");
g.start(2);
assert_eq!(g.current, 0, "held position; not following");
}
#[test]
fn remove_keeps_ids_stable() {
let mut g = Gallery::live();
g.push_queued(0, 3); g.current = 1;
let removed = g.remove_current();
assert_eq!(removed.map(|(id, _)| id), Some(1));
assert_eq!(g.len(), 2);
g.start(2);
g.finish(2, entry("c", None));
assert!(g
.slots
.iter()
.any(|(id, s)| *id == 2 && matches!(s, Slot::Done(_))));
assert!(g.slots.iter().all(|(id, _)| *id != 1), "id 1 gone");
}
#[test]
fn nav_clamps_at_both_ends() {
let mut g = Gallery::fixed(vec![entry("a", None), entry("b", None)]);
g.prev();
assert_eq!(g.current, 0);
g.next();
g.next();
assert_eq!(g.current, 1);
}
#[test]
fn save_dest_uniquifies_on_collision() {
let dir = Path::new("/saved");
let e = entry("house_000", Some(42));
assert_eq!(
save_dest(dir, &e, |_| false),
PathBuf::from("/saved/house_000.png")
);
let taken = PathBuf::from("/saved/house_000.png");
assert_eq!(
save_dest(dir, &e, |p| p == taken),
PathBuf::from("/saved/house_000_42.png")
);
let taken2 = [
PathBuf::from("/saved/house_000.png"),
PathBuf::from("/saved/house_000_42.png"),
];
assert_eq!(
save_dest(dir, &e, |p| taken2.contains(&p.to_path_buf())),
PathBuf::from("/saved/house_000_1.png")
);
}
}