flow-pixl 0.1.12

Local pixel-art generator: SDXL + a pixel-art LoRA, snapped to true pixel art (Metal/CUDA/CPU).
//! Gallery state and pure helpers. No terminal or rendering dependencies, so the
//! navigation and save-path logic unit-test without a TTY or graphics protocol.

use std::path::{Path, PathBuf};

/// One finished image in the gallery.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
    pub path: PathBuf,
    pub prompt: String,
    /// The seed that produced it, when known (`None` for `pixl view`).
    pub seed: Option<u64>,
    /// Whether the user has copied this one into the saved folder.
    pub saved: bool,
}

/// A gallery slot, covering an image's whole lifecycle so the user can see the
/// entire queued batch up front, not just finished images.
pub enum Slot {
    /// Queued but not started — shown as a placeholder.
    #[cfg_attr(not(feature = "gen"), allow(dead_code))]
    Queued,
    /// Generating, with its latest in-flight preview (if any yet).
    #[cfg_attr(not(feature = "gen"), allow(dead_code))]
    Generating(Option<image::DynamicImage>),
    /// Finished.
    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,
        }
    }
}

/// Default keepers folder: `~/.pixl/saved` (consistent with the run dirs).
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")
}

/// Destination path for saving `entry` into `saved_dir` without clobbering a
/// different existing file: keep the source name; on collision disambiguate with
/// the seed (`name_<seed>.png`), then a numeric suffix. `exists` is injected so
/// this stays pure and testable.
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;
    }
}

/// The navigable list of slots plus a cursor.
///
/// `follow_edge` tracks whether the cursor is riding the live edge: while it is,
/// the cursor follows the image currently being generated; once the user steps
/// back to inspect, their position is held.
#[derive(Default)]
pub struct Gallery {
    /// `(global id, state)`. The id is the generator's image index and is stable
    /// across removals, so events keep matching after a slot is discarded.
    pub slots: Vec<(usize, Slot)>,
    pub current: usize,
    follow_edge: bool,
}

impl Gallery {
    /// An empty gallery that follows the live edge (for streaming generation).
    #[cfg_attr(not(feature = "gen"), allow(dead_code))]
    pub fn live() -> Self {
        Self {
            slots: Vec::new(),
            current: 0,
            follow_edge: true,
        }
    }

    /// A fixed gallery over already-known images (for `pixl view`).
    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;
    }

    /// Append `n` queued slots with ids `start..start+n`.
    #[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));
        }
    }

    /// Mark slot `id` as generating; follow it if we're riding the edge.
    #[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;
            }
        }
    }

    /// Update slot `id`'s latest preview.
    #[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);
            }
        }
    }

    /// Mark slot `id` finished.
    #[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);
        }
    }

    /// Remove the current slot, returning its `(id, state)`. The cursor clamps to
    /// the new length.
    #[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)));
        // start image 0 -> follows it
        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(_))));
        // next image starts; following -> cursor moves to it
        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); // ids 0,1,2
        g.current = 1;
        let removed = g.remove_current();
        assert_eq!(removed.map(|(id, _)| id), Some(1));
        assert_eq!(g.len(), 2);
        // ids 0 and 2 survive; events for id 2 still land on the right slot
        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")
        );
    }
}