Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-filedialog/src/lib.rs

//! **facett-filedialog** (§21) — a **themed, in-app** file dialog (native `rfd`
//! dialogs can't be themed, so we draw a coherent one in egui). Open / open-many /
//! save / pick-folder, fully keyboard-navigable via the shared [`KeyMap`]
//! (arrows = move, `Confirm`/`Cancel`, type-to-filter, `Copy` a path). It is a
//! [`Facet`] (picturable headlessly with an **injected listing** — FILE-3) and
//! composes with §5 (open as a glass overlay).
//!
//! The listing is **injected** (`set_entries`), not read from disk, so the dialog
//! is deterministic + testable; a host wires real `std::fs` reads to `set_entries`
//! as an effect. FILE-2 (optional native `rfd` backend) is a host-side feature.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`FileDialogState`]** — the complete observable, serializable, round-trippable
//!   state (mode + cwd + injected listing + filter + cursor + picks + save-name +
//!   outcome + scale). [`FileDialog::state`] hands back a `&FileDialogState`.
//! - **[`Msg`] + [`FileDialog::update`]** — the single mutation path (FC-2): inject a
//!   listing, move/set the keyboard cursor, filter, toggle a pick, confirm/cancel,
//!   rescale. Every public mutator and every canvas gesture routes through here, so a
//!   headless driver ([`facett_core::harness`]) reaches the same transitions.
//! - **[`FileDialog::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//!   dialog, and *returns* the [`Msg`]s the interactions produced; the
//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//! - **Rich `state_json`** — the introspection dump publishes the *filtered* view
//!   (`visible`) + a `Debug`-formatted `mode` + a shaped `outcome`, all derived rather
//!   than a plain `serde(state())`, so the macro is invoked in **form 3**
//!   (`custom_state_json`). The dialog does no I/O, so [`Effect`] is uninhabited (FC-8).

use egui::{Align2, FontId, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
use facett_core::look::{Action, keymap};
use facett_core::{FacetCaps, Semantics, a11y_node, theme};
use serde::{Deserialize, Serialize};

/// What the dialog is for (FILE-1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DialogMode {
    Open,
    OpenMany,
    Save,
    PickFolder,
}

impl DialogMode {
    pub fn title(self) -> &'static str {
        match self {
            DialogMode::Open => "Open File",
            DialogMode::OpenMany => "Open Files",
            DialogMode::Save => "Save File",
            DialogMode::PickFolder => "Choose Folder",
        }
    }
    pub fn confirm_label(self) -> &'static str {
        match self {
            DialogMode::Save => "Save",
            DialogMode::PickFolder => "Choose",
            _ => "Open",
        }
    }
    fn many(self) -> bool {
        matches!(self, DialogMode::OpenMany)
    }
    fn folders_only(self) -> bool {
        matches!(self, DialogMode::PickFolder)
    }
}

/// One listing entry (injected).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entry {
    pub name: String,
    pub is_dir: bool,
}

impl Entry {
    pub fn file(name: impl Into<String>) -> Self {
        Self { name: name.into(), is_dir: false }
    }
    pub fn dir(name: impl Into<String>) -> Self {
        Self { name: name.into(), is_dir: true }
    }
}

/// What the dialog resolved to when the user confirmed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Outcome {
    /// Still open (no decision yet).
    Pending,
    /// User confirmed these paths (current dir joined with the picks).
    Confirmed(Vec<String>),
    /// User cancelled.
    Cancelled,
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the dialog through, the same effect a keyboard shortcut,
/// a row click, or a button press produces. Applied by [`FileDialog::update`], the
/// **only** legal way to mutate the [`FileDialogState`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Inject the directory listing (sorted dirs-first, then by name); resets the cursor.
    SetEntries(Vec<Entry>),
    /// Set the current directory (display path; the host maps it to a real path).
    SetCwd(String),
    /// Set the Save-mode filename field.
    SetSaveName(String),
    /// Set the type-to-filter substring (case-insensitive); resets the cursor.
    SetFilter(String),
    /// Move the keyboard cursor by `delta` within the *filtered* listing (clamped).
    MoveCursor(i64),
    /// Move the keyboard cursor to an absolute *filtered*-row index (a row click).
    SetCursor(usize),
    /// Toggle the cursored entry into the multi-select picks (OpenMany).
    TogglePick,
    /// Confirm: enter a directory (if cursored on one) or resolve the selection.
    Confirm,
    /// Cancel the dialog.
    Cancel,
    /// Set the uniform scale (clamped to 0.25..=4.0).
    SetScale(f32),
}

