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}