feat(plugin): add a Plugin Manager window listing installed add-ons

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-15 01:46:09 +03:00
commit ec865a95aa
9 changed files with 221 additions and 2 deletions

View file

@ -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)

View file

@ -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...");

View file

@ -292,6 +292,7 @@ pub(super) struct OpenCADStudio {
color_pick_target: Option<ColorPickTarget>,
shortcuts_window: Option<window::Id>,
about_window: Option<window::Id>,
plugin_manager_window: Option<window::Id>,
/// New-release notification window — opened on startup when the
/// GitHub releases API reports a newer version than this build.
update_notice_window: Option<window::Id>,
@ -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();
}

View file

@ -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.

View file

@ -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)

View file

@ -10,4 +10,4 @@ pub mod registry;
pub use registry::all_ribbon_modules;
pub(crate) use host::BuiltinPlugin;
pub(crate) use registry::try_dispatch;
pub(crate) use registry::{installed_manifests, try_dispatch};

View file

@ -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<Box<dyn BuiltinPlugin>> {
.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<Box<dyn CadModule>> {
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::<Vec<_>>()
);
// 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();

View file

@ -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;

146
src/ui/plugin_manager.rs Normal file
View file

@ -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()
}