Skip to main content

blockworx/tools/
file_menu.rs

1//! The File menu: which diagram the session is editing, and where it lives.
2//!
3//! One thing opens here, and it is a diagram — a `.bwx` container, which on
4//! disk is a directory with that suffix. There is no second entry for the
5//! `document.json` beside the log: that file is a *projection*, carrying
6//! neither the log nor the assets, so it is not a diagram anybody can open
7//! (R53). It is still what an export writes and what the CLI will read for a
8//! developer.
9//!
10//! The document bar's File section — a cascading dropdown, no dialogs of our
11//! own. Nothing here writes *changes*: a container takes every commit as it
12//! is made (F5), so there is no save action and no UI implies one (invariant
13//! 10) — and nothing here refreshes `document.json` either, since the
14//! projection keeps itself fresh (R31). "Save as…" is the one entry that
15//! gives a scratch session a file for the first time.
16//!
17//! Native only: the browser has no container to open.
18
19use std::path::{Path, PathBuf};
20
21use egui::{TextWrapMode, Ui};
22
23use crate::file::{FileRequest, SaveScope};
24use crate::tools::tool::Action;
25use blockworx_store::doc::Viewing;
26
27/// See `preferences_menu::no_wrap`: menu popups shrink-to-fit and proportional
28/// fonts can round a label just past the popup width, wrapping the last glyph.
29fn no_wrap(ui: &mut Ui) {
30    ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
31}
32
33/// Populate the File button's dropdown. Returns the action a click
34/// requested. `home` is the container this session is attached to, or `None`
35/// for a scratch session, which has nothing on disk to share.
36pub fn menu(
37    ui: &mut Ui,
38    recent: &[PathBuf],
39    viewing: Viewing,
40    home: Option<&Path>,
41) -> Option<Action> {
42    no_wrap(ui);
43    let mut action = None;
44    if ui.button("New diagram").clicked() {
45        action = Some(Action::NewDocument);
46    }
47    if ui.button("Open diagram…").clicked() {
48        action = Some(Action::PickFile(FileRequest::OpenContainer));
49    }
50    if ui
51        .button("Open shared diagram…")
52        .on_hover_text("Open a diagram someone sent as a .zip \u{2014} it unpacks beside the file")
53        .clicked()
54    {
55        action = Some(Action::PickFile(FileRequest::OpenBundle));
56    }
57    if let Some(picked) = recent_menu(ui, recent) {
58        action = Some(Action::OpenRecent(picked));
59    }
60    ui.separator();
61    // No "Refresh document.json" here. The user: *"'Refresh the
62    // document.json' seems weird as a menu option - I don't know why this
63    // would be needed."* — and they are right, because it is not: the
64    // projection has kept itself fresh on its own since the debounced idle
65    // refresh landed, so the entry offered to do by hand a thing that had
66    // already happened. It stays in the registry, reachable from the
67    // palette, for the one case the timer will not touch: a projection
68    // somebody hand-edited, which is `Freshness::Unrecognized` and must be
69    // overwritten deliberately or not at all (R31).
70    // One entry, whose meaning is the rev on the canvas (R37). While the
71    // lens is open it writes the log through *that* rev, so a reader can
72    // take the document as they are looking at it and edit on top of it;
73    // at head it is the Save-as it has always been. A second "Save rev
74    // as…" would differ only by a condition the pill overhead is already
75    // announcing.
76    let scope = SaveScope::from(viewing);
77    if ui
78        .button("Save as…")
79        .on_hover_text(save_as_hint(scope))
80        .clicked()
81    {
82        action = Some(Action::PickFile(FileRequest::SaveAsContainer(scope)));
83    }
84    if let Some(shared) = share(ui, home) {
85        action = Some(shared);
86    }
87    action
88}
89
90/// The share entry: this diagram as one file, which is the form that crosses
91/// an e-mail (R54). Whole history, always — the ledger's without-history
92/// bundle is deferred, since cutting at a rev is what Save-as is for.
93///
94/// Disabled rather than hidden on a scratch session, on R19's rule, and the
95/// disabled hover says the one thing that makes it live.
96fn share(ui: &mut Ui, home: Option<&Path>) -> Option<Action> {
97    let mut action = None;
98    ui.add_enabled_ui(home.is_some(), |ui| {
99        let pressed = ui
100            .button("Share…")
101            .on_hover_text("Write this diagram and its whole history to one .zip")
102            .on_disabled_hover_text("Save this diagram first \u{2014} there is nothing on disk yet")
103            .clicked();
104        if let (true, Some(home)) = (pressed, home) {
105            action = Some(Action::PickFile(FileRequest::ShareBundle(
106                crate::file::bundle_name(home),
107            )));
108        }
109    });
110    action
111}
112
113/// What Save-as promises, which is not the same sentence in both places it
114/// can be pressed from.
115fn save_as_hint(scope: SaveScope) -> String {
116    match scope {
117        SaveScope::Whole => {
118            "Give this diagram a home on disk; every change lands in it from here on".to_owned()
119        }
120        SaveScope::Through(at) => format!(
121            "Saves the diagram as shown \u{2014} through rev {}",
122            at.get(),
123        ),
124    }
125}
126
127/// The remembered containers, as a submenu. Disabled rather than hidden
128/// while nothing has been opened yet, so the menu keeps its shape.
129fn recent_menu(ui: &mut Ui, recent: &[PathBuf]) -> Option<PathBuf> {
130    let mut picked = None;
131    ui.add_enabled_ui(!recent.is_empty(), |ui| {
132        ui.menu_button("Open recent", |ui| {
133            no_wrap(ui);
134            for path in recent {
135                if ui
136                    .button(entry_label(path))
137                    .on_hover_text(path.display().to_string())
138                    .clicked()
139                {
140                    picked = Some(path.clone());
141                }
142            }
143        });
144    });
145    picked
146}
147
148/// A recent entry's label: the container's own name, since the whole path
149/// would run the menu off the screen. Two containers can share a name, which
150/// is what the hover text is for.
151fn entry_label(path: &Path) -> String {
152    crate::file::container_name(path)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    /// The hover says what the entry will do, and under the lens that is
160    /// not what it does at the head — the promise names the rev, so a
161    /// reader knows what they are about to get (R37).
162    #[test]
163    fn the_hover_promises_the_rev_that_will_be_written() {
164        assert_eq!(
165            save_as_hint(SaveScope::from(Viewing::Past(
166                blockworx_doc::fixtures::rev(7)
167            ))),
168            "Saves the diagram as shown \u{2014} through rev 7",
169        );
170        assert!(
171            !save_as_hint(SaveScope::from(Viewing::Head)).contains("rev"),
172            "at the present there is no rev to promise",
173        );
174    }
175
176    #[test]
177    fn a_recent_entry_reads_as_the_container_name() {
178        assert_eq!(
179            entry_label(Path::new("/home/ada/work/engine.bwx")),
180            "engine.bwx"
181        );
182    }
183}