Skip to main content

ui/
filedlg.rs

1// filedlg -- Open / Save file dialog (ContentDialog with a folder list), both OS
2//                                                                  [src/ui/ui_filedlg.s]
3//
4//   filedlg(id, flags, open, path, cap, exts) -> EV_ACT when a file was chosen
5//        flags FD_OPEN / FD_SAVE / FD_FOLDER | index << FD_EXT_SHIFT (initially selected
6//        extension); FD_FOLDER chooses a folder (the own dialog lists folders only and returns
7//        the selected one, else the one shown).
8//        path (at most cap - 1 bytes, UTF-8) gives the initial file or folder (empty = the
9//        folder used last, else the home folder) and receives the chosen path ('/'
10//        separators on every OS; Windows accepts them).  exts = the extension list, one
11//        entry per type: "png=PNG-Bild", "jpg;jpeg;jfif=JPEG-Bild" (lower case; several
12//        extensions of one type separated by ';', the first is the one appended; optional
13//        "=description"; empty = every file); FD_SAVE appends the selected type's extension
14//        when the name has none.  Call it every frame like any dialog; open = 1 shows it.
15//        Where the system has a file dialog (Windows, the Linux portal) that one is shown
16//        (modal); Android's picker answers later: nothing is drawn while it is open, the
17//        chosen document arrives as its content:// name in a later frame.  Otherwise,
18//        headless or with jfgui_FILEDLG=builtin, the library's own dialog.
19//        Widget ids used: id .. id + 0x3FFF (list rows id + 17 + i).
20//
21// One file dialog can be open at a time (the state below is shared).
22
23use alloc::string::String;
24use alloc::vec::Vec;
25
26use super::consts::*;
27use super::i18n::tr;
28use super::keys::*;
29use super::win::Shared;
30use super::Ui;
31
32const FD_MAX: usize = 8192; // entries per folder
33const FD_STORE: usize = 512 * 1024; // bytes of names per folder
34const FD_PATH: usize = 1040;
35const FD_DRIVES: usize = 256; // the plat::drives list (bytes, as the asm's buffer)
36const FD_NAME_CAP: usize = 256;
37const FD_DIRBIT: u32 = 0x8000_0000;
38
39/// state shared by all windows (one file dialog at a time)
40#[derive(Default)]
41pub(crate) struct S {
42    /// the names of the folder's entries, each ended by a NUL
43    store: Vec<u8>,
44    /// entries: offset in store | FD_DIRBIT for folders
45    idx: Vec<u32>,
46    /// the extension list of the open dialog
47    exts: Vec<String>,
48    msg: Option<&'static str>,
49    id: u32,
50    sel: i32,
51    /// the system's picker answers later (plat::FD_PENDING)
52    pending: bool,
53    /// FD_FOLDER: folders only, a folder is the answer
54    folder: bool,
55    dir: String,
56    last: String,
57    name: String,
58}
59
60// str_copy(src, cap): at most cap - 1 bytes, never splitting a UTF-8 sequence
61fn copy_cap(src: &str, cap: usize) -> String {
62    let src = super::basic::cstr(src);
63    if cap == 0 {
64        return String::new();
65    }
66    let mut n = src.len().min(cap - 1);
67    while !src.is_char_boundary(n) {
68        n -= 1;
69    }
70    String::from(&src[..n])
71}
72
73// str_cat(dst, src, cap): append, truncating like str_copy
74fn cat_cap(dst: &mut String, src: &str, cap: usize) {
75    if dst.len() + 1 >= cap {
76        return;
77    }
78    let room = cap - 1 - dst.len();
79    let src = super::basic::cstr(src);
80    let mut n = src.len().min(room);
81    while !src.is_char_boundary(n) {
82        n -= 1;
83    }
84    dst.push_str(&src[..n]);
85}
86
87// fd_norm(s): '\' -> '/'
88fn norm(s: &str) -> String {
89    s.replace('\\', "/")
90}
91
92// fd_is_root(s): "/" and "X:/"
93fn is_root(s: &str) -> bool {
94    let b = s.as_bytes();
95    match b.len() {
96        1 => b[0] == b'/',
97        3 => b[1] == b':' && b[2] == b'/',
98        _ => false,
99    }
100}
101
102// a path that names a root or starts at one ("/x", "\x", "C:...")
103fn is_abs(s: &str) -> bool {
104    let b = s.as_bytes();
105    !b.is_empty() && (b[0] == b'/' || b[0] == b'\\' || (b.len() > 1 && b[1] == b':'))
106}
107
108// fd_canon(p): resolve "." and ".." segments and repeated '/' ('/' paths; the root "/" or
109// "X:/" stays; ".." at the root is dropped)
110fn canon(p: &str) -> String {
111    let b = p.as_bytes();
112    let mut root = 0;
113    if !b.is_empty() && b[0] == b'/' {
114        root = 1;
115    } else if b.len() > 1 && b[1] == b':' {
116        root = 2;
117        if b.len() > 2 && b[2] == b'/' {
118            root = 3;
119        }
120    }
121    let mut out: Vec<u8> = b[..root].to_vec();
122    let mut r = root;
123    while r < b.len() {
124        if b[r] == b'/' {
125            r += 1;
126            continue;
127        }
128        let mut e = r;
129        while e < b.len() && b[e] != b'/' {
130            e += 1;
131        }
132        let seg = &b[r..e];
133        if seg == b"." {
134            // skip
135        } else if seg == b".." {
136            // drop the last written segment
137            while out.len() > root {
138                let c = out.pop();
139                if c == Some(b'/') {
140                    break;
141                }
142            }
143        } else {
144            if out.len() > root {
145                out.push(b'/');
146            }
147            out.extend_from_slice(seg);
148        }
149        r = e;
150    }
151    String::from_utf8(out).unwrap_or_default()
152}
153
154// fd_join(dst, name): dst = dst + '/' + name (FD_PATH bytes)
155fn join(dst: &mut String, name: &str) {
156    if !dst.is_empty() && !dst.ends_with('/') {
157        cat_cap(dst, "/", FD_PATH);
158    }
159    cat_cap(dst, name, FD_PATH);
160}
161
162// fd_ieq(a, b): equal ignoring ASCII case; b ends at '=' or ';'
163fn ieq(a: &[u8], b: &[u8]) -> bool {
164    let mut i = 0;
165    loop {
166        let x = a.get(i).copied().unwrap_or(0).to_ascii_lowercase();
167        let mut y = b.get(i).copied().unwrap_or(0);
168        if y == b'=' || y == b';' {
169            y = 0;
170        }
171        let y = y.to_ascii_lowercase();
172        if x != y {
173            return false;
174        }
175        if x == 0 {
176            return true;
177        }
178        i += 1;
179    }
180}
181
182impl S {
183    // fd_entry(i) -> (name, folder) (None when out of range)
184    fn entry(&self, i: i32) -> Option<(&str, bool)> {
185        if i < 0 || i as usize >= self.idx.len() {
186            return None;
187        }
188        let v = self.idx[i as usize];
189        let off = (v & !FD_DIRBIT) as usize;
190        let end = self.store[off..].iter().position(|&c| c == 0).map_or(self.store.len(), |n| off + n);
191        Some((core::str::from_utf8(&self.store[off..end]).unwrap_or(""), v & FD_DIRBIT != 0))
192    }
193
194    // fd_ext_ok(name): the name ends in one of the extensions (or there is no filter)
195    fn ext_ok(&self, name: &str) -> bool {
196        if self.exts.is_empty() || self.exts[0].is_empty() {
197            return true;
198        }
199        let Some(dot) = name.rfind('.') else { return false };
200        let ext = &name.as_bytes()[dot + 1..];
201        for e in &self.exts {
202            if e.is_empty() {
203                break;
204            }
205            // the extensions of this entry: "a;b;c=..."
206            let b = e.as_bytes();
207            let mut k = 0;
208            loop {
209                if ieq(ext, &b[k..]) {
210                    return true;
211                }
212                // skip to the next one after ';' (none after '=')
213                while k < b.len() && b[k] != b';' && b[k] != b'=' {
214                    k += 1;
215                }
216                if k >= b.len() || b[k] == b'=' {
217                    break;
218                }
219                k += 1;
220            }
221        }
222        false
223    }
224
225    // fd_sort: folders first, then the names in the user's collation with numbers by value
226    // ("Bild 2" before "Bild 10", "ä" with "a"), as the systems' file dialogs sort
227    fn sort(&mut self) {
228        let coll = super::i18n::collator().numeric(true);
229        let store = &self.store;
230        self.idx.sort_by_cached_key(|&v| {
231            let off = (v & !FD_DIRBIT) as usize;
232            let end = store[off..].iter().position(|&c| c == 0).map_or(store.len(), |n| off + n);
233            (v & FD_DIRBIT == 0, coll.key(core::str::from_utf8(&store[off..end]).unwrap_or("")))
234        });
235    }
236
237    // fd_set_dir(s): dir = s with '/' separators, no trailing separator except roots
238    fn set_dir(&mut self, s: &str) {
239        let mut d;
240        if is_abs(s) {
241            d = copy_cap(s, FD_PATH - 2);
242        } else {
243            // relative: below the current directory
244            d = norm(&copy_cap(&plat::cwd(), FD_PATH - 2));
245            join(&mut d, s);
246        }
247        let mut d = canon(&norm(&d));
248        loop {
249            if is_root(&d) {
250                break;
251            }
252            let b = d.as_bytes();
253            if b.len() == 2 && b[1] == b':' {
254                d.push('/'); // "C:" -> "C:/"
255                break;
256            }
257            if b.is_empty() {
258                d.push('/'); // empty -> "/"
259                break;
260            }
261            if b[b.len() - 1] != b'/' {
262                break;
263            }
264            d.pop();
265        }
266        self.dir = d;
267    }
268
269    // fd_parent: go to the parent folder (roots stay)
270    fn parent(&mut self) {
271        if is_root(&self.dir) {
272            return;
273        }
274        let Some(k) = self.dir.rfind('/') else { return };
275        if k == 0 || (k == 2 && self.dir.as_bytes()[1] == b':') {
276            self.dir.truncate(k + 1); // "/" or "C:/"
277        } else {
278            self.dir.truncate(k);
279        }
280    }
281
282    // fd_enter(name): open the sub folder name
283    fn enter(&mut self, name: &str) {
284        let mut d = core::mem::take(&mut self.dir);
285        join(&mut d, name);
286        self.dir = d;
287        self.name.clear();
288    }
289}
290
291impl Ui {
292    /// ui_filedlg(id, flags, open, path, cap, exts) -> EV_ACT when confirmed
293    #[allow(clippy::too_many_arguments)]
294    #[cfg_attr(jfgui_dev, track_caller)]
295    pub fn filedlg(&mut self, id: u32, flags: u32, open: &Shared<i32>, path: &mut String, cap: u32, exts: &[&str]) -> u32 {
296        let _src = self.src_scope();
297        let mut result = 0;
298        // first frame of an opening: load the start folder
299        if open.get() != 0 && self.m.filedlg.id != id {
300            // the system dialog, when there is one (modal: returns when the user is done)
301            let r = self.fd_system(flags, path, cap, exts);
302            if r == plat::FD_PENDING {
303                // the system's picker answers later (Android): wait for it
304                self.m.filedlg.id = id;
305                self.m.filedlg.pending = true;
306            } else if r != -1 {
307                open.set(0);
308                if r != 0 {
309                    result = EV_ACT;
310                }
311                self.repass();
312            } else {
313                self.fd_init(id, flags, path, exts);
314            }
315        }
316        if self.m.filedlg.pending && self.m.filedlg.id == id {
317            return self.fd_wait(open, path, cap);
318        }
319        let folder = flags & FD_FOLDER != 0;
320        let title = tr(if folder { "Ordner auswählen" } else if flags & FD_SAVE != 0 { "Speichern unter" } else { "Öffnen" });
321        if !self.dialog_begin(id, title, open) {
322            if self.m.filedlg.id == id {
323                self.m.filedlg.id = 0;
324            }
325            self.dialog_end();
326            if result == EV_ACT {
327                super::perm::grant_user(path); // the system dialog: the user chose it (fs.user)
328            }
329            return result;
330        }
331        self.fd_toolbar_row(id);
332        // folder contents
333        self.next_size(480, 300);
334        let mut sel = self.m.filedlg.sel;
335        let n = self.m.filedlg.idx.len() as i32;
336        let (first, end) = self.list_begin(id.wrapping_add(16), &mut sel, n, 32);
337        for i in first..end {
338            self.fd_row(i);
339        }
340        let (ev, sel) = self.list_end(&mut sel);
341        self.m.filedlg.sel = sel;
342        if ev & EV_DOUBLE != 0 {
343            if self.fd_activate(sel) && self.fd_confirm(flags, path, cap) {
344                // a file was double-clicked: confirm like the primary button
345                result = EV_ACT;
346                open.set(0);
347            }
348        } else if ev & EV_ACT != 0 {
349            // a file was selected: its name goes into the name box
350            let f = &mut self.m.filedlg;
351            if let Some((name, false)) = f.entry(sel) {
352                f.name = copy_cap(name, FD_NAME_CAP);
353                f.msg = None;
354            }
355        }
356        // file name (not when choosing a folder)
357        if !folder {
358            self.box_begin(0, BOX_H | BOX_XCENTER, 8);
359            self.text(TS_BODY, tr("Dateiname:"));
360            self.next_size(-1, 0);
361            let mut name = core::mem::take(&mut self.m.filedlg.name);
362            let ev = self.textbox(id.wrapping_add(12), None, &mut name, FD_NAME_CAP as u32);
363            self.m.filedlg.name = name;
364            if ev & EV_ACT != 0 {
365                self.m.filedlg.msg = None;
366            }
367            self.box_end();
368        }
369        if let Some(m) = self.m.filedlg.msg {
370            self.text(TS_CAPTION | TX_SECONDARY, m);
371        }
372        let prim = tr(if folder { "Ordner auswählen" } else if flags & FD_SAVE != 0 { "Speichern" } else { "Öffnen" });
373        if self.dialog_buttons(Some(prim), None, Some(tr("Abbrechen"))) == DLG_PRIMARY {
374            if self.fd_confirm(flags, path, cap) {
375                result = EV_ACT;
376            } else {
377                open.set(1); // not done yet: keep the dialog open
378                self.repass();
379            }
380        }
381        self.dialog_end();
382        if result == EV_ACT {
383            super::perm::grant_user(path); // the user chose it (fs.user)
384        }
385        result
386    }
387
388    // the system's picker is open (Android): its answer, once it comes
389    fn fd_wait(&mut self, open: &Shared<i32>, path: &mut String, cap: u32) -> u32 {
390        let Some((r, _, chosen)) = plat::file_dialog_poll() else { return 0 };
391        self.m.filedlg.pending = false;
392        self.m.filedlg.id = 0;
393        let wanted = open.get() != 0; // the app may have closed it meanwhile
394        open.set(0);
395        self.repass();
396        if r != 1 || !wanted {
397            return 0;
398        }
399        *path = copy_cap(&chosen, cap as usize);
400        super::perm::grant_user(path); // the user chose it (fs.user)
401        EV_ACT
402    }
403
404    // fd_system(flags, path, cap, exts) -> 1 chosen, 0 cancelled, -1 use the own dialog,
405    // FD_PENDING the system's picker answers later (fd_wait)
406    fn fd_system(&mut self, flags: u32, path: &mut String, cap: u32, exts: &[&str]) -> i32 {
407        if self.headless || plat::getenv("jfgui_FILEDLG").is_some_and(|v| v.as_bytes().first() == Some(&b'b')) {
408            return -1; // "builtin"
409        }
410        if self.hr_outside("the system's file dialog") {
411            return -1; // hot reload's replay: the own dialog (hotreload.rs)
412        }
413        let r = plat::file_dialog(flags, path, exts).0;
414        if r == 1 {
415            *path = copy_cap(path, cap as usize);
416        }
417        r
418    }
419
420    // fd_init(id, flags, path, exts): the dialog opens: choose the start folder and name, list it
421    fn fd_init(&mut self, id: u32, flags: u32, path: &str, exts: &[&str]) {
422        let f = &mut self.m.filedlg;
423        f.id = id;
424        f.folder = flags & FD_FOLDER != 0;
425        f.exts = exts.iter().map(|e| String::from(*e)).collect();
426        f.name.clear();
427        f.msg = None;
428        let path = super::basic::cstr(path);
429        if !path.is_empty() {
430            let mut tmp = norm(&copy_cap(path, FD_PATH));
431            if super::perm::trusted(|| plat::file_exists(&tmp)) == 2 {
432                self.m.filedlg.set_dir(&tmp);
433                self.fd_load();
434                return;
435            }
436            // a file (or a new name): folder part + name part
437            match tmp.rfind('/') {
438                Some(k) => {
439                    self.m.filedlg.name = copy_cap(&tmp[k + 1..], FD_NAME_CAP);
440                    // cut after the separator for roots ("/", "C:/"), at it otherwise
441                    if k == 0 || (k == 2 && tmp.as_bytes()[1] == b':') {
442                        tmp.truncate(k + 1);
443                    } else {
444                        tmp.truncate(k);
445                    }
446                    if super::perm::trusted(|| plat::file_exists(&tmp)) == 2 {
447                        self.m.filedlg.set_dir(&tmp);
448                        self.fd_load();
449                        return;
450                    }
451                }
452                None => self.m.filedlg.name = copy_cap(&tmp, FD_NAME_CAP),
453            }
454        }
455        if !self.m.filedlg.last.is_empty() {
456            let last = self.m.filedlg.last.clone();
457            self.m.filedlg.set_dir(&last);
458            self.fd_load();
459            return;
460        }
461        self.fd_go_home();
462    }
463
464    // fd_go_home: switch to the home folder and list it
465    fn fd_go_home(&mut self) {
466        let h = copy_cap(&plat::home().unwrap_or_else(|| String::from("/")), FD_PATH);
467        self.m.filedlg.set_dir(&h);
468        self.fd_load();
469    }
470
471    // fd_toolbar_row(id): up, home, drives, current folder
472    fn fd_toolbar_row(&mut self, id: u32) {
473        self.box_begin(0, BOX_H | BOX_XCENTER, 4);
474        if is_root(&self.m.filedlg.dir) {
475            self.next_flags(ST_DISABLED);
476        }
477        if self.icon_button(id.wrapping_add(1), IC_UP, tr("Übergeordneter Ordner")) & EV_ACT != 0 {
478            self.m.filedlg.parent();
479            self.fd_load();
480        }
481        if self.icon_button(id.wrapping_add(2), IC_HOME, tr("Startordner")) & EV_ACT != 0 {
482            self.fd_go_home();
483        }
484        // drives (at most 8, as many as a list of FD_DRIVES bytes holds)
485        let mut used = 0;
486        for (k, d) in plat::drives().iter().enumerate().take(8) {
487            used += d.len() + 1;
488            if used >= FD_DRIVES {
489                break;
490            }
491            // label without the trailing separator of "C:/"
492            let mut label = copy_cap(d, 16);
493            if label.len() == 3 && label.as_bytes()[1] == b':' {
494                label.truncate(2);
495            }
496            if self.button(id.wrapping_add(3 + k as u32), &label) & EV_ACT != 0 {
497                self.m.filedlg.set_dir(d);
498                self.fd_load();
499            }
500        }
501        self.spacer(8);
502        // the folder, with the native separator
503        let disp = copy_cap(&self.m.filedlg.dir, FD_PATH);
504        #[cfg(windows)]
505        let disp = disp.replace('/', "\\");
506        self.next_size(-1, 0);
507        self.text(TS_BODY | TX_ELLIPSIS | TX_SECONDARY, &disp);
508        self.box_end();
509    }
510
511    // fd_row(i): one list row: folder / file icon and the name
512    fn fd_row(&mut self, i: i32) {
513        self.list_item_begin(i);
514        let (name, dir) = match self.m.filedlg.entry(i) {
515            Some((n, d)) => (String::from(n), d),
516            None => (String::new(), false),
517        };
518        self.icon(if dir { IC_FOLDER } else { IC_PAGE }, 16);
519        self.next_size(-1, 0);
520        self.text(TS_BODY | TX_ELLIPSIS, &name);
521        self.list_item_end();
522    }
523
524    // fd_activate(i) -> entry i is a file (folders are opened here)
525    fn fd_activate(&mut self, i: i32) -> bool {
526        let f = &mut self.m.filedlg;
527        let Some((name, dir)) = f.entry(i) else { return false };
528        let name = String::from(name);
529        if dir {
530            f.enter(&name);
531            self.fd_load();
532            return false;
533        }
534        f.name = copy_cap(&name, FD_NAME_CAP);
535        true
536    }
537
538    // fd_confirm(flags, path, cap) -> the chosen path was copied to path
539    fn fd_confirm(&mut self, flags: u32, path: &mut String, cap: u32) -> bool {
540        let f = &mut self.m.filedlg;
541        if flags & FD_FOLDER != 0 {
542            // the selected folder, else the one shown
543            let mut d = copy_cap(&f.dir, FD_PATH);
544            if let Some((n, true)) = f.entry(f.sel) {
545                let n = String::from(n);
546                join(&mut d, &n);
547            }
548            *path = copy_cap(&d, cap as usize);
549            f.last = copy_cap(&f.dir, FD_PATH);
550            return true;
551        }
552        if f.name.is_empty() {
553            // no name: open the selected folder
554            if let Some((n, true)) = f.entry(f.sel) {
555                let n = String::from(n);
556                f.enter(&n);
557                self.fd_load();
558            }
559            return false;
560        }
561        // absolute name ("/x", "C:/x", "C:\x") or relative to the folder
562        let mut tmp = if is_abs(&f.name) {
563            copy_cap(&f.name, FD_PATH)
564        } else {
565            let mut t = copy_cap(&f.dir, FD_PATH);
566            join(&mut t, &f.name.clone());
567            t
568        };
569        // a path that filled the buffer was cut: refuse it
570        if tmp.len() >= FD_PATH - 16 {
571            f.msg = Some(tr("Der Pfad ist zu lang."));
572            return false;
573        }
574        tmp = canon(&norm(&tmp));
575        let ex = super::perm::trusted(|| plat::file_exists(&tmp));
576        if ex == 2 {
577            // a folder name: go there
578            self.m.filedlg.set_dir(&tmp);
579            self.fd_load();
580            self.m.filedlg.name.clear();
581            return false;
582        }
583        let f = &mut self.m.filedlg;
584        if flags & FD_SAVE == 0 {
585            if ex != 1 {
586                f.msg = Some(tr("Die Datei wurde nicht gefunden."));
587                return false;
588            }
589        } else if !f.exts.is_empty() && !f.exts[0].is_empty() {
590            // append the first extension when the name has none
591            let base = match tmp.rfind('/') {
592                Some(k) => &tmp[k..],
593                None => &tmp[..],
594            };
595            if !base.contains('.') {
596                cat_cap(&mut tmp, ".", FD_PATH);
597                // the selected extension (flags bits 8..15), without its "=description"
598                let k = ((flags >> FD_EXT_SHIFT) & 0xFF) as usize;
599                let e = f.exts.get(k).filter(|e| !e.is_empty()).unwrap_or(&f.exts[0]);
600                let e = e.split(['=', ';']).next().unwrap_or("");
601                if tmp.len() + e.len() >= FD_PATH - 1 {
602                    f.msg = Some(tr("Der Pfad ist zu lang.")); // the extension does not fit
603                    return false;
604                }
605                tmp.push_str(e);
606            }
607        }
608        *path = copy_cap(&tmp, cap as usize);
609        f.last = copy_cap(&f.dir, FD_PATH);
610        true
611    }
612
613    // fd_load: list the folder (folders first, then files matching the extensions)
614    fn fd_load(&mut self) {
615        let f = &mut self.m.filedlg;
616        f.idx.clear();
617        f.store.clear();
618        f.sel = -1;
619        f.msg = None;
620        let dir = f.dir.clone();
621        let mut ents: Vec<(String, bool)> = Vec::new();
622        super::perm::trusted(|| plat::dir_list(&dir, &mut |name: &str, is_dir: bool, _size: u32| ents.push((String::from(name), is_dir))));
623        let f = &mut self.m.filedlg;
624        for (name, is_dir) in ents {
625            // hidden entries, "." and ".."; choosing a folder: folders only
626            if name.starts_with('.') || (!is_dir && (f.folder || !f.ext_ok(&name))) {
627                continue;
628            }
629            if f.idx.len() >= FD_MAX || f.store.len() + name.len() + 1 > FD_STORE {
630                continue;
631            }
632            let off = f.store.len() as u32;
633            f.store.extend_from_slice(name.as_bytes());
634            f.store.push(0);
635            f.idx.push(if is_dir { off | FD_DIRBIT } else { off });
636        }
637        f.sort();
638        self.repass();
639    }
640}