feat(plugin): ModuleEvent::PluginFileDialog for native file import
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 "<command> <path>" 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 <noreply@anthropic.com>
This commit is contained in:
parent
844069b4ef
commit
50825b6346
8 changed files with 107 additions and 7 deletions
|
|
@ -17,6 +17,21 @@ pub enum ModuleEvent {
|
||||||
SetWireframe(bool),
|
SetWireframe(bool),
|
||||||
/// Toggle the layer manager panel.
|
/// Toggle the layer manager panel.
|
||||||
ToggleLayers,
|
ToggleLayers,
|
||||||
|
/// Ask the host to open a native file picker. On selection the host
|
||||||
|
/// dispatches `"<command> <path>"` 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<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data types ────────────────────────────────────────────────────────────
|
// ── Data types ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
- [~] 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] 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] 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 `"<command> <path>"` back to the plugin with original case preserved (bypasses the command-line upper-casing)
|
||||||
|
|
||||||
### Phase 2 — Dynamic loading (desktop)
|
### Phase 2 — Dynamic loading (desktop)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -722,6 +722,13 @@ pub enum Message {
|
||||||
tool_id: String,
|
tool_id: String,
|
||||||
event: ModuleEvent,
|
event: ModuleEvent,
|
||||||
},
|
},
|
||||||
|
/// Result of a plugin-requested file picker (`ModuleEvent::PluginFileDialog`).
|
||||||
|
/// `path` is `None` when the user cancels. On `Some`, the host dispatches
|
||||||
|
/// `"<command> <path>"` to the plugins with original case preserved.
|
||||||
|
PluginFileDialogResult {
|
||||||
|
command: String,
|
||||||
|
path: Option<std::path::PathBuf>,
|
||||||
|
},
|
||||||
// ── Application menu ──────────────────────────────────────────────────
|
// ── Application menu ──────────────────────────────────────────────────
|
||||||
ToggleAppMenu,
|
ToggleAppMenu,
|
||||||
CloseAppMenu,
|
CloseAppMenu,
|
||||||
|
|
|
||||||
|
|
@ -1027,6 +1027,42 @@ impl OpenCADStudio {
|
||||||
ModuleEvent::ToggleLayers => {
|
ModuleEvent::ToggleLayers => {
|
||||||
return Task::done(Message::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 "<command> <path>" 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()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
use crate::plugin::host::HostSession;
|
use crate::plugin::host::HostSession;
|
||||||
|
|
||||||
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool {
|
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||||
|
// "DP_IMPORT <path>" 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 {
|
match cmd {
|
||||||
"DP_HELLO" => {
|
"DP_HELLO" => {
|
||||||
host.push_info("Hello from demo_plugin (plugin host OK).");
|
host.push_info("Hello from demo_plugin (plugin host OK).");
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ pub mod register;
|
||||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||||
|
|
||||||
inventory::submit!(crate::command::CommandRegistration {
|
inventory::submit!(crate::command::CommandRegistration {
|
||||||
names: &["DP_HELLO"]
|
names: &["DP_HELLO", "DP_IMPORT"]
|
||||||
});
|
});
|
||||||
|
|
||||||
pub struct DemoPluginModule;
|
pub struct DemoPluginModule;
|
||||||
|
|
@ -25,12 +25,27 @@ impl CadModule for DemoPluginModule {
|
||||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||||
vec![RibbonGroup {
|
vec![RibbonGroup {
|
||||||
title: "Smoke",
|
title: "Smoke",
|
||||||
tools: vec![RibbonItem::LargeTool(ToolDef {
|
tools: vec![
|
||||||
id: "DP_HELLO",
|
RibbonItem::LargeTool(ToolDef {
|
||||||
label: "Hello",
|
id: "DP_HELLO",
|
||||||
icon: IconKind::Glyph("★"),
|
label: "Hello",
|
||||||
event: ModuleEvent::Command("DP_HELLO".to_string()),
|
icon: IconKind::Glyph("★"),
|
||||||
})],
|
event: ModuleEvent::Command("DP_HELLO".to_string()),
|
||||||
|
}),
|
||||||
|
// Exercises ModuleEvent::PluginFileDialog: the host opens a
|
||||||
|
// native picker and dispatches "DP_IMPORT <path>" 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()],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,20 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_file_dialog_dispatch_preserves_case() {
|
||||||
|
// ModuleEvent::PluginFileDialog dispatches "<command> <path>" 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]
|
#[test]
|
||||||
fn unknown_plugin_command_falls_through() {
|
fn unknown_plugin_command_falls_through() {
|
||||||
let mut app = OpenCADStudio::new_for_test();
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
|
|
||||||
|
|
@ -1269,6 +1269,12 @@ pub fn module_event_to_message(event: ModuleEvent) -> Message {
|
||||||
ModuleEvent::ClearModels => Message::ClearScene,
|
ModuleEvent::ClearModels => Message::ClearScene,
|
||||||
ModuleEvent::SetWireframe(w) => Message::SetWireframe(w),
|
ModuleEvent::SetWireframe(w) => Message::SetWireframe(w),
|
||||||
ModuleEvent::ToggleLayers => Message::ToggleLayers,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue