From 1acff0227c5fb74d4688f2fc98fd0ff3b7b696fa Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:01:39 +0300 Subject: [PATCH] pinakes-ui: add `WidgetContainer`; basic widget injection system Signed-off-by: NotAShelf Change-Id: I7ca9a47e9b085b8c49869586c90034816a6a6964 --- crates/pinakes-ui/src/plugin_ui/mod.rs | 4 +- crates/pinakes-ui/src/plugin_ui/widget.rs | 118 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 crates/pinakes-ui/src/plugin_ui/widget.rs diff --git a/crates/pinakes-ui/src/plugin_ui/mod.rs b/crates/pinakes-ui/src/plugin_ui/mod.rs index fc40360..1684cb9 100644 --- a/crates/pinakes-ui/src/plugin_ui/mod.rs +++ b/crates/pinakes-ui/src/plugin_ui/mod.rs @@ -33,6 +33,8 @@ pub mod actions; pub mod data; pub mod registry; pub mod renderer; +pub mod widget; -pub use registry::{PluginPage, PluginRegistry}; +pub use registry::PluginRegistry; pub use renderer::PluginViewRenderer; +pub use widget::{WidgetContainer, WidgetLocation}; diff --git a/crates/pinakes-ui/src/plugin_ui/widget.rs b/crates/pinakes-ui/src/plugin_ui/widget.rs new file mode 100644 index 0000000..35ba4fa --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/widget.rs @@ -0,0 +1,118 @@ +//! Widget injection system for plugin UI +//! +//! Allows plugins to inject small UI elements into existing host pages at +//! predefined locations. Unlike full pages, widgets have no data sources of +//! their own and render with empty data context. + +use dioxus::prelude::*; +use pinakes_plugin_api::UiWidget; + +use super::{data::PluginPageData, renderer::render_element}; +use crate::client::ApiClient; + +/// Predefined injection points in the host UI. +/// +/// These correspond to the string constants in +/// `pinakes_plugin_api::widget_location` and determine where a widget is +/// rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WidgetLocation { + LibraryHeader, + LibrarySidebar, + DetailPanel, + SearchFilters, +} + +impl WidgetLocation { + /// Returns the canonical string identifier used in plugin manifests. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::LibraryHeader => "library_header", + Self::LibrarySidebar => "library_sidebar", + Self::DetailPanel => "detail_panel", + Self::SearchFilters => "search_filters", + } + } +} + +/// Props for [`WidgetContainer`]. +#[derive(Props, PartialEq, Clone)] +pub struct WidgetContainerProps { + /// Injection point to render widgets for. + pub location: WidgetLocation, + /// All widgets from all plugins (plugin_id, widget) pairs. + pub widgets: Vec<(String, UiWidget)>, + /// API client (unused by widgets themselves but threaded through for + /// consistency with the rest of the plugin UI system). + pub client: Signal, +} + +/// Renders all widgets registered for a specific [`WidgetLocation`]. +/// +/// Returns `None` if no widgets target this location. +/// +/// # Usage +/// +/// ```rust,ignore +/// // In a host component: +/// WidgetContainer { +/// location: WidgetLocation::LibraryHeader, +/// widgets: plugin_registry.read().all_widgets(), +/// client, +/// } +/// ``` +#[component] +pub fn WidgetContainer(props: WidgetContainerProps) -> Element { + let location_str = props.location.as_str(); + let matching: Vec<_> = props + .widgets + .iter() + .filter(|(_, w)| w.target == location_str) + .cloned() + .collect(); + + if matching.is_empty() { + return rsx! {}; + } + + rsx! { + div { class: "plugin-widget-container", "data-location": location_str, + for (plugin_id , widget) in &matching { + WidgetViewRenderer { + plugin_id: plugin_id.clone(), + widget: widget.clone(), + client: props.client, + } + } + } + } +} + +/// Props for [`WidgetViewRenderer`]. +#[derive(Props, PartialEq, Clone)] +pub struct WidgetViewRendererProps { + /// Plugin that owns this widget. + pub plugin_id: String, + /// Widget definition to render. + pub widget: UiWidget, + /// API client signal. + pub client: Signal, +} + +/// Renders a single plugin widget with an empty data context. +/// +/// Widgets do not declare data sources; they render statically (or use +/// inline expressions with no external data). +#[component] +pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element { + let empty_data = PluginPageData::default(); + rsx! { + div { + class: "plugin-widget", + "data-plugin-id": props.plugin_id.clone(), + "data-widget-id": props.widget.id.clone(), + { render_element(&props.widget.content, &empty_data, props.client) } + } + } +}