/// Side work as data (FC-8). The dialog does no I/O — every [`Msg`] mutates only the
/// in-memory [`FileDialogState`] (a host performs disk reads and feeds the result
/// back via [`Msg::SetEntries`]) — so this is uninhabited on purpose: the
/// type-checked statement that [`FileDialog::update`] never asks the host to do
/// anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// **The complete observable state (FC-1 / FC-3)** of a [`FileDialog`], in one
/// serializable, round-trippable struct. The listing is *injected*
/// ([`Msg::SetEntries`]) so the dialog renders deterministically headless (FILE-3);
/// selection/expansion key on the stable file *name*, never a volatile index (FC-5).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FileDialogState {
    pub mode: DialogMode,
    /// Current directory path (display only; the host maps it to a real path).
    pub cwd: String,
    pub entries: Vec<Entry>,
    /// Type-to-filter substring.
    pub filter: String,
    /// Keyboard cursor index into the *filtered* listing.
    pub cursor: usize,
    /// Multi-select picks (OpenMany), by entry name (FC-5: stable id, not index).
    pub picks: Vec<String>,
    /// Save-mode filename field.
    pub save_name: String,
    pub outcome: Outcome,
    pub scale: f32,
}

impl FileDialogState {
    /// Entries passing the current filter (case-insensitive substring), respecting
    /// folders-only mode.
    fn filtered(&self) -> Vec<&Entry> {
        let needle = self.filter.to_ascii_lowercase();
        self.entries
            .iter()
            .filter(|e| !self.mode.folders_only() || e.is_dir)
            .filter(|e| needle.is_empty() || e.name.to_ascii_lowercase().contains(&needle))
            .collect()
    }
}

/// The themed file dialog. All observable state lives in [`FileDialogState`] (FC-1);
/// the listing is injected ([`Msg::SetEntries`]) so it renders deterministically
/// headless (FILE-3). The dialog has no ephemeral animation/scroll cache, so nothing
/// lives outside the Model (FC-7) — the whole struct is the Model plus its title.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileDialog {
    title: String,
    /// All observable state (FC-3).
    state: FileDialogState,
}

