From 29c71be1010503d1ce87965b6572194837c422b0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 14:48:56 +0300 Subject: [PATCH] feat(plugin): load external cdylib plugins at runtime (phase 2) Move BuiltinPlugin into ocs_plugin_api (host feature) so out-of-tree crates can implement it, and add export_plugin! to emit the two C symbols a cdylib exposes: ocs_plugin_api_version (checked first, so an ABI-incompatible build never runs) and ocs_plugin_register -> boxed BuiltinPlugin. The host loads every compatible package from the plugins folder at startup via libloading (desktop only), keeps the library resident for the session, and merges its ribbon tab + command dispatch into the same paths as built-ins (honouring enable/disable). The Plugin Manager shows external packages with a Loaded / incompatible status. Approach B: the plugin hands back a boxed trait object, assuming a matching toolchain + ocs_plugin_api version (the version symbol enforces the latter). crates/ocs_example_plugin is the reference cdylib. Part of the #100 extensibility epic (phase 2). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 8 ++ Cargo.toml | 4 +- crates/ocs_example_plugin/Cargo.toml | 16 ++++ crates/ocs_example_plugin/README.md | 37 ++++++++ crates/ocs_example_plugin/plugin.toml | 13 +++ crates/ocs_example_plugin/src/lib.rs | 68 ++++++++++++++ crates/ocs_plugin_api/src/host.rs | 41 +++++++++ docs/plugin-architecture.md | 27 ++++-- src/app/mod.rs | 20 +++- src/app/view.rs | 1 + src/plugin/external.rs | 126 ++++++++++++++++++++++++++ src/plugin/host.rs | 23 +---- src/plugin/registry.rs | 43 ++++++++- src/ui/plugin_manager.rs | 11 ++- 14 files changed, 402 insertions(+), 36 deletions(-) create mode 100644 crates/ocs_example_plugin/Cargo.toml create mode 100644 crates/ocs_example_plugin/README.md create mode 100644 crates/ocs_example_plugin/plugin.toml create mode 100644 crates/ocs_example_plugin/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 83795bf5..adbd784c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,7 @@ dependencies = [ "image", "inventory", "js-sys", + "libloading", "lzma-sys", "ocs_plugin_api", "open", @@ -3623,6 +3624,13 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "ocs_example_plugin" +version = "0.1.0" +dependencies = [ + "ocs_plugin_api", +] + [[package]] name = "ocs_plugin_api" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6f992d8c..c234ae93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" build = "build.rs" [workspace] -members = ["crates/ocs_plugin_api"] +members = ["crates/ocs_plugin_api", "crates/ocs_example_plugin"] # Embed the application icon into the Windows .exe so Explorer, the taskbar, # the Start-menu tile and file-association entries show it (issue #107). @@ -79,6 +79,8 @@ acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } rayon = "1" open = "5" ureq = { version = "3", default-features = false, features = ["rustls"] } +# Runtime loading of external plugin cdylibs (phase 2, desktop only). +libloading = "0.8" [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1" diff --git a/crates/ocs_example_plugin/Cargo.toml b/crates/ocs_example_plugin/Cargo.toml new file mode 100644 index 00000000..58ad70c6 --- /dev/null +++ b/crates/ocs_example_plugin/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ocs_example_plugin" +version = "0.1.0" +edition = "2021" +description = "Reference external add-on for Open CAD Studio — builds a cdylib the host loads at runtime." +license = "GPL-3.0-only" + +# A dynamically-loaded plugin is a C-ABI dynamic library. `cargo build -p +# ocs_example_plugin` produces target//libocs_example_plugin.so (or +# .dll/.dylib); drop it beside a plugin.toml in the plugins folder. +[lib] +crate-type = ["cdylib"] + +[dependencies] +# The stable contract, with the runtime `host` surface (HostApi/BuiltinPlugin). +ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } diff --git a/crates/ocs_example_plugin/README.md b/crates/ocs_example_plugin/README.md new file mode 100644 index 00000000..ddc33334 --- /dev/null +++ b/crates/ocs_example_plugin/README.md @@ -0,0 +1,37 @@ +# ocs_example_plugin + +Reference **dynamically-loaded** add-on for Open CAD Studio. It depends only on +`ocs_plugin_api` (with the `host` feature) — never on the `OpenCADStudio` +binary — so it shows the full surface an out-of-tree plugin targets: a +`PluginManifest`, a `CadModule` ribbon tab, a `BuiltinPlugin` entry point, and +the `export_plugin!` C-ABI export. + +## Build & install + +```sh +cargo build -p ocs_example_plugin # → target/debug/libocs_example_plugin.so +``` + +Copy the library and `plugin.toml` into a folder named after the plugin id under +the user plugins directory: + +``` +/OpenCADStudio/plugins/opencad.example/ + plugin.toml + libocs_example_plugin.so # .dll on Windows, .dylib on macOS +``` + +`` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or +`$XDG_CONFIG_HOME` / `~/.config` (Linux). + +Restart Open CAD Studio. The host loads the cdylib at startup (after checking +`ocs_plugin_api_version`), adds the **Example** ribbon tab, and routes `EX_` +commands to it. `PLUGINS` lists it under *External* as **Loaded**; run `EX_HELLO` +to see it respond. + +## Contract + +- `ocs_plugin_api::export_plugin!(MyPlugin)` emits `ocs_plugin_api_version()` and + `ocs_plugin_register()`. +- The package must be built with the **same toolchain and `ocs_plugin_api` + version** as the host (approach B — the version symbol enforces the latter). diff --git a/crates/ocs_example_plugin/plugin.toml b/crates/ocs_example_plugin/plugin.toml new file mode 100644 index 00000000..c8f4609f --- /dev/null +++ b/crates/ocs_example_plugin/plugin.toml @@ -0,0 +1,13 @@ +[plugin] +id = "opencad.example" +name = "Example Plugin" +version = "0.1.0" +description = "Reference dynamically-loaded add-on" +author = "Open CAD Studio contributors" +license = "GPL-3.0-only" + +[opencad] +api_version = 1 +ribbon_order = 50 +command_prefixes = ["EX_"] +xdata_apps = [] diff --git a/crates/ocs_example_plugin/src/lib.rs b/crates/ocs_example_plugin/src/lib.rs new file mode 100644 index 00000000..b4315352 --- /dev/null +++ b/crates/ocs_example_plugin/src/lib.rs @@ -0,0 +1,68 @@ +//! Reference external add-on, built as a `cdylib` the host loads at runtime. +//! +//! It depends only on `ocs_plugin_api` (with the `host` feature) — never on the +//! `OpenCADStudio` binary — so it demonstrates the stable contract an +//! out-of-tree plugin targets: a `PluginManifest`, a `CadModule` ribbon tab, a +//! `BuiltinPlugin` entry point, and the `export_plugin!` C-ABI export. + +use ocs_plugin_api::host::{BuiltinPlugin, HostApi}; +use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; +use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +static MANIFEST: PluginManifest = PluginManifest { + id: "opencad.example", + name: "Example Plugin", + version: "0.1.0", + description: "Reference dynamically-loaded add-on", + api_version: ApiVersion::CURRENT, + ribbon_order: 50, + xdata_apps: &[], + command_prefixes: &["EX_"], +}; + +/// Ribbon tab for the example plugin. +struct ExampleModule; + +impl CadModule for ExampleModule { + fn id(&self) -> &'static str { + "example" + } + fn title(&self) -> &'static str { + "Example" + } + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { + title: "Demo", + tools: vec![RibbonItem::LargeTool(ToolDef { + id: "EX_HELLO", + label: "Hello", + icon: IconKind::Glyph("◆"), + event: ModuleEvent::Command("EX_HELLO".to_string()), + })], + }] + } +} + +/// The plugin entry point handed to the host. +struct ExamplePlugin; + +impl BuiltinPlugin for ExamplePlugin { + fn manifest(&self) -> &'static PluginManifest { + &MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(ExampleModule) + } + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + match cmd { + "EX_HELLO" => { + host.push_info("Hello from the external example plugin (cdylib loaded)."); + true + } + _ => false, + } + } +} + +// Emit the C-ABI symbols the host loader looks for. +ocs_plugin_api::export_plugin!(ExamplePlugin); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index f7a3f502..115995b6 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -16,6 +16,47 @@ use std::any::Any; use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; +use crate::manifest::PluginManifest; +use crate::ribbon::CadModule; + +/// An add-on package's entry point: its manifest, optional ribbon tab, and +/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins +/// implement the same trait from this crate, so an out-of-tree add-on targets +/// the stable contract rather than the host binary. +pub trait BuiltinPlugin: Send + Sync { + fn manifest(&self) -> &'static PluginManifest; + fn ribbon(&self) -> Box; + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; +} + +/// Export a `BuiltinPlugin` from a `cdylib` so the host can load it at runtime. +/// +/// Emits the two C symbols the loader looks for: `ocs_plugin_api_version` +/// (checked before anything else, so an ABI-incompatible build is rejected +/// without running its code) and `ocs_plugin_register` (constructs the plugin +/// and hands ownership to the host as a boxed trait object). +/// +/// ```ignore +/// ocs_plugin_api::export_plugin!(MyPlugin::new()); +/// ``` +#[macro_export] +macro_rules! export_plugin { + ($ctor:expr) => { + #[no_mangle] + pub extern "C" fn ocs_plugin_api_version() -> u32 { + $crate::API_VERSION + } + + #[no_mangle] + pub extern "C" fn ocs_plugin_register( + ) -> *mut ::std::boxed::Box { + let plugin: ::std::boxed::Box = + ::std::boxed::Box::new($ctor); + ::std::boxed::Box::into_raw(::std::boxed::Box::new(plugin)) + } + }; +} + /// The plugin-facing runtime surface for one active document tab. pub trait HostApi { /// Index of the tab this session targets. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 0fc964aa..18cab3cc 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -270,16 +270,29 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with ### Phase 2 — Dynamic loading (desktop) ``` -%APPDATA%/OpenCADStudio/plugins/ - opencad.storm_sewer/ +/OpenCADStudio/plugins/ + opencad.example/ plugin.toml - opencad_storm_sewer.dll # cdylib + libocs_example_plugin.so # cdylib (.dll / .dylib per platform) ``` -- `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) +- [x] Discover packages — scan the plugins folder, read `plugin.toml`, check for + a native library, gate on `api_version`. Surfaced in the Plugin Manager with + a status pill (`src/plugin/external.rs`). +- [x] Load via `libloading` — each cdylib exports two C symbols (via the + `ocs_plugin_api::export_plugin!` macro): `ocs_plugin_api_version` (checked + first, so an incompatible build never runs) and `ocs_plugin_register` → + `*mut Box`. Loaded once at startup; the library stays + resident for the session (ribbon tabs / dispatch hold its vtables). External + plugins merge into the same ribbon + `try_dispatch` path as built-ins and + honour the enable/disable set. +- [x] `api_version` compatibility gate at load time. +- [x] Enable/disable in settings (like QGIS plugin manager) — landed in phase 1. + +**ABI approach:** the plugin hands back a boxed `BuiltinPlugin` (not a `repr(C)` +vtable). This assumes the package was built against the same toolchain and +`ocs_plugin_api` version; the version symbol enforces the latter. Reference +implementation: [`crates/ocs_example_plugin`](../crates/ocs_example_plugin). ### Phase 3 — Interchange & QA diff --git a/src/app/mod.rs b/src/app/mod.rs index b2028a92..80362374 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -298,8 +298,11 @@ pub(super) struct OpenCADStudio { /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, /// External add-on packages found in the plugins folder, refreshed when the - /// Plugin Manager opens. Discovery only — not yet loaded (phase 2). + /// Plugin Manager opens. external_plugins: Vec, + /// Ids of external packages actually loaded this session (a subset of + /// `external_plugins` — compatible, with a library, dlopen'd at startup). + loaded_plugin_ids: rustc_hash::FxHashSet, /// PDSIZE text buffer for the Point Style (DDPTYPE) dialog. point_size_buf: String, /// Point Style size mode: `true` = relative to screen, `false` = absolute. @@ -1465,6 +1468,7 @@ impl OpenCADStudio { modal_dragging: false, disabled_plugins: rustc_hash::FxHashSet::default(), external_plugins: Vec::new(), + loaded_plugin_ids: rustc_hash::FxHashSet::default(), point_size_buf: String::new(), point_size_relative: true, default_assoc_prompted: false, @@ -1644,6 +1648,20 @@ impl OpenCADStudio { if let Some(s) = settings::UserSettings::load() { app.apply_settings(&s); } + // Load external plugin packages from the plugins folder once, then fold + // their ribbon tabs into the ribbon. Skipped under test/wasm. + #[cfg(all(not(target_arch = "wasm32"), not(test)))] + { + for (id, res) in crate::plugin::external::load_at_startup() { + if let Err(e) = res { + app.command_line + .push_error(&format!("Plugin '{id}' failed to load: {e}")); + } + } + app.loaded_plugin_ids = + crate::plugin::external::loaded_ids().into_iter().collect(); + app.rebuild_ribbon_modules(); + } app.last_saved_settings = Some(app.current_settings()); app.sync_ribbon_layers(); app diff --git a/src/app/view.rs b/src/app/view.rs index a6253b73..72a0e271 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1125,6 +1125,7 @@ impl OpenCADStudio { &crate::plugin::installed_manifests(), &self.disabled_plugins, &self.external_plugins, + &self.loaded_plugin_ids, ), 520, 460, diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 1c1ac325..daf48c68 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -194,6 +194,132 @@ fn parse_string_array(s: &str) -> Vec { .collect() } +// ── Runtime loading (desktop only) ────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +pub use loader::{load, load_at_startup, loaded_ids, with_loaded, LoadedPlugin}; + +#[cfg(not(target_arch = "wasm32"))] +mod loader { + use super::{lib_extension, ExternalPlugin}; + use ocs_plugin_api::host::BuiltinPlugin; + use std::path::{Path, PathBuf}; + + /// A loaded external plugin. The library must outlive the boxed plugin, so + /// `plugin` is declared before `_lib` (fields drop in declaration order). + pub struct LoadedPlugin { + plugin: Box, + _lib: libloading::Library, + pub id: String, + } + + impl LoadedPlugin { + pub fn plugin(&self) -> &dyn BuiltinPlugin { + self.plugin.as_ref() + } + } + + use std::cell::RefCell; + + // Process-wide store of loaded external plugins. The libraries must stay + // resident for the whole session — ribbon tabs and command dispatch hold + // vtables that live inside them — so this is filled once at startup and + // never cleared mid-session (reloading would dangle live ribbon modules). + thread_local! { + static LOADED: RefCell> = const { RefCell::new(Vec::new()) }; + } + + /// Discover packages and load every API-compatible one with a native + /// library into the process store. Call once at startup. Returns per-id + /// results so the host can report load failures. + pub fn load_at_startup() -> Vec<(String, Result<(), String>)> { + let discovered = super::discover(); + let mut out = Vec::new(); + LOADED.with(|cell| { + let mut store = cell.borrow_mut(); + if !store.is_empty() { + return; // already loaded this session + } + for d in &discovered { + if !d.api_compatible() || !d.lib_present { + continue; + } + match load(d) { + Ok(lp) => { + out.push((lp.id.clone(), Ok(()))); + store.push(lp); + } + Err(e) => out.push((d.id.clone(), Err(e))), + } + } + }); + out + } + + /// Ids of the plugins currently loaded in the process store. + pub fn loaded_ids() -> Vec { + LOADED.with(|c| c.borrow().iter().map(|lp| lp.id.clone()).collect()) + } + + /// Run `f` over the loaded plugins (borrowing the store). + pub fn with_loaded(f: impl FnOnce(&[LoadedPlugin]) -> R) -> R { + LOADED.with(|c| f(&c.borrow())) + } + + /// Path to the native library beside `plugin.toml`, if any. + fn lib_file(dir: &Path) -> Option { + let ext = lib_extension(); + std::fs::read_dir(dir).ok()?.flatten().find_map(|e| { + let p = e.path(); + (p.extension().and_then(|s| s.to_str()) == Some(ext)).then_some(p) + }) + } + + /// Load a discovered package's `cdylib`, gating on the API version before + /// any of its code runs. Approach B (see `docs/plugin-architecture.md`): + /// the plugin hands back a boxed `BuiltinPlugin`; this assumes the package + /// was built against the same toolchain and `ocs_plugin_api` version, which + /// the version symbol enforces. + /// + /// # Safety + /// Calls `dlopen`/`dlsym` on an arbitrary file and trusts its exported + /// symbols' signatures. Only invoke on packages the user installed. + pub fn load(p: &ExternalPlugin) -> Result { + let path = lib_file(&p.dir).ok_or("no native library in package")?; + unsafe { + let lib = libloading::Library::new(&path).map_err(|e| e.to_string())?; + + let version: libloading::Symbol u32> = lib + .get(b"ocs_plugin_api_version") + .map_err(|_| "missing ocs_plugin_api_version symbol".to_string())?; + let v = version(); + if v != ocs_plugin_api::API_VERSION { + return Err(format!( + "API version {v} != host {}", + ocs_plugin_api::API_VERSION + )); + } + + let register: libloading::Symbol< + extern "C" fn() -> *mut Box, + > = lib + .get(b"ocs_plugin_register") + .map_err(|_| "missing ocs_plugin_register symbol".to_string())?; + let raw = register(); + if raw.is_null() { + return Err("ocs_plugin_register returned null".into()); + } + let plugin = *Box::from_raw(raw); + let id = plugin.manifest().id.to_string(); + Ok(LoadedPlugin { + plugin, + _lib: lib, + id, + }) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/plugin/host.rs b/src/plugin/host.rs index f4ca3a50..44ea177c 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -2,21 +2,8 @@ // access) and implements the stable `HostApi` contract plugins target. pub(crate) use crate::app::plugin_host::HostSession; -/// The stable runtime surface a plugin's `dispatch` receives. -pub use ocs_plugin_api::host::HostApi; - -use crate::modules::CadModule; - -use super::manifest::PluginManifest; - -/// Add-on package entry point (phase 1: in-tree, in-process). -/// -/// One `PluginRegistration` per package — ribbon tab, manifest, and command -/// dispatch are owned here. `dispatch` receives `&mut dyn HostApi` (the stable -/// `ocs_plugin_api` contract), not the host's concrete session type. See -/// `docs/plugin-architecture.md`. -pub trait BuiltinPlugin: Send + Sync { - fn manifest(&self) -> &'static PluginManifest; - fn ribbon(&self) -> Box; - fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; -} \ No newline at end of file +/// The stable contract types a plugin targets. `BuiltinPlugin` (the package +/// entry point) and `HostApi` (the runtime surface its `dispatch` receives) +/// both live in `ocs_plugin_api` so in-tree and out-of-tree add-ons implement +/// the same trait. See `docs/plugin-architecture.md`. +pub use ocs_plugin_api::host::{BuiltinPlugin, HostApi}; \ No newline at end of file diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index e562b9c0..8b794f28 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -44,6 +44,16 @@ pub fn ribbon_modules_enabled( .filter(|p| !disabled.contains(p.manifest().id)) .map(|p| (p.manifest().ribbon_order, p.ribbon())) .collect(); + // Dynamically-loaded external plugins contribute tabs too (their libraries + // stay resident for the session, so these vtables remain valid). + #[cfg(not(target_arch = "wasm32"))] + crate::plugin::external::with_loaded(|loaded| { + for lp in loaded { + if !disabled.contains(lp.id.as_str()) { + addons.push((lp.plugin().manifest().ribbon_order, lp.plugin().ribbon())); + } + } + }); addons.sort_by_key(|(order, _)| *order); core.extend(addons.into_iter().map(|(_, ribbon)| ribbon)); core @@ -53,12 +63,35 @@ pub fn ribbon_modules_enabled( /// 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; + { + 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; + } } - if plugin.dispatch(&mut host, cmd) { + } + // Then the dynamically-loaded external plugins. The store is a separate + // borrow from `app`, so wrapping `app` in a fresh `HostSession` here is + // sound. + #[cfg(not(target_arch = "wasm32"))] + { + let handled = crate::plugin::external::with_loaded(|loaded| { + let mut host = HostSession::new(app, tab); + for lp in loaded { + if disabled.contains(lp.id.as_str()) { + continue; + } + if lp.plugin().dispatch(&mut host, cmd) { + return true; + } + } + false + }); + if handled { return true; } } diff --git a/src/ui/plugin_manager.rs b/src/ui/plugin_manager.rs index 9f21116c..8080b6e4 100644 --- a/src/ui/plugin_manager.rs +++ b/src/ui/plugin_manager.rs @@ -156,13 +156,15 @@ fn status_badge<'a>(label: &str, color: Color) -> Element<'a, Message> { .into() } -fn external_card<'a>(p: &ExternalPlugin) -> Element<'a, Message> { - let (status, color) = if !p.api_compatible() { +fn external_card<'a>(p: &ExternalPlugin, loaded: bool) -> Element<'a, Message> { + let (status, color) = if loaded { + ("Loaded", Color { r: 0.2, g: 0.5, b: 0.3, a: 1.0 }) + } else if !p.api_compatible() { ("API incompatible", Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 }) } else if !p.lib_present { ("No library", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 }) } else { - ("Ready to load", Color { r: 0.2, g: 0.45, b: 0.28, a: 1.0 }) + ("Restart to load", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 }) }; let header = row![ text(p.name.clone()).size(15).color(WHITE), @@ -201,6 +203,7 @@ pub fn view_window<'a>( plugins: &[&'static PluginManifest], disabled: &FxHashSet, externals: &[ExternalPlugin], + loaded: &FxHashSet, ) -> Element<'a, Message> { let title = text("Installed Plugins").size(20).color(WHITE); let subtitle = text(format!( @@ -229,7 +232,7 @@ pub fn view_window<'a>( .color(DIM), ); for p in externals { - list = list.push(external_card(p)); + list = list.push(external_card(p, loaded.contains(&p.id))); } } let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into();