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}