impl FileDialog {
    pub fn new(mode: DialogMode, cwd: impl Into<String>) -> Self {
        Self {
            title: mode.title().to_string(),
            state: FileDialogState {
                mode,
                cwd: cwd.into(),
                entries: Vec::new(),
                filter: String::new(),
                cursor: 0,
                picks: Vec::new(),
                save_name: String::new(),
                outcome: Outcome::Pending,
                scale: 1.0,
            },
        }
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &FileDialogState {
        &self.state
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. Every public mutator and every
    /// canvas gesture routes here, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SetEntries(mut entries) => {
                // Sorted dirs-first, then by name, for a stable view.
                entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
                self.state.entries = entries;
                self.state.cursor = 0;
            }
            Msg::SetCwd(cwd) => self.state.cwd = cwd,
            Msg::SetSaveName(name) => self.state.save_name = name,
            Msg::SetFilter(f) => {
                self.state.filter = f;
                self.state.cursor = 0;
            }
            Msg::MoveCursor(delta) => {
                let n = self.state.filtered().len() as i64;
                if n > 0 {
                    self.state.cursor = (self.state.cursor as i64 + delta).clamp(0, n - 1) as usize;
                }
            }
            Msg::SetCursor(i) => {
                if i < self.state.filtered().len() {
                    self.state.cursor = i;
                }
            }
            Msg::TogglePick => {
                let name = self.state.filtered().get(self.state.cursor).map(|e| e.name.clone());
                if let Some(name) = name {
                    if let Some(i) = self.state.picks.iter().position(|p| p == &name) {
                        self.state.picks.remove(i);
                    } else {
                        self.state.picks.push(name);
                    }
                }
            }
            Msg::Confirm => self.apply_confirm(),
            Msg::Cancel => self.state.outcome = Outcome::Cancelled,
            Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
        }
        Vec::new()
    }

    /// Confirm: enter a directory (if cursored on one) or resolve the selection.
    fn apply_confirm(&mut self) {
        let entry = self.state.filtered().get(self.state.cursor).cloned().cloned();
        match (self.state.mode, entry) {
            // Entering a directory in Open/OpenMany navigates into it.
            (DialogMode::Open | DialogMode::OpenMany, Some(e)) if e.is_dir => {
                self.state.cwd = join(&self.state.cwd, &e.name);
                self.state.entries.clear();
                self.state.cursor = 0;
                // Host observes the cwd change and injects the new listing.
            }
            (DialogMode::Save, _) => {
                if !self.state.save_name.is_empty() {
                    let name = self.state.save_name.clone();
                    self.state.outcome = Outcome::Confirmed(vec![join(&self.state.cwd, &name)]);
                }
            }
            (DialogMode::PickFolder, Some(e)) if e.is_dir => {
                self.state.outcome = Outcome::Confirmed(vec![join(&self.state.cwd, &e.name)]);
            }
            (DialogMode::OpenMany, _) if !self.state.picks.is_empty() => {
                self.state.outcome =
                    Outcome::Confirmed(self.state.picks.iter().map(|p| join(&self.state.cwd, p)).collect());
            }
            (DialogMode::Open | DialogMode::OpenMany, Some(e)) => {
                self.state.outcome = Outcome::Confirmed(vec![join(&self.state.cwd, &e.name)]);
            }
            _ => {}
        }
    }

    // ── public API — thin wrappers over the FC-2 `update` mutation path ─────────

    /// Inject the directory listing (the host's `std::fs::read_dir` result, or a
    /// test fixture). Sorted dirs-first, then by name, for a stable view.
    pub fn set_entries(&mut self, entries: Vec<Entry>) {
        let _ = self.update(Msg::SetEntries(entries));
    }
    pub fn set_cwd(&mut self, cwd: impl Into<String>) {
        let _ = self.update(Msg::SetCwd(cwd.into()));
    }
    pub fn set_save_name(&mut self, name: impl Into<String>) {
        let _ = self.update(Msg::SetSaveName(name.into()));
    }
    pub fn move_cursor(&mut self, delta: i64) {
        let _ = self.update(Msg::MoveCursor(delta));
    }
    pub fn set_filter(&mut self, f: impl Into<String>) {
        let _ = self.update(Msg::SetFilter(f.into()));
    }
    /// Toggle the cursored entry into the multi-select picks (OpenMany).
    pub fn toggle_pick(&mut self) {
        let _ = self.update(Msg::TogglePick);
    }
    /// Confirm: enter a directory (if cursored on one) or resolve the selection.
    pub fn confirm(&mut self) {
        let _ = self.update(Msg::Confirm);
    }
    pub fn cancel(&mut self) {
        let _ = self.update(Msg::Cancel);
    }

    // ── observable getters ──────────────────────────────────────────────────────

    pub fn outcome(&self) -> &Outcome {
        &self.state.outcome
    }
    pub fn cursor(&self) -> usize {
        self.state.cursor
    }
    pub fn picks(&self) -> &[String] {
        &self.state.picks
    }

    /// The path the cursored entry would copy to the clipboard (`Copy`).
    pub fn cursored_path(&self) -> Option<String> {
        self.state.filtered().get(self.state.cursor).map(|e| join(&self.state.cwd, &e.name))
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the dialog and
    /// *returns* the [`Msg`]s the interactions produced. It MUST NOT mutate the
    /// [`FileDialogState`]; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
    /// bridge applies the returned messages through [`update`](Self::update).
    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        let mut msgs: Vec<Msg> = Vec::new();

        // Resolve navigation through the shared KeyMap (COH-2): the dialog never
        // hardcodes Key/Modifiers. `consume` reads egui input (not `self`), so it is
        // pure w.r.t. the Model — each shortcut becomes an emitted `Msg`.
        let km = keymap(ui);
        if km.consume(Action::Down, ui) {
            msgs.push(Msg::MoveCursor(1));
        }
        if km.consume(Action::Up, ui) {
            msgs.push(Msg::MoveCursor(-1));
        }
        if km.consume(Action::Confirm, ui) {
            msgs.push(Msg::Confirm);
        }
        if km.consume(Action::Cancel, ui) {
            msgs.push(Msg::Cancel);
        }
        if self.state.mode.many() && km.consume(Action::SelectAll, ui) {
            msgs.push(Msg::TogglePick);
        }

        let th = theme(ui);
        ui.horizontal(|ui| {
            ui.heading(&self.title);
            ui.label(format!("· {}", self.state.cwd));
        });
        ui.horizontal(|ui| {
            ui.label("🔍");
            // Pure view: edit a CLONE of the filter, emit a `SetFilter` if it changed.
            let mut buf = self.state.filter.clone();
            ui.add(egui::TextEdit::singleline(&mut buf).hint_text("type to filter…").desired_width(200.0));
            if buf != self.state.filter {
                msgs.push(Msg::SetFilter(buf));
            }
        });
        if self.state.mode == DialogMode::Save {
            ui.horizontal(|ui| {
                ui.label("Name:");
                let mut buf = self.state.save_name.clone();
                ui.add(egui::TextEdit::singleline(&mut buf).desired_width(240.0));
                if buf != self.state.save_name {
                    msgs.push(Msg::SetSaveName(buf));
                }
            });
        }
        ui.separator();

        // The listing — custom-painted rows so the cursor/selection are themed.
        // FC-4: each painted row also allocates an AccessKit node (so a robot
        // driver / screen reader / `get_by_label` can see + click it); FC-5: that
        // node is keyed on the stable file *name*, never the loop index, so a
        // reorder/insert/delete preserves the row's identity.
        let row_h = 22.0 * self.state.scale;
        let filtered = self.state.filtered();
        let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), (filtered.len() as f32 * row_h).max(row_h)), Sense::hover());
        let painter = ui.painter_at(rect);
        let font = FontId::proportional(13.0 * self.state.scale);
        let base = ui.id().with("filedialog-rows");
        // Collect the row a row's click resolves to *after* the paint loop, so the
        // emitted `Msg` doesn't fight the borrow of `filtered`.
        let mut clicked_row: Option<usize> = None;
        for (i, e) in filtered.iter().enumerate() {
            let y = rect.top() + i as f32 * row_h;
            let row = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), row_h));
            let picked = self.state.picks.iter().any(|p| p == &e.name);
            if i == self.state.cursor {
                painter.rect_filled(row, 0.0, th.accent.linear_multiply(0.22));
                painter.rect_stroke(row, 0.0, Stroke::new(1.0_f32, th.accent), StrokeKind::Inside);
            }
            let glyph = if e.is_dir { "📁" } else if picked { "☑" } else { "📄" };
            let color = if e.is_dir { th.accent } else { th.text };
            painter.text(row.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, format!("{glyph} {}", e.name), font.clone(), color);

            // FC-4/FC-5: hit-testable, addressable node over the same rect, keyed
            // on the stable name, reporting `selected` = is-this-row picked or the
            // keyboard cursor. Read `.clicked()` to drive selection below.
            let selected = picked || i == self.state.cursor;
            let resp = a11y_node(ui, base, &e.name, Sense::click(), row, Semantics::list_item(&e.name, selected));
            if resp.clicked() {
                clicked_row = Some(i);
            }
        }
        // A row click moves the keyboard cursor to it; in OpenMany it also toggles
        // the row's membership in the multi-select picks (mirrors `SelectAll`). Both
        // as emitted `Msg`s (FC-9), applied in order by the `update` bridge.
        if let Some(i) = clicked_row {
            msgs.push(Msg::SetCursor(i));
            if self.state.mode.many() {
                msgs.push(Msg::TogglePick);
            }
        }

        ui.separator();
        ui.horizontal(|ui| {
            if ui.button(self.state.mode.confirm_label()).clicked() {
                msgs.push(Msg::Confirm);
            }
            if ui.button("Cancel").clicked() {
                msgs.push(Msg::Cancel);
            }
            if self.state.mode.many() {
                ui.label(format!("{} selected", self.state.picks.len()));
            }
        });

        // ── render-lane emit: this pure view path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        {
            let filtered_n = self.state.filtered().len();
            facett_core::testmatrix::emit(
                "facett-filedialog::FileDialog::view",
                "ui_render",
                // OK = the listing drew valid geometry: the filtered view is a subset
                // of the injected entries and the keyboard cursor lands on a real row
                // (or there are no rows to land on).
                filtered_n <= self.state.entries.len() && (filtered_n == 0 || self.state.cursor < filtered_n),
                &format!(
                    "entries={} filtered={} cursor={} picks={}",
                    self.state.entries.len(),
                    filtered_n,
                    self.state.cursor,
                    self.state.picks.len(),
                ),
            );
        }

        msgs
    }
}

