Skip to main content

eov_plugin_api/
lib.rs

1//! Shared plugin API for EOV.
2//!
3//! This crate defines the traits, structs, and manifest types that both the
4//! host application and individual plugins depend on. It intentionally avoids
5//! any dependency on Slint or other UI frameworks so that plugin non-UI logic
6//! can be tested independently.
7//!
8//! # Adding a new plugin
9//!
10//! 1. Create a directory under the plugin directory (default `~/.eov/plugins/`).
11//! 2. Add a `plugin.toml` manifest (see [`PluginManifest`]).
12//! 3. Place your `.slint` UI file relative to the plugin root.
13//! 4. Implement the [`Plugin`] trait and register it via the plugin registry.
14//!
15//! # Manifest format (`plugin.toml`)
16//!
17//! ```toml
18//! id = "example_plugin"
19//! name = "Example Plugin"
20//! version = "0.1.0"
21//! entry_ui = "ui/my_panel.slint"
22//! entry_component = "MyPanel"
23//!
24//! [icon]
25//! kind = "svg"
26//! data = "<svg>...</svg>"
27//! ```
28
29pub mod ffi;
30pub mod host;
31pub mod manifest;
32pub mod viewport_filter;
33
34pub use host::{
35    ActiveSidebar, HostLogLevel, HostSnapshot, HostToolMode, ModalDialogRequest, OpenFileInfo,
36    PluginUndoRedoState, SidebarRequest, ViewportOverlayComponentRequest, ViewportSnapshot,
37};
38pub use manifest::ManifestToolbarButton;
39pub use manifest::PluginManifest;
40pub use viewport_filter::ViewportFilter;
41pub use viewport_filter::{DmaBufDescriptor, GpuFilterContext};
42
43use std::path::{Path, PathBuf};
44use thiserror::Error;
45
46// ---------------------------------------------------------------------------
47// Errors
48// ---------------------------------------------------------------------------
49
50#[derive(Debug, Error)]
51pub enum PluginError {
52    #[error("plugin manifest error in '{plugin_id}': {message}")]
53    Manifest { plugin_id: String, message: String },
54    #[error("plugin '{plugin_id}' missing required file: {path}")]
55    MissingFile { plugin_id: String, path: PathBuf },
56    #[error("duplicate plugin id: '{0}'")]
57    DuplicateId(String),
58    #[error("duplicate toolbar button id: '{0}'")]
59    DuplicateButtonId(String),
60    #[error("plugin activation error in '{plugin_id}': {message}")]
61    Activation { plugin_id: String, message: String },
62    #[error("IO error: {0}")]
63    Io(#[from] std::io::Error),
64    #[error("{0}")]
65    Other(String),
66}
67
68pub type PluginResult<T> = Result<T, PluginError>;
69
70// ---------------------------------------------------------------------------
71// Icon descriptor
72// ---------------------------------------------------------------------------
73
74/// Describes how a plugin icon is provided.
75#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77pub enum IconDescriptor {
78    /// Inline SVG string.
79    Svg { data: String },
80    /// Path to an image file, relative to the plugin root.
81    File { path: PathBuf },
82}
83
84// ---------------------------------------------------------------------------
85// Toolbar button registration
86// ---------------------------------------------------------------------------
87
88/// Registration data for a plugin toolbar button.
89///
90/// The host renders the actual button; the plugin only provides metadata and
91/// an action identifier.
92#[derive(Debug, Clone)]
93pub struct ToolbarButtonRegistration {
94    /// Owning plugin id.
95    pub plugin_id: String,
96    /// Unique identifier for this button (scoped to the plugin).
97    pub button_id: String,
98    /// Tooltip / accessible label shown on hover.
99    pub tooltip: String,
100    /// Icon to display.
101    pub icon: IconDescriptor,
102    /// Alternate icon used when a HUD toolbar button is active.
103    pub toggled_icon: Option<IconDescriptor>,
104    /// Opaque action identifier dispatched back to the plugin on click.
105    pub action_id: String,
106    /// Optional host tool mode this button owns and toggles.
107    pub tool_mode: Option<HostToolMode>,
108    /// Optional logical hotkey text that activates this tool button.
109    pub hotkey: Option<String>,
110    /// Whether the host should render this button in its active state.
111    pub active: bool,
112}
113
114/// Registration data for a plugin HUD toolbar button.
115///
116/// The host renders these inside each viewport HUD toolbar. They are icon-only
117/// actions and do not participate in host tool-mode or hotkey ownership.
118#[derive(Debug, Clone)]
119pub struct HudToolbarButtonRegistration {
120    /// Owning plugin id.
121    pub plugin_id: String,
122    /// Unique identifier for this button (scoped to the plugin).
123    pub button_id: String,
124    /// Tooltip / accessible label shown on hover.
125    pub tooltip: String,
126    /// Icon to display.
127    pub icon: IconDescriptor,
128    /// Alternate icon used when the HUD button is active.
129    pub toggled_icon: Option<IconDescriptor>,
130    /// Opaque action identifier dispatched back to the plugin on click.
131    pub action_id: String,
132    /// Whether the host should render this button in its active state.
133    pub active: bool,
134}
135
136// ---------------------------------------------------------------------------
137// Host context — the API surface a plugin can call during activation
138// ---------------------------------------------------------------------------
139
140/// Trait implemented by the host and passed to plugins during activation.
141///
142/// Plugins call methods on the host context to register toolbar buttons and
143/// request windows. The trait is object-safe so it can be used with `dyn`.
144pub trait HostContext {
145    /// Register a toolbar button. The button is appended after all built-in
146    /// toolbar items.
147    fn add_toolbar_button(&mut self, button: ToolbarButtonRegistration) -> PluginResult<()>;
148
149    /// Register an icon button in the viewport HUD toolbar.
150    fn add_hud_toolbar_button(
151        &mut self,
152        _button: HudToolbarButtonRegistration,
153    ) -> PluginResult<()> {
154        Err(PluginError::Other(
155            "host does not support HUD toolbar buttons".to_string(),
156        ))
157    }
158
159    /// Request the host to open a plugin window.
160    ///
161    /// `ui_path` is an absolute path to the `.slint` file.
162    /// `component` is the exported component name within that file.
163    fn open_plugin_window(
164        &mut self,
165        plugin_id: &str,
166        ui_path: &Path,
167        component: &str,
168    ) -> PluginResult<()>;
169
170    /// Show a plugin-owned sidebar inside the main application window.
171    fn show_sidebar(&mut self, plugin_id: &str, _request: SidebarRequest) -> PluginResult<()> {
172        Err(PluginError::Other(format!(
173            "host does not support sidebars for plugin '{plugin_id}'"
174        )))
175    }
176
177    /// Hide the currently active sidebar if it belongs to `plugin_id`.
178    fn hide_sidebar(&mut self, plugin_id: &str) -> PluginResult<()> {
179        Err(PluginError::Other(format!(
180            "host does not support sidebars for plugin '{plugin_id}'"
181        )))
182    }
183
184    /// Update plugin-owned undo/redo visibility and availability state.
185    fn set_undo_redo_state(
186        &mut self,
187        plugin_id: &str,
188        _state: PluginUndoRedoState,
189    ) -> PluginResult<()> {
190        Err(PluginError::Other(format!(
191            "host does not support undo/redo for plugin '{plugin_id}'"
192        )))
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Plugin trait
198// ---------------------------------------------------------------------------
199
200/// The core trait that every plugin must implement.
201pub trait Plugin: Send + Sync {
202    /// Return the parsed manifest for this plugin.
203    fn manifest(&self) -> &PluginManifest;
204
205    /// Called once during startup. The plugin should register toolbar buttons
206    /// and any other contributions via `host`. `plugin_root` is the absolute
207    /// path to the plugin directory on disk.
208    fn activate(&self, host: &mut dyn HostContext, plugin_root: &Path) -> PluginResult<()>;
209
210    /// Called when a toolbar button registered by this plugin is clicked.
211    /// `action_id` corresponds to `ToolbarButtonRegistration::action_id`.
212    /// `plugin_root` is the absolute path to the plugin directory on disk.
213    fn on_action(
214        &self,
215        action_id: &str,
216        host: &mut dyn HostContext,
217        plugin_root: &Path,
218    ) -> PluginResult<()>;
219
220    /// Called when the host dispatches an undo request to this plugin.
221    fn on_undo(&self, _host: &mut dyn HostContext, _plugin_root: &Path) -> PluginResult<()> {
222        Ok(())
223    }
224
225    /// Called when the host dispatches a redo request to this plugin.
226    fn on_redo(&self, _host: &mut dyn HostContext, _plugin_root: &Path) -> PluginResult<()> {
227        Ok(())
228    }
229}
230
231// ---------------------------------------------------------------------------
232// Plugin descriptor — returned by discovery, before the plugin is loaded
233// ---------------------------------------------------------------------------
234
235/// Lightweight descriptor produced by directory scanning before the plugin is
236/// fully loaded.
237#[derive(Debug, Clone)]
238pub struct PluginDescriptor {
239    /// Absolute path to the plugin root directory.
240    pub root: PathBuf,
241    /// Parsed manifest.
242    pub manifest: PluginManifest,
243}
244
245impl PluginDescriptor {
246    /// Resolve the `entry_ui` path against the plugin root.
247    /// Returns `None` if the plugin has no UI entry.
248    pub fn resolve_ui_path(&self) -> Option<PathBuf> {
249        self.manifest.entry_ui.as_ref().map(|ui| self.root.join(ui))
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn toolbar_button_fields_preserved() {
259        let reg = ToolbarButtonRegistration {
260            plugin_id: "test".into(),
261            button_id: "btn1".into(),
262            tooltip: "Test Button".into(),
263            icon: IconDescriptor::Svg {
264                data: "<svg/>".into(),
265            },
266            toggled_icon: None,
267            action_id: "do_thing".into(),
268            tool_mode: None,
269            hotkey: None,
270            active: false,
271        };
272        assert_eq!(reg.plugin_id, "test");
273        assert_eq!(reg.button_id, "btn1");
274        assert_eq!(reg.tooltip, "Test Button");
275        assert_eq!(reg.action_id, "do_thing");
276        assert_eq!(reg.tool_mode, None);
277        assert_eq!(reg.hotkey, None);
278        assert!(!reg.active);
279    }
280
281    #[test]
282    fn descriptor_resolves_ui_path() {
283        let desc = PluginDescriptor {
284            root: PathBuf::from("/plugins/my_plugin"),
285            manifest: PluginManifest {
286                id: "my_plugin".into(),
287                name: "My Plugin".into(),
288                version: "0.1.0".into(),
289                entry_ui: Some("ui/panel.slint".into()),
290                entry_component: Some("Panel".into()),
291                icon: None,
292                toolbar_buttons: Vec::new(),
293            },
294        };
295        assert_eq!(
296            desc.resolve_ui_path(),
297            Some(PathBuf::from("/plugins/my_plugin/ui/panel.slint"))
298        );
299    }
300
301    #[test]
302    fn host_snapshot_sidebar_roundtrip_shape() {
303        let snapshot = HostSnapshot {
304            app_name: "eov".into(),
305            app_version: "0.0.0".into(),
306            render_backend: "cpu".into(),
307            filtering_mode: "trilinear".into(),
308            split_enabled: false,
309            focused_pane: 0,
310            open_files: Vec::new(),
311            active_file: None,
312            active_viewport: None,
313            recent_files: Vec::new(),
314            active_sidebar: Some(ActiveSidebar {
315                plugin_id: "annotations".into(),
316                button_id: Some("toggle_annotations".into()),
317                width_px: 250,
318                ui_path: "/plugins/annotations/ui/sidebar.slint".into(),
319                component: "AnnotationsSidebar".into(),
320            }),
321        };
322
323        assert_eq!(
324            snapshot.active_sidebar.as_ref().map(|s| s.width_px),
325            Some(250)
326        );
327    }
328}