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;