diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 1c21d0506..f87bea042 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -255,7 +255,8 @@ 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 -- [x] Plugin manager UI stub (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=`) ### Phase 2 — Dynamic loading (desktop) @@ -268,6 +269,7 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with - `libloading` + `#[no_mangle] extern "C" fn ocs_plugin_register() -> *const PluginVTable` - `api_version` compatibility gate at load time +- [x] Enable/disable in settings (like QGIS plugin manager) — landed early in phase 1 - Enable/disable in settings (like QGIS plugin manager) ### Phase 3 — Interchange & QA diff --git a/src/app/mod.rs b/src/app/mod.rs index 986715c40..477bf9024 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -293,6 +293,10 @@ pub(super) struct OpenCADStudio { modal_drag_last: Option, /// True while the modal title bar is held (a drag is in progress). modal_dragging: bool, + /// Plugin ids the user turned off in the Plugin Manager. Disabled plugins + /// keep their manifest listed but drop their ribbon tab and command + /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. + disabled_plugins: rustc_hash::FxHashSet, /// New-release notification window — opened on startup when the /// GitHub releases API reports a newer version than this build. /// First-launch "make Open CAD Studio the default for .dwg/.dxf?" prompt @@ -1018,6 +1022,8 @@ pub enum Message { PluginManagerOpen, #[allow(dead_code)] PluginManagerClose, + /// Enable (`true`) or disable (`false`) the plugin with this id. + SetPluginEnabled(String, bool), // ── Quick Select / Select Similar ─────────────────────────────────── /// Extend the current selection with every entity in the active /// layout that matches a selected entity by (type, layer). @@ -1428,6 +1434,7 @@ impl OpenCADStudio { modal_offset: iced::Vector::ZERO, modal_drag_last: None, modal_dragging: false, + disabled_plugins: rustc_hash::FxHashSet::default(), default_assoc_prompted: false, update_notice_version: None, update_notice_body: None, diff --git a/src/app/settings.rs b/src/app/settings.rs index 4f4596e35..cfc980f2c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -74,6 +74,10 @@ pub struct UserSettings { /// prompt has already been shown. Set once the user answers (either way), /// so we never nag again on subsequent launches. pub default_assoc_prompted: bool, + /// Ids of plugins the user turned off in the Plugin Manager. Disabled + /// plugins keep their manifest listed but drop their ribbon tab and command + /// dispatch. + pub disabled_plugins: Vec, } impl Default for UserSettings { @@ -96,6 +100,7 @@ impl Default for UserSettings { SnapType::Nearest, ], default_assoc_prompted: false, + disabled_plugins: Vec::new(), } } } @@ -130,6 +135,14 @@ impl UserSettings { "osnap" => s.snap_enabled = val == "1", "otrack" => s.otrack = val == "1", "default_assoc_prompted" => s.default_assoc_prompted = val == "1", + "disabled_plugins" => { + s.disabled_plugins = val + .split(',') + .map(|t| t.trim()) + .filter(|t| !t.is_empty()) + .map(|t| t.to_string()) + .collect(); + } "snap_modes" => { let modes: Vec = val.split(',').filter_map(|t| snap_from_id(t.trim())).collect(); @@ -155,7 +168,7 @@ impl UserSettings { .collect::>() .join(","); let body = format!( - "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\n", + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -165,6 +178,7 @@ impl UserSettings { b(self.otrack), b(self.default_assoc_prompted), modes, + self.disabled_plugins.join(","), ); let _ = std::fs::write(path, body); } diff --git a/src/app/update.rs b/src/app/update.rs index 9b17cfafd..975e21b75 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -141,6 +141,11 @@ impl OpenCADStudio { otrack: self.snapper.otrack_enabled, snap_modes: super::settings::UserSettings::modes_from(self.snapper.enabled.iter()), default_assoc_prompted: self.default_assoc_prompted, + disabled_plugins: { + let mut v: Vec = self.disabled_plugins.iter().cloned().collect(); + v.sort(); + v + }, } } @@ -155,6 +160,22 @@ impl OpenCADStudio { self.snapper.otrack_enabled = s.otrack; self.snapper.enabled = s.snap_modes.iter().copied().collect(); self.default_assoc_prompted = s.default_assoc_prompted; + self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); + self.rebuild_ribbon_modules(); + } + + /// Rebuild the ribbon's tab list from the registry, dropping the tabs of any + /// disabled plugins. Call after `disabled_plugins` changes. + pub(super) fn rebuild_ribbon_modules(&mut self) { + let modules = + crate::plugin::ribbon_modules_enabled(&self.disabled_plugins); + self.ribbon.set_modules(modules); + } + + /// Snapshot of disabled plugin ids — lets the registry skip them while it + /// holds a `&mut` borrow of the app via `HostSession`. + pub(crate) fn disabled_plugin_ids(&self) -> rustc_hash::FxHashSet { + self.disabled_plugins.clone() } /// Write preferences to disk only when they differ from the last write, @@ -5432,6 +5453,16 @@ impl OpenCADStudio { self.close_active_modal(); Task::none() } + Message::SetPluginEnabled(id, enabled) => { + if enabled { + self.disabled_plugins.remove(&id); + } else { + self.disabled_plugins.insert(id); + } + self.rebuild_ribbon_modules(); + self.persist_settings_if_changed(); + Task::none() + } Message::EnterViewport(handle) => { let i = self.active_tab; diff --git a/src/app/view.rs b/src/app/view.rs index 811607160..202984c23 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1120,7 +1120,10 @@ impl OpenCADStudio { sized(crate::ui::shortcuts::view_window(&self.shortcut_overrides), 720, 520) } super::ModalKind::PluginManager => sized( - crate::ui::plugin_manager::view_window(&crate::plugin::installed_manifests()), + crate::ui::plugin_manager::view_window( + &crate::plugin::installed_manifests(), + &self.disabled_plugins, + ), 520, 460, ), diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 883cc4db7..cc76fb341 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -8,6 +8,6 @@ pub mod host; pub mod manifest; pub mod registry; -pub use registry::all_ribbon_modules; +pub use registry::{all_ribbon_modules, ribbon_modules_enabled}; pub(crate) use host::BuiltinPlugin; 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 8c8e96c74..fbc5949bd 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -28,11 +28,20 @@ pub fn installed_manifests() -> Vec<&'static PluginManifest> { manifests } -/// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`). +/// Core ribbon tabs plus *every* add-on tab (sorted by `manifest.ribbon_order`). pub fn all_ribbon_modules() -> Vec> { + ribbon_modules_enabled(&rustc_hash::FxHashSet::default()) +} + +/// Core ribbon tabs plus add-on tabs whose plugin id is **not** in `disabled` +/// (sorted by `manifest.ribbon_order`). Used by the Plugin Manager toggle. +pub fn ribbon_modules_enabled( + disabled: &rustc_hash::FxHashSet, +) -> Vec> { let mut core = core_registry::all_modules(); let mut addons: Vec<(i32, Box)> = all_plugins() .into_iter() + .filter(|p| !disabled.contains(p.manifest().id)) .map(|p| (p.manifest().ribbon_order, p.ribbon())) .collect(); addons.sort_by_key(|(order, _)| *order); @@ -40,10 +49,15 @@ pub fn all_ribbon_modules() -> Vec> { core } -/// Try each plugin until one handles `cmd`. Returns true if handled. +/// Try each *enabled* plugin until one handles `cmd`. Returns true if handled. +/// Disabled plugins (toggled off in the Plugin Manager) are skipped. pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool { + let disabled = app.disabled_plugin_ids(); let mut host = HostSession::new(app, tab); for plugin in all_plugins() { + if disabled.contains(plugin.manifest().id) { + continue; + } if plugin.dispatch(&mut host, cmd) { return true; } @@ -98,6 +112,22 @@ mod tests { assert_eq!(titles.len(), core.len() + all_plugins().len()); } + #[test] + fn disabled_plugin_drops_its_ribbon_tab() { + let mut disabled = rustc_hash::FxHashSet::default(); + disabled.insert("opencad.demo_plugin".to_string()); + let titles: Vec<&str> = ribbon_modules_enabled(&disabled) + .iter() + .map(|m| m.title()) + .collect(); + assert!( + !titles.contains(&"Demo Plugin"), + "disabled plugin still present: {titles:?}" + ); + // Only the add-on tab is dropped; core tabs stay. + assert_eq!(titles.len(), core_registry::all_modules().len()); + } + #[test] fn try_dispatch_routes_demo_command() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/ui/plugin_manager.rs b/src/ui/plugin_manager.rs index cf42fdd4b..4770a56f7 100644 --- a/src/ui/plugin_manager.rs +++ b/src/ui/plugin_manager.rs @@ -1,13 +1,14 @@ -//! 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`. +//! Plugin Manager window — lists the add-ons compiled into this build and lets +//! the user enable/disable each one. A disabled plugin keeps its manifest +//! listed but drops its ribbon tab and command dispatch (persisted across +//! launches). Dynamic loading still comes 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::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Color, Element, Fill, Theme}; +use rustc_hash::FxHashSet; // Register the command names for autocomplete. inventory::submit!(crate::command::CommandRegistration { @@ -70,15 +71,47 @@ fn badge<'a>(label: String) -> Element<'a, Message> { .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); +fn toggle_button<'a>(id: &str, disabled: bool) -> Element<'a, Message> { + // Label shows the action the click performs. + let (label, on, off) = if disabled { + ("Enable", Color { r: 0.18, g: 0.5, b: 0.25, a: 1.0 }, Color { r: 0.22, g: 0.6, b: 0.3, a: 1.0 }) + } else { + ("Disable", Color { r: 0.4, g: 0.22, b: 0.22, a: 1.0 }, Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 }) + }; + let want_enabled = disabled; // clicking flips the state + let id_owned = id.to_string(); + button(text(label).size(12).color(WHITE)) + .padding([3, 12]) + .on_press(Message::SetPluginEnabled(id_owned, want_enabled)) + .style(move |_: &Theme, status| { + let bg = match status { + button::Status::Hovered | button::Status::Pressed => off, + _ => on, + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: WHITE, + border: Border { radius: 4.0.into(), ..Default::default() }, + ..Default::default() + } + }) + .into() +} + +fn plugin_card<'a>(m: &PluginManifest, disabled: bool) -> Element<'a, Message> { + let mut header = row![text(m.name.to_string()).size(15).color(WHITE)]; + if disabled { + header = header.push(Space::new().width(8)); + header = header.push(badge("Disabled".to_string())); + } + let header = header + .push(Space::new().width(Fill)) + .push(badge(format!("v{}", m.version))) + .push(Space::new().width(8)) + .push(badge(format!("API {}", m.api_version.major))) + .push(Space::new().width(10)) + .push(toggle_button(m.id, disabled)) + .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); @@ -107,7 +140,10 @@ fn plugin_card<'a>(m: &PluginManifest) -> Element<'a, Message> { .into() } -pub fn view_window<'a>(plugins: &[&'static PluginManifest]) -> Element<'a, Message> { +pub fn view_window<'a>( + plugins: &[&'static PluginManifest], + disabled: &FxHashSet, +) -> Element<'a, Message> { let title = text("Installed Plugins").size(20).color(WHITE); let subtitle = text(format!( "{} add-on{} compiled into this build", @@ -124,7 +160,7 @@ pub fn view_window<'a>(plugins: &[&'static PluginManifest]) -> Element<'a, Messa } else { let mut list = column![].spacing(10); for m in plugins { - list = list.push(plugin_card(m)); + list = list.push(plugin_card(m, disabled.contains(m.id))); } scrollable(list.width(Fill)).height(Fill).into() }; diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index f8e8465f4..452ad8150 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -135,6 +135,17 @@ impl Ribbon { self.active = index; } } + + /// Replace the tab list (e.g. after a plugin is enabled/disabled in the + /// Plugin Manager). Clamps the active tab so it stays in range. + pub fn set_modules(&mut self, modules: Vec>) { + self.modules = modules; + if self.active >= self.modules.len() { + self.active = self.modules.len().saturating_sub(1); + } + self.active_tool = None; + self.open_dropdown = None; + } pub fn activate_tool(&mut self, id: &str) { self.active_tool = Some(id.to_string()); }