Skip to main content

progit_plugin_sdk/traits/
experimental_fragments.rs

1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2026 Markus Maiwald
3
4//! # Widget Fragments (experimental_)
5//!
6//! Mechanism for plugins to inject UI fragments into existing TUI widgets.
7//!
8//! ## Stability
9//!
10//! Experimental as of SDK 0.3. Promote to stable after `progit-pr-stacker`
11//! v0.1 ships and exercises the API in production.
12//!
13//! ## Design constraints
14//!
15//! 1. **Trait Firewall.** Fragments produce typed `RenderFragment` data;
16//!    the TUI does the actual drawing. No `crossterm::Print` from plugins.
17//! 2. **Bounded slot set.** Each widget exposes a finite set of named slots
18//!    (see `FRAGMENT_SLOT_REGISTRY`). Plugins register against existing slots,
19//!    they do NOT invent new ones.
20//! 3. **Bounded performance.** Fragments rendering on every frame must be
21//!    fast (< 1 ms). Slower fragments declare themselves async-friendly via
22//!    `RenderFragment::ready = false`; the TUI shows a placeholder until ready.
23//! 4. **Conflict resolution.** If two plugins claim the same slot, higher
24//!    `priority` wins; ties broken lexicographically; conflicts logged once.
25
26use serde::{Deserialize, Serialize};
27
28use crate::render::TokenSpan;
29use crate::traits::core::PluginResult;
30
31/// Slot identifier — `<widget_name>.<slot_name>` (e.g. `"mr_detail.stack_panel"`).
32pub type FragmentSlot = String;
33
34/// Registry of all valid slot identifiers in SDK 0.3.
35///
36/// New slots require an SDK minor bump and TUI core support. The plugin
37/// loader rejects manifests that declare slots not in this registry.
38pub const FRAGMENT_SLOT_REGISTRY: &[&str] = &[
39    "mr_detail.stack_panel",
40    "mr_detail.linked_issues",
41    "mr_detail.ci_status",
42    "mr_list.backend_pill",
43    "mr_list.stack_indicator",
44    "dashboard.profile_card",
45    "dashboard.releases_card",
46    "file_tree.node_decoration",
47    "review.draft_pill",
48];
49
50/// Context the host passes to the plugin when asking it to render a fragment.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FragmentContext {
53    pub slot: FragmentSlot,
54    /// Slot-specific payload. Schema documented per slot in the user-facing
55    /// SDK reference; validated by the SDK before dispatch.
56    pub data: serde_json::Value,
57    /// Hard cap. Fragment must not return more rows.
58    pub max_rows: usize,
59    /// Hard cap. Fragment must not return more columns.
60    pub max_cols: usize,
61}
62
63/// What the plugin returns.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct RenderFragment {
66    /// Sequence of styled lines. May be empty (means "render nothing for this slot").
67    pub lines: Vec<Vec<TokenSpan>>,
68    /// `true` = render synchronously next frame.
69    /// `false` = TUI shows placeholder, asks again on next tick.
70    pub ready: bool,
71    /// Optional named actions the user can trigger from this fragment via hotkey.
72    pub actions: Vec<FragmentAction>,
73}
74
75/// A user-actionable hotkey scoped to a fragment.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct FragmentAction {
78    /// Hotkey identifier. Format follows the same scheme as the rest of the
79    /// TUI (e.g. `"Ctrl+L"`, `"g s"`).
80    pub key: String,
81    /// User-visible label. Rendered in the status bar when the fragment is
82    /// focused.
83    pub label: String,
84    /// Plugin command name dispatched on key press.
85    pub command: String,
86}
87
88/// Trait plugins implement.
89pub trait FragmentRenderer {
90    /// Slots this plugin claims to fill. Must be a subset of `FRAGMENT_SLOT_REGISTRY`.
91    fn fragment_slots(&self) -> Vec<FragmentSlot>;
92
93    /// Render a fragment for the given slot + context.
94    fn render_fragment(&mut self, ctx: &FragmentContext) -> PluginResult<RenderFragment>;
95}
96
97/// Validate that a fragment slot is recognized.
98pub fn slot_is_valid(slot: &str) -> bool {
99    FRAGMENT_SLOT_REGISTRY.iter().any(|&s| s == slot)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn known_slots_validate() {
108        for slot in FRAGMENT_SLOT_REGISTRY {
109            assert!(slot_is_valid(slot), "slot {slot} should validate");
110        }
111    }
112
113    #[test]
114    fn unknown_slot_rejected() {
115        assert!(!slot_is_valid("unknown.slot"));
116        assert!(!slot_is_valid(""));
117    }
118
119    #[test]
120    fn render_fragment_serde_round_trip() {
121        let f = RenderFragment {
122            lines: vec![],
123            ready: true,
124            actions: vec![FragmentAction {
125                key: "Ctrl+L".into(),
126                label: "Land stack".into(),
127                command: "stack-land".into(),
128            }],
129        };
130        let s = serde_json::to_string(&f).unwrap();
131        let back: RenderFragment = serde_json::from_str(&s).unwrap();
132        assert!(back.ready);
133        assert_eq!(back.actions.len(), 1);
134        assert_eq!(back.actions[0].command, "stack-land");
135    }
136}