fn join(dir: &str, name: &str) -> String {
    if dir.ends_with('/') || dir.is_empty() {
        format!("{dir}{name}")
    } else {
        format!("{dir}/{name}")
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for FileDialog {
    type Model = FileDialogState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &FileDialogState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        FileDialog::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        FileDialog::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for FileDialog` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the dialog publishes a RICHER
// `state_json` than a plain `serde(state())`: the *filtered* `visible` listing, a
// `Debug`-formatted `mode`, and a shaped `outcome` are derived — so the default
// `state_json` is suppressed and this one supplied byte-for-byte (the keys a
// facett-demo / mega_matrix consumer reads).
facett_core::impl_facet_via_elm!(FileDialog, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let filtered: Vec<&str> = self.state.filtered().iter().map(|e| e.name.as_str()).collect();
        serde_json::json!({
            "mode": format!("{:?}", self.state.mode),
            "cwd": self.state.cwd,
            "filter": self.state.filter,
            "cursor": self.state.cursor,
            "visible": filtered,
            "picks": self.state.picks,
            "save_name": self.state.save_name,
            "outcome": match &self.state.outcome {
                Outcome::Pending => serde_json::json!("pending"),
                Outcome::Cancelled => serde_json::json!("cancelled"),
                Outcome::Confirmed(p) => serde_json::json!({ "confirmed": p }),
            },
        })
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().searchable().scalable().selectable().copyable().pasteable()
    }

    fn scale(&self) -> f32 {
        self.state.scale
    }
    fn set_scale(&mut self, scale: f32) {
        let _ = self.update(Msg::SetScale(scale));
    }

    fn copy(&mut self) -> Option<String> {
        self.copy_payload().map(|p| p.as_text())
    }

    fn paste(&mut self, text: &str) -> bool {
        self.paste_payload(&ClipPayload::Text(text.to_string()));
        self.state.mode == DialogMode::Save
    }
});

// ── typed copy/paste (§16) — copy the cursored path; paste a Save-mode filename ─
impl CopySource for FileDialog {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        self.cursored_path().map(ClipPayload::Text)
    }
}

impl PasteTarget for FileDialog {
    fn accepts(&self, k: ClipKind) -> bool {
        // Only the Save-mode filename field ingests a paste (its text view); the
        // read-only listing modes accept nothing.
        self.state.mode == DialogMode::Save && matches!(k, ClipKind::Text | ClipKind::Rows | ClipKind::DataColumns)
    }
    fn paste_payload(&mut self, p: &ClipPayload) {
        if self.state.mode != DialogMode::Save {
            return;
        }
        // Take the first cell of the first line of the payload's text view as the
        // filename (a paste of tabular/multi-line content collapses to one cell).
        // Routed through the FC-2 mutation path, not a direct field write.
        let text = p.as_text();
        let name = text.lines().next().unwrap_or("").split('\t').next().unwrap_or("").trim();
        let _ = self.update(Msg::SetSaveName(name.to_string()));
    }
}

#[cfg(test)]
mod tests;