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}