fastpack-gui 0.21.0

Native desktop GUI for FastPack (primary interface)
Documentation
use eframe::egui;
use fastpack_core::types::config::SpriteOverride;
use rust_i18n::t;

use crate::state::{AppState, FrameInfo, SheetData};

const THUMB_SIZE: f32 = 20.0;

enum TreeNode {
    Folder {
        name: String,
        full_path: String,
        children: Vec<TreeNode>,
    },
    Sprite {
        name: String,
        frame_idx: usize,
    },
}

fn insert_frame(nodes: &mut Vec<TreeNode>, path_prefix: &str, parts: &[&str], frame_idx: usize) {
    if parts.is_empty() {
        return;
    }
    if parts.len() == 1 {
        nodes.push(TreeNode::Sprite {
            name: parts[0].to_string(),
            frame_idx,
        });
        return;
    }
    let folder_name = parts[0];
    let full_path = if path_prefix.is_empty() {
        folder_name.to_string()
    } else {
        format!("{path_prefix}/{folder_name}")
    };
    let pos = nodes
        .iter()
        .position(|n| matches!(n, TreeNode::Folder { name, .. } if name == folder_name));
    if let Some(i) = pos {
        if let TreeNode::Folder { children, .. } = &mut nodes[i] {
            insert_frame(children, &full_path, &parts[1..], frame_idx);
        }
    } else {
        let mut children = Vec::new();
        insert_frame(&mut children, &full_path, &parts[1..], frame_idx);
        nodes.push(TreeNode::Folder {
            name: folder_name.to_string(),
            full_path,
            children,
        });
    }
}

fn build_tree(frames: &[FrameInfo]) -> Vec<TreeNode> {
    let mut roots: Vec<TreeNode> = Vec::new();
    for (i, frame) in frames.iter().enumerate() {
        let parts: Vec<&str> = frame.id.split('/').collect();
        insert_frame(&mut roots, "", &parts, i);
    }
    roots
}

fn draw_thumbnail(
    ui: &mut egui::Ui,
    frame: &FrameInfo,
    sheets: &[SheetData],
    atlas_textures: &[egui::TextureHandle],
) {
    let (rect, _) =
        ui.allocate_exact_size(egui::vec2(THUMB_SIZE, THUMB_SIZE), egui::Sense::hover());

    let si = frame.sheet_idx;
    if si >= atlas_textures.len() || si >= sheets.len() {
        return;
    }
    let sheet = &sheets[si];
    let aw = sheet.width as f32;
    let ah = sheet.height as f32;
    if aw <= 0.0 || ah <= 0.0 || frame.w == 0 || frame.h == 0 {
        return;
    }

    let uv = egui::Rect::from_min_max(
        egui::pos2(frame.x as f32 / aw, frame.y as f32 / ah),
        egui::pos2(
            (frame.x + frame.w) as f32 / aw,
            (frame.y + frame.h) as f32 / ah,
        ),
    );

    let aspect = frame.w as f32 / frame.h as f32;
    let (tw, th) = if aspect >= 1.0 {
        (THUMB_SIZE, (THUMB_SIZE / aspect).max(1.0))
    } else {
        ((THUMB_SIZE * aspect).max(1.0), THUMB_SIZE)
    };
    let thumb_rect = egui::Rect::from_min_size(
        egui::pos2(
            rect.min.x + (THUMB_SIZE - tw) * 0.5,
            rect.min.y + (THUMB_SIZE - th) * 0.5,
        ),
        egui::vec2(tw, th),
    );

    ui.painter().image(
        atlas_textures[si].id(),
        thumb_rect,
        uv,
        egui::Color32::WHITE,
    );
}

fn collect_visual_order(nodes: &[TreeNode]) -> Vec<usize> {
    let mut out = Vec::new();
    for node in nodes {
        match node {
            TreeNode::Sprite { frame_idx, .. } => out.push(*frame_idx),
            TreeNode::Folder { children, .. } => out.extend(collect_visual_order(children)),
        }
    }
    out
}

#[allow(clippy::too_many_arguments)]
fn show_nodes(
    ui: &mut egui::Ui,
    nodes: &[TreeNode],
    frames: &[FrameInfo],
    sheets: &[SheetData],
    atlas_textures: &[egui::TextureHandle],
    selected: &mut Vec<usize>,
    anchor: &mut Option<usize>,
    visual_order: &[usize],
) {
    for node in nodes {
        match node {
            TreeNode::Sprite { name, frame_idx } => {
                let idx = *frame_idx;
                let frame = &frames[idx];
                let is_selected = selected.contains(&idx);

                let resp = ui.horizontal(|ui| {
                    draw_thumbnail(ui, frame, sheets, atlas_textures);
                    ui.selectable_label(is_selected, egui::RichText::new(name).small())
                });

                if resp.inner.clicked() {
                    let shift = ui.input(|i| i.modifiers.shift);
                    let ctrl = ui.input(|i| i.modifiers.ctrl);

                    if shift {
                        let range_start = anchor.unwrap_or(idx);
                        let ps = visual_order.iter().position(|&x| x == range_start);
                        let pe = visual_order.iter().position(|&x| x == idx);
                        if let (Some(s), Some(e)) = (ps, pe) {
                            let (lo, hi) = if s <= e { (s, e) } else { (e, s) };
                            let range: Vec<usize> = visual_order[lo..=hi].to_vec();
                            if ctrl {
                                for &f in &range {
                                    if !selected.contains(&f) {
                                        selected.push(f);
                                    }
                                }
                            } else {
                                *selected = range;
                            }
                        } else {
                            if !ctrl {
                                selected.clear();
                            }
                            if !selected.contains(&idx) {
                                selected.push(idx);
                            }
                        }
                    } else if ctrl {
                        if let Some(pos) = selected.iter().position(|&x| x == idx) {
                            selected.remove(pos);
                        } else {
                            selected.push(idx);
                        }
                    } else {
                        selected.clear();
                        selected.push(idx);
                        *anchor = Some(idx);
                    }
                }
            }
            TreeNode::Folder {
                name,
                full_path,
                children,
            } => {
                egui::CollapsingHeader::new(egui::RichText::new(name).small().strong())
                    .id_salt(full_path.as_str())
                    .default_open(true)
                    .show(ui, |ui| {
                        show_nodes(
                            ui,
                            children,
                            frames,
                            sheets,
                            atlas_textures,
                            selected,
                            anchor,
                            visual_order,
                        );
                    });
            }
        }
    }
}

