Merge pull request #6687 from dotCooCoo/fix/menu-list-recent-confined

Scripts: opening a macOS menu reads the Scripts folder again
This commit is contained in:
BFlat
2026-11-12 09:14:22 +07:00
committed by GitHub
5 changed files with 76 additions and 9 deletions
+9 -1
View File
@@ -30,6 +30,9 @@ use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole,
// AppKit can deliver window input while its system menu is still tracking (especially with
// asynchronous menu presentation). Keep the native tracking state separate from egui popups.
static TRACKING: AtomicBool = AtomicBool::new(false);
/// A tracking session began since the last [`Backend::drain`], which reports it as
/// [`Event::Opened`].
static OPENED: AtomicBool = AtomicBool::new(false);
pub(crate) fn is_tracking() -> bool {
TRACKING.load(Ordering::Relaxed)
@@ -46,9 +49,11 @@ fn observe_tracking(ctx: &egui::Context) {
let block = RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
TRACKING.store(tracking, Ordering::Relaxed);
if tracking {
OPENED.store(true, Ordering::Relaxed);
super::mac_cursor::release();
}
// Apply any deferred menu updates as soon as the tracking session ends.
// Report the opened menu and apply any deferred menu updates as soon as the
// tracking session ends.
if !tracking {
repaint.request_repaint();
}
@@ -267,6 +272,9 @@ impl Backend for MacMenu {
fn drain(&mut self) -> Vec<Event> {
let mut out = Vec::new();
if OPENED.swap(false, Ordering::Relaxed) {
out.push(Event::Opened);
}
while let Ok(chosen) = self.rx.try_recv() {
cancel_tracking();
// Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled.
+18 -4
View File
@@ -499,6 +499,9 @@ pub enum Event {
/// A key equivalent pressed: the item's key with the modifiers held, to be handled as the key
/// press it was.
Key(KeyboardShortcut),
/// A menu of the bar opened. The Scripts folder is read again, as when the in-window File menu
/// opens; the new listing shows the next time the menu opens.
Opened,
}
/// A platform's native menu bar (the desktop app implements it for macOS with muda).
@@ -517,6 +520,8 @@ pub struct NativeMenu {
/// Hash of the state the menus were last read in ([`state_hash`]).
state: Option<u64>,
clicks: Vec<Item>,
/// A menu opened since the last [`run`].
opened: bool,
/// Something was chosen (AppKit may have toggled a check mark itself): read the menus again.
dirty: bool,
logged: bool,
@@ -524,17 +529,21 @@ pub struct NativeMenu {
impl NativeMenu {
pub fn new(backend: Box<dyn Backend>) -> NativeMenu {
NativeMenu { backend, state: None, clicks: Vec::new(), dirty: true, logged: false }
NativeMenu { backend, state: None, clicks: Vec::new(), opened: false, dirty: true, logged: false }
}
/// The key equivalents pressed since the last frame, for [`key_events`]; clicks wait for
/// [`run`].
/// The key equivalents pressed since the last frame, for [`key_events`]; clicks and menus
/// opened wait for [`run`].
pub fn take_keys(&mut self) -> Vec<KeyboardShortcut> {
let mut keys = Vec::new();
for e in self.backend.drain() {
match e {
Event::Key(k) => keys.push(k),
Event::Click(it) => self.clicks.push(it),
Event::Opened => {
self.opened = true;
continue;
}
}
self.dirty = true;
}
@@ -542,10 +551,15 @@ impl NativeMenu {
}
}
/// Run the items clicked in the native menu since the last frame, like in-window menu clicks.
/// Run the items clicked in the native menu since the last frame, like in-window menu clicks, and
/// read the Scripts folder again when a menu opened.
pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) {
let picking = app.file_dialog_open();
let Some(menu) = app.services.native_menu.as_mut() else { return };
if std::mem::take(&mut menu.opened) {
crate::scripts_menu::refresh(app, Some(ctx));
}
let Some(menu) = app.services.native_menu.as_mut() else { return };
for it in std::mem::take(&mut menu.clicks) {
// A click that raced the menu going inert under a file dialog.
if picking && !modal_allows(&it) {
+44
View File
@@ -353,6 +353,50 @@ fn the_folder_is_read_at_the_first_frame_and_when_the_window_becomes_active() {
let _ = std::fs::remove_dir_all(dir);
}
/// A macOS menu bar that reports a menu opening at its next drain when `0` is set.
struct Opens(std::rc::Rc<std::cell::Cell<bool>>);
impl native_menu::Backend for Opens {
fn sync(&mut self, _: &native_menu::MenuBar) {}
fn drain(&mut self) -> Vec<native_menu::Event> {
if self.0.replace(false) { vec![native_menu::Event::Opened] } else { Vec::new() }
}
}
#[test]
fn a_menu_of_the_macos_menu_bar_opening_reads_the_folder_again() {
let dir = folder("native-open");
let mut app = app(&dir);
write(&dir, "first.js", "");
read(&app);
let opens = std::rc::Rc::new(std::cell::Cell::new(false));
app.services.native_menu = Some(native_menu::NativeMenu::new(Box::new(Opens(opens.clone()))));
let ctx = egui::Context::default();
let mut frame = |opened: bool| {
opens.set(opened);
assert_eq!(app.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys), Some(Vec::new()));
native_menu::run(&mut app, &ctx);
scripts_menu::wait();
native_menu::layout(&app).bar
};
let listed = |bar: &native_menu::MenuBar| {
let file = bar.menus.iter().find(|m| m.title == "File").unwrap();
let scripts = file.children.iter().find_map(|n| match n {
Node::Submenu { label, children } if label == "Scripts" => Some(children.as_slice()),
_ => None,
});
scripts.unwrap_or_default().iter().filter_map(|n| if let Node::Item(it) = n { Some(it.label.clone()) } else { None }).collect::<Vec<_>>()
};
// While the window stays active and no menu opens, the folder is left alone.
write(&dir, "second.js", "");
let bar = frame(false);
assert_eq!(listed(&bar)[..1], ["first"], "{:?}", listed(&bar));
assert!(!listed(&bar).iter().any(|l| l == "second"), "{:?}", listed(&bar));
let bar = frame(true);
assert_eq!(listed(&bar)[..2], ["first", "second"], "{:?}", listed(&bar));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_read_asked_for_during_another_runs_when_that_one_ends() {
let dir = folder("pending");
+4 -3
View File
@@ -1953,9 +1953,10 @@ way. The listed scripts come first in the menu, above Check Script… and Other
The rest of this section is VectorCraft's own rules:
- The folder is read when the app starts, each time its window becomes the active one, and, where the menus are in
the window (Windows, Linux and BSD), each time the File menu opens. A script added while VectorCraft runs is listed
after the next of these. A read takes names only, never a script's text, and runs off the UI thread, one read at a
- The folder is read when the app starts, each time its window becomes the active one, each time the File menu opens
where the menus are in the window (Windows, Linux and BSD), and each time a menu of the macOS menu bar opens. A
script added while VectorCraft runs is listed after the next of these; in the macOS menu bar it shows the next time
the menu opens. A read takes names only, never a script's text, and runs off the UI thread, one read at a
time.
- Each level is sorted by label without regard to case, with scripts and subfolders mixed. A script whose label
another row of its folder has (`Tidy.js` beside `Tidy.jsx`, or beside a subfolder `Tidy`) shows its whole file name.
+1 -1
View File
@@ -744,7 +744,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
- Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`.
- Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`).
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`).
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.