pinakes-ui: add WidgetContainer; basic widget injection system

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7ca9a47e9b085b8c49869586c90034816a6a6964
This commit is contained in:
raf 2026-03-10 00:01:39 +03:00
commit 1acff0227c
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 121 additions and 1 deletions

View file

@ -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};

View file

@ -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<ApiClient>,
}
/// 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<ApiClient>,
}
/// 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) }
}
}
}