/// Render the sprite list panel with collapsible folder tree and sprite thumbnails.
pub fn show(ui: &mut egui::Ui, state: &mut AppState, atlas_textures: &[egui::TextureHandle]) {
    ui.horizontal(|ui| {
        ui.strong(t!("sprite_list.sources"));
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            if ui.small_button(t!("sprite_list.add")).clicked() {
                state.pending.add_source = true;
            }
        });
    });
    ui.separator();

    let mut remove_idx: Option<usize> = None;
    for (i, source) in state.project.sources.iter().enumerate() {
        ui.horizontal(|ui| {
            if ui.small_button(t!("sprite_list.remove")).clicked() {
                remove_idx = Some(i);
            }
            let full = source.path.to_string_lossy();
            let display = source
                .path
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_else(|| full.to_string());
            ui.label(egui::RichText::new(display).small())
                .on_hover_text(full.as_ref());
        });
    }
    if state.project.sources.is_empty() {
        ui.label(
            egui::RichText::new(t!("sprite_list.no_sources"))
                .weak()
                .small(),
        );
    }
    if let Some(i) = remove_idx {
        state.remove_source(i);
    }

    ui.add_space(6.0);

    let frame_count = state.frames.len();
    ui.horizontal(|ui| {
        ui.strong(t!("sprite_list.frames", count = frame_count));
    });
    ui.separator();

    if state.frames.is_empty() {
        ui.label(
            egui::RichText::new(t!("sprite_list.pack_hint"))
                .weak()
                .small(),
        );
        return;
    }

    let tree = build_tree(&state.frames);
    let visual_order = collect_visual_order(&tree);
    let mut new_selected = state.selected_frames.clone();
    let mut new_anchor = state.anchor_frame;

    egui::ScrollArea::vertical()
        .auto_shrink([false, false])
        .show(ui, |ui| {
            show_nodes(
                ui,
                &tree,
                &state.frames,
                &state.sheets,
                atlas_textures,
                &mut new_selected,
                &mut new_anchor,
                &visual_order,
            );
        });

    state.selected_frames = new_selected;
    state.anchor_frame = new_anchor;

    if state.selected_frames.len() >= 2 {
        ui.separator();
        if ui.button("Preview Animation  [P]").clicked() {
            state.anim_preview.open = true;
            state.anim_preview.current_frame = 0;
            state.anim_preview.elapsed_secs = 0.0;
            state.anim_preview.playing = true;
            state.anim_preview.zoom = 1.0;
            state.anim_preview.pan = [0.0, 0.0];
        }
    }

    show_sprite_detail(ui, state);
}

fn show_sprite_detail(ui: &mut egui::Ui, state: &mut AppState) {
    let Some(&sel_idx) = state.selected_frames.last() else {
        return;
    };
    let Some(frame) = state.frames.get(sel_idx) else {
        return;
    };

    let frame_id = frame.id.clone();
    let frame_w = frame.w;
    let frame_h = frame.h;
    let frame_x = frame.x;
    let frame_y = frame.y;

    ui.separator();
    ui.label(egui::RichText::new(&frame_id).small().strong());
    ui.label(
        egui::RichText::new(format!(
            "{}×{}  ({}, {})",
            frame_w, frame_h, frame_x, frame_y
        ))
        .small()
        .weak(),
    );

    let ovr_idx = state
        .project
        .config
        .sprite_overrides
        .iter()
        .position(|o| o.id == frame_id);

    if let Some(idx) = ovr_idx {
        let (np_chg, pv_chg) = {
            let ovr = &mut state.project.config.sprite_overrides[idx];
            let np =
                crate::widgets::nine_patch_editor::show(ui, &mut ovr.nine_patch, frame_w, frame_h);
            let pv = crate::widgets::pivot_editor::show(ui, &mut ovr.pivot);
            (np, pv)
        };
        if np_chg || pv_chg {
            state.dirty = true;
        }
        if ui.small_button(t!("sprite_list.remove_override")).clicked() {
            state.project.config.sprite_overrides.remove(idx);
            state.dirty = true;
        }
    } else if ui.small_button(t!("sprite_list.add_override")).clicked() {
        state.project.config.sprite_overrides.push(SpriteOverride {
            id: frame_id,
            pivot: None,
            nine_patch: None,
        });
        state.dirty = true;
    }
}