From 50825b634687776f5b0e46f68d29c653ad16a16c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 10:22:08 +0300 Subject: [PATCH] feat(plugin): ModuleEvent::PluginFileDialog for native file import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add-ons had no way to request a file picker — only the host's own Open dialog. PluginFileDialog lets a plugin tool ask the host to open a native picker; on selection the host dispatches " " back to the plugin with original case preserved (bypassing the command-line upper-casing that mangles case-sensitive paths on Linux/macOS). The demo plugin gains an Import tool exercising it. Part of the #100 extensibility epic (surfaced in #106). Co-Authored-By: Claude Opus 4.8 --- crates/ocs_plugin_api/src/ribbon.rs | 15 ++++++++++++ docs/plugin-architecture.md | 1 + src/app/mod.rs | 7 ++++++ src/app/update.rs | 36 +++++++++++++++++++++++++++++ src/modules/demo_plugin/dispatch.rs | 6 +++++ src/modules/demo_plugin/mod.rs | 29 +++++++++++++++++------ src/plugin/registry.rs | 14 +++++++++++ src/ui/ribbon/widgets.rs | 6 +++++ 8 files changed, 107 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/ribbon.rs b/crates/ocs_plugin_api/src/ribbon.rs index bb782cc2..7d01e2f5 100644 --- a/crates/ocs_plugin_api/src/ribbon.rs +++ b/crates/ocs_plugin_api/src/ribbon.rs @@ -17,6 +17,21 @@ pub enum ModuleEvent { SetWireframe(bool), /// Toggle the layer manager panel. ToggleLayers, + /// Ask the host to open a native file picker. On selection the host + /// dispatches `" "` back to the plugin (full original case, + /// bypassing the command line so case-sensitive paths/args survive); on + /// cancel nothing happens. Lets an add-on import files without owning any + /// dialog UI. + PluginFileDialog { + /// Plugin command to dispatch with the chosen path appended. + command: String, + /// Dialog window title. + title: String, + /// Human label for the file-type filter (e.g. "PNEZD Points"). + filter_name: String, + /// Accepted extensions, without the dot (e.g. `["csv", "txt"]`). + extensions: Vec, + }, } // ── Data types ──────────────────────────────────────────────────────────── diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index f87bea04..80132dbb 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -257,6 +257,7 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with - [~] Extract `ocs_plugin_api` crate — manifest + ribbon/`CadModule` done; `acadrust`-typed host surface pending - [x] Plugin manager UI (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button - [x] Enable/disable plugins from the manager — a disabled plugin drops its ribbon tab and command dispatch; persisted in `settings.txt` (`disabled_plugins=`) +- [x] `ModuleEvent::PluginFileDialog` — a plugin tool requests a native file picker; the host opens it and dispatches `" "` back to the plugin with original case preserved (bypasses the command-line upper-casing) ### Phase 2 — Dynamic loading (desktop) diff --git a/src/app/mod.rs b/src/app/mod.rs index 477bf902..92aaa10b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -722,6 +722,13 @@ pub enum Message { tool_id: String, event: ModuleEvent, }, + /// Result of a plugin-requested file picker (`ModuleEvent::PluginFileDialog`). + /// `path` is `None` when the user cancels. On `Some`, the host dispatches + /// `" "` to the plugins with original case preserved. + PluginFileDialogResult { + command: String, + path: Option, + }, // ── Application menu ────────────────────────────────────────────────── ToggleAppMenu, CloseAppMenu, diff --git a/src/app/update.rs b/src/app/update.rs index 975e21b7..b1b232ab 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1027,6 +1027,42 @@ impl OpenCADStudio { ModuleEvent::ToggleLayers => { return Task::done(Message::ToggleLayers); } + ModuleEvent::PluginFileDialog { + command, + title, + filter_name, + extensions, + } => { + return Task::perform( + async move { + let exts: Vec<&str> = + extensions.iter().map(|s| s.as_str()).collect(); + let path = rfd::AsyncFileDialog::new() + .set_title(title) + .add_filter(filter_name, &exts) + .add_filter("All Files", &["*"]) + .pick_file() + .await + .map(|h| crate::sys::handle_path(&h)); + (command, path) + }, + |(command, path)| Message::PluginFileDialogResult { command, path }, + ); + } + } + Task::none() + } + Message::PluginFileDialogResult { command, path } => { + if let Some(path) = path { + // Dispatch " " with original case intact — + // the command line would upper-case the whole string and + // mangle case-sensitive paths on Linux/macOS. + let line = format!("{} {}", command, path.to_string_lossy()); + let i = self.active_tab; + if !crate::plugin::try_dispatch(self, i, &line) { + self.command_line + .push_error(&format!("No plugin handled: {command}")); + } } Task::none() } diff --git a/src/modules/demo_plugin/dispatch.rs b/src/modules/demo_plugin/dispatch.rs index 82e604f9..d4344e0d 100644 --- a/src/modules/demo_plugin/dispatch.rs +++ b/src/modules/demo_plugin/dispatch.rs @@ -1,6 +1,12 @@ use crate::plugin::host::HostSession; pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { + // "DP_IMPORT " arrives from ModuleEvent::PluginFileDialog with the + // path in its original case (the command line is bypassed). + if let Some(path) = cmd.strip_prefix("DP_IMPORT ") { + host.push_info(&format!("demo_plugin imported: {path}")); + return true; + } match cmd { "DP_HELLO" => { host.push_info("Hello from demo_plugin (plugin host OK)."); diff --git a/src/modules/demo_plugin/mod.rs b/src/modules/demo_plugin/mod.rs index 7b374326..7e5ebd51 100644 --- a/src/modules/demo_plugin/mod.rs +++ b/src/modules/demo_plugin/mod.rs @@ -8,7 +8,7 @@ pub mod register; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; inventory::submit!(crate::command::CommandRegistration { - names: &["DP_HELLO"] + names: &["DP_HELLO", "DP_IMPORT"] }); pub struct DemoPluginModule; @@ -25,12 +25,27 @@ impl CadModule for DemoPluginModule { fn ribbon_groups(&self) -> Vec { vec![RibbonGroup { title: "Smoke", - tools: vec![RibbonItem::LargeTool(ToolDef { - id: "DP_HELLO", - label: "Hello", - icon: IconKind::Glyph("★"), - event: ModuleEvent::Command("DP_HELLO".to_string()), - })], + tools: vec![ + RibbonItem::LargeTool(ToolDef { + id: "DP_HELLO", + label: "Hello", + icon: IconKind::Glyph("★"), + event: ModuleEvent::Command("DP_HELLO".to_string()), + }), + // Exercises ModuleEvent::PluginFileDialog: the host opens a + // native picker and dispatches "DP_IMPORT " back here. + RibbonItem::LargeTool(ToolDef { + id: "DP_IMPORT", + label: "Import", + icon: IconKind::Glyph("📂"), + event: ModuleEvent::PluginFileDialog { + command: "DP_IMPORT".to_string(), + title: "Import Demo File".to_string(), + filter_name: "Text".to_string(), + extensions: vec!["txt".to_string(), "csv".to_string()], + }, + }), + ], }] } } diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index fbc5949b..e562b9c0 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -139,6 +139,20 @@ mod tests { ); } + #[test] + fn plugin_file_dialog_dispatch_preserves_case() { + // ModuleEvent::PluginFileDialog dispatches " " verbatim; + // the mixed-case path must reach the plugin unaltered. + let mut app = OpenCADStudio::new_for_test(); + let line = "DP_IMPORT /home/User/My Points.CSV"; + assert!(try_dispatch(&mut app, 0, line)); + let info = app.command_history_info(); + assert!( + info.iter().any(|t| t.contains("/home/User/My Points.CSV")), + "path case not preserved; info: {info:?}" + ); + } + #[test] fn unknown_plugin_command_falls_through() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index e39c4c49..c46a946b 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -1269,6 +1269,12 @@ pub fn module_event_to_message(event: ModuleEvent) -> Message { ModuleEvent::ClearModels => Message::ClearScene, ModuleEvent::SetWireframe(w) => Message::SetWireframe(w), ModuleEvent::ToggleLayers => Message::ToggleLayers, + // Needs the tool context + async picker — route through the normal + // ribbon-click handler rather than a direct 1:1 message. + e @ ModuleEvent::PluginFileDialog { .. } => Message::RibbonToolClick { + tool_id: String::new(), + event: e, + }, } }