From ec865a95aad55569881d31adea34923bc76087eb Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 15 Jun 2026 01:46:09 +0300 Subject: [PATCH] feat(plugin): add a Plugin Manager window listing installed add-ons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only inventory of the add-ons compiled into the build — name, version, id, API level, description, command prefixes — built from the extracted `ocs_plugin_api` manifest. Opened with the `PLUGINS` / `PLUGINMANAGER` command or the Start-page "Plugins" button. Adds `installed_manifests()` to the plugin registry and wires the window the same way as the existing About / Shortcuts windows. Part of #100 (phase-1 plugin manager UI stub). Co-Authored-By: Claude Opus 4.8 --- docs/plugin-architecture.md | 2 +- src/app/commands.rs | 5 ++ src/app/mod.rs | 9 +++ src/app/update.rs | 25 ++++++ src/app/view.rs | 4 + src/plugin/mod.rs | 2 +- src/plugin/registry.rs | 29 +++++++ src/ui/mod.rs | 1 + src/ui/plugin_manager.rs | 146 ++++++++++++++++++++++++++++++++++++ 9 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 src/ui/plugin_manager.rs diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 657d3aab..1c21d050 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -255,7 +255,7 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with - [x] Storm Sewer off `commands.rs` monolith - [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`) - [~] Extract `ocs_plugin_api` crate — manifest + ribbon/`CadModule` done; `acadrust`-typed host surface pending -- [ ] Plugin manager UI stub (list installed, versions) +- [x] Plugin manager UI stub (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button ### Phase 2 — Dynamic loading (desktop) diff --git a/src/app/commands.rs b/src/app/commands.rs index 8154d8ff..a091055a 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -36,6 +36,7 @@ impl OpenCADStudio { && !matches!( cmd, "NEW" | "OPEN" | "EXIT" | "QUIT" | "REPORT" | "CHANGELOG" | "ABOUT" + | "PLUGINS" | "PLUGINMANAGER" ) { self.command_line @@ -2675,6 +2676,10 @@ impl OpenCADStudio { return Task::done(Message::AboutOpen); } + "PLUGINS" | "PLUGINMANAGER" => { + return Task::done(Message::PluginManagerOpen); + } + "CHANGELOG" => { let _ = open::that("https://github.com/HakanSeven12/OpenCADStudio/releases"); self.command_line.push_info("Opening release notes..."); diff --git a/src/app/mod.rs b/src/app/mod.rs index 7fefa5a6..adac1947 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -292,6 +292,7 @@ pub(super) struct OpenCADStudio { color_pick_target: Option, shortcuts_window: Option, about_window: Option, + plugin_manager_window: Option, /// New-release notification window — opened on startup when the /// GitHub releases API reports a newer version than this build. update_notice_window: Option, @@ -987,6 +988,10 @@ pub enum Message { // ── About window ──────────────────────────────────────────────────── AboutOpen, AboutCopyInfo, + // ── Plugin Manager window ─────────────────────────────────────────── + PluginManagerOpen, + #[allow(dead_code)] + PluginManagerClose, // ── Quick Select / Select Similar ─────────────────────────────────── /// Extend the current selection with every entity in the active /// layout that matches a selected entity by (type, layer). @@ -1402,6 +1407,7 @@ impl OpenCADStudio { color_pick_target: None, shortcuts_window: None, about_window: None, + plugin_manager_window: None, update_notice_window: None, assoc_prompt_window: None, default_assoc_prompted: false, @@ -1712,6 +1718,9 @@ pub fn run() -> iced::Result { if Some(window_id) == state.about_window { return "About Open CAD Studio".into(); } + if Some(window_id) == state.plugin_manager_window { + return "Plugin Manager".into(); + } if Some(window_id) == state.update_notice_window { return "Update Available".into(); } diff --git a/src/app/update.rs b/src/app/update.rs index f1ca68c3..df4afa92 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1640,6 +1640,9 @@ impl OpenCADStudio { self.about_window = None; self.ribbon.deactivate_tool_if("ABOUT"); } + if self.plugin_manager_window == Some(id) { + self.plugin_manager_window = None; + } if self.update_notice_window == Some(id) { self.update_notice_window = None; } @@ -5394,6 +5397,28 @@ impl OpenCADStudio { iced::clipboard::write(info) } + // ── Plugin Manager window ───────────────────────────────────── + Message::PluginManagerOpen => { + if let Some(id) = self.plugin_manager_window { + return window::gain_focus(id); + } + let (id, task) = window::open(window::Settings { + size: iced::Size::new(520.0, 460.0), + resizable: true, + level: window::Level::AlwaysOnTop, + ..Default::default() + }); + self.plugin_manager_window = Some(id); + task.map(|_| Message::Noop) + } + Message::PluginManagerClose => { + if let Some(id) = self.plugin_manager_window.take() { + window::close(id) + } else { + Task::none() + } + } + Message::EnterViewport(handle) => { let i = self.active_tab; // Clear paper-space selection before entering model space. diff --git a/src/app/view.rs b/src/app/view.rs index b18b6445..87956899 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -474,6 +474,9 @@ impl OpenCADStudio { if Some(window_id) == self.about_window { return crate::ui::about::view_window(); } + if Some(window_id) == self.plugin_manager_window { + return crate::ui::plugin_manager::view_window(&crate::plugin::installed_manifests()); + } if Some(window_id) == self.update_notice_window { let latest = self.update_notice_version.as_deref().unwrap_or("?"); let body = self.update_notice_body.as_deref().unwrap_or(""); @@ -3743,6 +3746,7 @@ pub(super) fn start_page_view<'a>() -> Element<'a, Message> { event: crate::modules::ModuleEvent::Command("CHANGELOG".to_string()), }, ), + outline_btn("Plugins", Message::PluginManagerOpen), outline_btn("About", Message::AboutOpen), ] .spacing(12) diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 158ed223..883cc4db 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -10,4 +10,4 @@ pub mod registry; pub use registry::all_ribbon_modules; pub(crate) use host::BuiltinPlugin; -pub(crate) use registry::try_dispatch; \ No newline at end of file +pub(crate) use registry::{installed_manifests, try_dispatch}; \ No newline at end of file diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index c7b1e9f0..8c8e96c7 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -1,6 +1,7 @@ // Compile-time plugin registry via `inventory`. use super::host::{BuiltinPlugin, HostSession}; +use super::manifest::PluginManifest; use crate::app::OpenCADStudio; use crate::modules::{registry as core_registry, CadModule}; @@ -18,6 +19,15 @@ pub fn all_plugins() -> Vec> { .collect() } +/// Static manifest of every installed add-on, sorted by `ribbon_order` then id +/// for a stable display. Used by the plugin manager window. +pub fn installed_manifests() -> Vec<&'static PluginManifest> { + let mut manifests: Vec<&'static PluginManifest> = + all_plugins().iter().map(|p| p.manifest()).collect(); + manifests.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(b.id))); + manifests +} + /// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`). pub fn all_ribbon_modules() -> Vec> { let mut core = core_registry::all_modules(); @@ -61,6 +71,25 @@ mod tests { ); } + #[test] + fn installed_manifests_lists_demo_plugin() { + let manifests = installed_manifests(); + assert!( + manifests.iter().any(|m| m.id == "opencad.demo_plugin"), + "ids: {:?}", + manifests.iter().map(|m| m.id).collect::>() + ); + // Sorted by (ribbon_order, id) — verify non-decreasing order. + let mut prev: Option<(i32, &str)> = None; + for m in &manifests { + let key = (m.ribbon_order, m.id); + if let Some(p) = prev { + assert!(p <= key, "manifests not sorted: {p:?} then {key:?}"); + } + prev = Some(key); + } + } + #[test] fn addon_ribbon_tabs_merge_after_core() { let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 17b6ce4b..28698e60 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -17,6 +17,7 @@ pub mod open_progress; pub mod overlay; pub mod page_setup; pub mod plotstyle; +pub mod plugin_manager; pub mod properties; pub mod ribbon; pub mod scale_popup; diff --git a/src/ui/plugin_manager.rs b/src/ui/plugin_manager.rs new file mode 100644 index 00000000..cf42fdd4 --- /dev/null +++ b/src/ui/plugin_manager.rs @@ -0,0 +1,146 @@ +//! Plugin Manager window — lists the add-ons compiled into this build. +//! +//! Phase-1 stub: read-only inventory of installed plugins (name, version, id, +//! API level, description). Enable/disable and dynamic loading come with the +//! phase-2 loader; see `docs/plugin-architecture.md`. + +use crate::app::Message; +use crate::plugin::manifest::PluginManifest; +use iced::widget::{column, container, row, scrollable, text, Space}; +use iced::{Background, Border, Color, Element, Fill, Theme}; + +// Register the command names for autocomplete. +inventory::submit!(crate::command::CommandRegistration { + names: &["PLUGINS", "PLUGINMANAGER"] +}); + +const BG: Color = Color { + r: 0.15, + g: 0.15, + b: 0.15, + a: 1.0, +}; +const CARD: Color = Color { + r: 0.12, + g: 0.12, + b: 0.12, + a: 1.0, +}; +const BORDER: Color = Color { + r: 0.30, + g: 0.30, + b: 0.30, + a: 1.0, +}; +const DIM: Color = Color { + r: 0.55, + g: 0.55, + b: 0.55, + a: 1.0, +}; +const ACCENT: Color = Color { + r: 0.30, + g: 0.62, + b: 0.95, + a: 1.0, +}; +const WHITE: Color = Color { + r: 0.92, + g: 0.92, + b: 0.92, + a: 1.0, +}; + +fn badge<'a>(label: String) -> Element<'a, Message> { + container(text(label).size(11).color(WHITE)) + .padding([2, 8]) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.20, + g: 0.34, + b: 0.52, + a: 1.0, + })), + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} + +fn plugin_card<'a>(m: &PluginManifest) -> Element<'a, Message> { + let header = row![ + text(m.name.to_string()).size(15).color(WHITE), + Space::new().width(Fill), + badge(format!("v{}", m.version)), + Space::new().width(8), + badge(format!("API {}", m.api_version.major)), + ] + .align_y(iced::Center); + + let id_line = text(m.id.to_string()).size(11).color(ACCENT); + let desc = text(m.description.to_string()).size(12).color(DIM); + + let mut body = column![header, id_line, desc].spacing(5); + + if !m.command_prefixes.is_empty() { + body = body.push( + text(format!("Commands: {}", m.command_prefixes.join(", "))) + .size(11) + .color(DIM), + ); + } + + container(body.padding([12, 14])) + .width(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(CARD)), + border: Border { + color: BORDER, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }) + .into() +} + +pub fn view_window<'a>(plugins: &[&'static PluginManifest]) -> Element<'a, Message> { + let title = text("Installed Plugins").size(20).color(WHITE); + let subtitle = text(format!( + "{} add-on{} compiled into this build", + plugins.len(), + if plugins.len() == 1 { "" } else { "s" } + )) + .size(12) + .color(DIM); + + let body: Element<'_, Message> = if plugins.is_empty() { + container(text("No plugins installed.").size(13).color(DIM)) + .padding(20) + .into() + } else { + let mut list = column![].spacing(10); + for m in plugins { + list = list.push(plugin_card(m)); + } + scrollable(list.width(Fill)).height(Fill).into() + }; + + container( + column![title, subtitle, Space::new().height(12), body] + .spacing(4) + .padding(20) + .width(Fill) + .height(Fill), + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .width(Fill) + .height(Fill) + .into() +}