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:
parent
72ec1146df
commit
ec865a95aa
9 changed files with 221 additions and 2 deletions
|
|
@ -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] Storm Sewer off `commands.rs` monolith
|
||||||
- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`)
|
- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`)
|
||||||
- [~] 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
|
||||||
- [ ] 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)
|
### Phase 2 — Dynamic loading (desktop)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ impl OpenCADStudio {
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
cmd,
|
cmd,
|
||||||
"NEW" | "OPEN" | "EXIT" | "QUIT" | "REPORT" | "CHANGELOG" | "ABOUT"
|
"NEW" | "OPEN" | "EXIT" | "QUIT" | "REPORT" | "CHANGELOG" | "ABOUT"
|
||||||
|
| "PLUGINS" | "PLUGINMANAGER"
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
self.command_line
|
self.command_line
|
||||||
|
|
@ -2675,6 +2676,10 @@ impl OpenCADStudio {
|
||||||
return Task::done(Message::AboutOpen);
|
return Task::done(Message::AboutOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"PLUGINS" | "PLUGINMANAGER" => {
|
||||||
|
return Task::done(Message::PluginManagerOpen);
|
||||||
|
}
|
||||||
|
|
||||||
"CHANGELOG" => {
|
"CHANGELOG" => {
|
||||||
let _ = open::that("https://github.com/HakanSeven12/OpenCADStudio/releases");
|
let _ = open::that("https://github.com/HakanSeven12/OpenCADStudio/releases");
|
||||||
self.command_line.push_info("Opening release notes...");
|
self.command_line.push_info("Opening release notes...");
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,7 @@ pub(super) struct OpenCADStudio {
|
||||||
color_pick_target: Option<ColorPickTarget>,
|
color_pick_target: Option<ColorPickTarget>,
|
||||||
shortcuts_window: Option<window::Id>,
|
shortcuts_window: Option<window::Id>,
|
||||||
about_window: Option<window::Id>,
|
about_window: Option<window::Id>,
|
||||||
|
plugin_manager_window: Option<window::Id>,
|
||||||
/// New-release notification window — opened on startup when the
|
/// New-release notification window — opened on startup when the
|
||||||
/// GitHub releases API reports a newer version than this build.
|
/// GitHub releases API reports a newer version than this build.
|
||||||
update_notice_window: Option<window::Id>,
|
update_notice_window: Option<window::Id>,
|
||||||
|
|
@ -987,6 +988,10 @@ pub enum Message {
|
||||||
// ── About window ────────────────────────────────────────────────────
|
// ── About window ────────────────────────────────────────────────────
|
||||||
AboutOpen,
|
AboutOpen,
|
||||||
AboutCopyInfo,
|
AboutCopyInfo,
|
||||||
|
// ── Plugin Manager window ───────────────────────────────────────────
|
||||||
|
PluginManagerOpen,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
PluginManagerClose,
|
||||||
// ── Quick Select / Select Similar ───────────────────────────────────
|
// ── Quick Select / Select Similar ───────────────────────────────────
|
||||||
/// Extend the current selection with every entity in the active
|
/// Extend the current selection with every entity in the active
|
||||||
/// layout that matches a selected entity by (type, layer).
|
/// layout that matches a selected entity by (type, layer).
|
||||||
|
|
@ -1402,6 +1407,7 @@ impl OpenCADStudio {
|
||||||
color_pick_target: None,
|
color_pick_target: None,
|
||||||
shortcuts_window: None,
|
shortcuts_window: None,
|
||||||
about_window: None,
|
about_window: None,
|
||||||
|
plugin_manager_window: None,
|
||||||
update_notice_window: None,
|
update_notice_window: None,
|
||||||
assoc_prompt_window: None,
|
assoc_prompt_window: None,
|
||||||
default_assoc_prompted: false,
|
default_assoc_prompted: false,
|
||||||
|
|
@ -1712,6 +1718,9 @@ pub fn run() -> iced::Result {
|
||||||
if Some(window_id) == state.about_window {
|
if Some(window_id) == state.about_window {
|
||||||
return "About Open CAD Studio".into();
|
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 {
|
if Some(window_id) == state.update_notice_window {
|
||||||
return "Update Available".into();
|
return "Update Available".into();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1640,6 +1640,9 @@ impl OpenCADStudio {
|
||||||
self.about_window = None;
|
self.about_window = None;
|
||||||
self.ribbon.deactivate_tool_if("ABOUT");
|
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) {
|
if self.update_notice_window == Some(id) {
|
||||||
self.update_notice_window = None;
|
self.update_notice_window = None;
|
||||||
}
|
}
|
||||||
|
|
@ -5394,6 +5397,28 @@ impl OpenCADStudio {
|
||||||
iced::clipboard::write(info)
|
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) => {
|
Message::EnterViewport(handle) => {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
// Clear paper-space selection before entering model space.
|
// Clear paper-space selection before entering model space.
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,9 @@ impl OpenCADStudio {
|
||||||
if Some(window_id) == self.about_window {
|
if Some(window_id) == self.about_window {
|
||||||
return crate::ui::about::view_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 {
|
if Some(window_id) == self.update_notice_window {
|
||||||
let latest = self.update_notice_version.as_deref().unwrap_or("?");
|
let latest = self.update_notice_version.as_deref().unwrap_or("?");
|
||||||
let body = self.update_notice_body.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()),
|
event: crate::modules::ModuleEvent::Command("CHANGELOG".to_string()),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
outline_btn("Plugins", Message::PluginManagerOpen),
|
||||||
outline_btn("About", Message::AboutOpen),
|
outline_btn("About", Message::AboutOpen),
|
||||||
]
|
]
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,4 @@ pub mod registry;
|
||||||
|
|
||||||
pub use registry::all_ribbon_modules;
|
pub use registry::all_ribbon_modules;
|
||||||
pub(crate) use host::BuiltinPlugin;
|
pub(crate) use host::BuiltinPlugin;
|
||||||
pub(crate) use registry::try_dispatch;
|
pub(crate) use registry::{installed_manifests, try_dispatch};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Compile-time plugin registry via `inventory`.
|
// Compile-time plugin registry via `inventory`.
|
||||||
|
|
||||||
use super::host::{BuiltinPlugin, HostSession};
|
use super::host::{BuiltinPlugin, HostSession};
|
||||||
|
use super::manifest::PluginManifest;
|
||||||
use crate::app::OpenCADStudio;
|
use crate::app::OpenCADStudio;
|
||||||
use crate::modules::{registry as core_registry, CadModule};
|
use crate::modules::{registry as core_registry, CadModule};
|
||||||
|
|
||||||
|
|
@ -18,6 +19,15 @@ pub fn all_plugins() -> Vec<Box<dyn BuiltinPlugin>> {
|
||||||
.collect()
|
.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`).
|
/// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`).
|
||||||
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
|
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
|
||||||
let mut core = core_registry::all_modules();
|
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]
|
#[test]
|
||||||
fn addon_ribbon_tabs_merge_after_core() {
|
fn addon_ribbon_tabs_merge_after_core() {
|
||||||
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
|
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ pub mod open_progress;
|
||||||
pub mod overlay;
|
pub mod overlay;
|
||||||
pub mod page_setup;
|
pub mod page_setup;
|
||||||
pub mod plotstyle;
|
pub mod plotstyle;
|
||||||
|
pub mod plugin_manager;
|
||||||
pub mod properties;
|
pub mod properties;
|
||||||
pub mod ribbon;
|
pub mod ribbon;
|
||||||
pub mod scale_popup;
|
pub mod scale_popup;
|
||||||
|
|
|
||||||
146
src/ui/plugin_manager.rs
Normal file
146
src/ui/plugin_manager.rs
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue