use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
pub type JsonMap = serde_json::Map<String, Json>;
use crate::extras::icon::Icon;
use crate::node::{ActionId, Node};
use crate::patch::NodeOp;
use crate::style::Color;
use crate::text::Line;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Slot {
Overlay,
SidePane,
BottomPane,
EditorReplace,
Fullscreen,
#[default]
Widget,
WidgetBelow,
Status,
TurnFooter,
Header,
Footer,
Settings,
}
pub type Placement = Slot;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WidgetPlacement {
#[default]
AboveEditor,
BelowEditor,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReservedComponent {
Board,
Cron,
Files,
Git,
Browser,
Terminal,
SessionInfo,
LhStatus,
TurnFiles,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EntrySize {
Small,
#[default]
Medium,
Large,
Full,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
#[default]
Session,
Host,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeyBinding {
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<ActionId>,
pub label: String,
}
#[allow(
clippy::large_enum_variant,
reason = "an entry holds one view; boxing the tree root would force a clone per render"
)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum UiView {
Tree {
root: Node,
},
Lines {
lines: Vec<String>,
},
Status {
text: Line,
},
Reserved {
component: ReservedComponent,
#[serde(default, skip_serializing_if = "Option::is_none")]
fallback: Option<Box<Node>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
params: Option<Json>,
},
Renderer {
key: String,
},
Settings {
namespace: crate::settings::SettingsNamespace,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiEntry {
pub id: String,
pub extension_id: String,
pub scope: Scope,
pub slot: Slot,
#[serde(default)]
pub order: i32,
pub seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<Icon>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge: Option<Line>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tone: Option<Color>,
pub view: UiView,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<KeyBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub close_on_escape: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<EntrySize>,
pub rev: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct UiRequest {
pub id: String,
#[serde(flatten)]
pub fields: JsonMap,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_visible: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shortcuts: Vec<KeyBinding>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiMetaPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_visible: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortcuts: Option<Vec<KeyBinding>>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntryMetaPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<Icon>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge: Option<Line>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keys: Option<Vec<KeyBinding>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tone: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus: Option<bool>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DismissReason {
Answered,
Timeout,
#[default]
Cancelled,
}
#[allow(
clippy::large_enum_variant,
reason = "registry ops are built once per batch; boxing the entry adds noise"
)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum RegistryOp {
Put {
entry: UiEntry,
},
Del {
id: String,
},
Meta {
id: String,
set: EntryMetaPatch,
},
Tree {
id: String,
rev: u64,
ops: Vec<NodeOp>,
},
Invalidate {
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
turns: Option<Vec<String>>,
},
Dialog {
request: UiRequest,
},
Dismiss {
id: String,
reason: DismissReason,
},
SessionMeta {
set: UiMetaPatch,
},
Effect {
request: UiRequest,
},
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiSnapshot {
pub scope: Scope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub rev: u64,
#[serde(default)]
pub entries: Vec<UiEntry>,
#[serde(default)]
pub dialogs: Vec<UiRequest>,
#[serde(default)]
pub meta: UiMeta,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiPatch {
pub scope: Scope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub rev: u64,
#[serde(default)]
pub ops: Vec<RegistryOp>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActionSource {
Tui,
#[default]
App,
Rpc,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiAction {
pub panel_id: String,
pub action_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Json>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub values: Option<JsonMap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<crate::patch::NodePath>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<ActionSource>,
}
pub const UI_ACTION_EVENT_TYPE: &str = "oino_ui_action";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiVisible {
pub entry_id: String,
pub visible: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiCapabilities {
pub protocol: u8,
pub dialogs: Vec<String>,
pub slots: Vec<Slot>,
pub reserved: Vec<ReservedComponent>,
pub nodes: String,
pub images: bool,
pub os_notify: bool,
pub clipboard: bool,
}
impl Default for UiCapabilities {
fn default() -> Self {
Self::full()
}
}
impl UiCapabilities {
#[must_use]
pub fn full() -> Self {
Self {
protocol: 2,
dialogs: [
"select",
"confirm",
"input",
"editor",
"multiSelect",
"form",
"approval",
"custom",
"clipboardReadImage",
"editExternally",
]
.into_iter()
.map(str::to_owned)
.collect(),
slots: [
Slot::Overlay,
Slot::SidePane,
Slot::BottomPane,
Slot::EditorReplace,
Slot::Fullscreen,
Slot::Widget,
Slot::WidgetBelow,
Slot::Status,
Slot::TurnFooter,
Slot::Header,
Slot::Footer,
Slot::Settings,
]
.into_iter()
.collect(),
reserved: [
ReservedComponent::Board,
ReservedComponent::Cron,
ReservedComponent::Files,
ReservedComponent::Git,
ReservedComponent::Browser,
ReservedComponent::Terminal,
ReservedComponent::SessionInfo,
ReservedComponent::LhStatus,
ReservedComponent::TurnFiles,
]
.into_iter()
.collect(),
nodes: "all".to_owned(),
images: true,
os_notify: true,
clipboard: true,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PanelOpen {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub panel_id: Option<String>,
pub placement: Placement,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<Icon>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge: Option<Line>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<Node>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<KeyBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub close_on_escape: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<EntrySize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<Json>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PanelUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch: Option<Vec<NodeOp>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<Icon>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge: Option<Line>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keys: Option<Vec<KeyBinding>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback: Option<Node>,
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "tests assert by unwrapping"
)]
use super::*;
use crate::extras::log::Log;
#[test]
fn action_event_matches_spec_example() {
let raw = r#"{"panelId":"unipi.kanboard/board","actionId":"open-task",
"value":"T-12","path":["main","tasks"],"values":{"filter":"open"},
"source":"app"}"#;
let action: UiAction = serde_json::from_str(raw).unwrap_or_default();
assert_eq!(action.panel_id, "unipi.kanboard/board");
assert_eq!(action.action_id, "open-task");
assert_eq!(action.source, Some(ActionSource::App));
}
#[test]
fn snapshot_and_patch_round_trip() {
let snapshot = UiSnapshot {
scope: Scope::Session,
session_id: Some("s1".into()),
rev: 7,
entries: vec![UiEntry {
id: "ext/board".into(),
extension_id: "ext".into(),
scope: Scope::Session,
slot: Slot::SidePane,
order: 0,
seq: 1,
title: Some("Board".into()),
icon: Some(Icon::Board),
badge: None,
tone: None,
view: UiView::Tree {
root: Log::new(["a"]).into(),
},
keys: vec![KeyBinding {
key: "enter".into(),
action: Some("open".into()),
label: "open".into(),
}],
focus: Some(true),
close_on_escape: None,
size: None,
rev: 3,
}],
dialogs: vec![UiRequest {
id: "1".into(),
fields: serde_json::Map::from_iter([
("method".to_owned(), Json::String("select".into())),
("title".to_owned(), Json::String("Pick".into())),
]),
}],
meta: UiMeta {
title: Some("fix: bug".into()),
working_message: None,
working_visible: None,
shortcuts: Vec::new(),
},
};
let json = serde_json::to_value(&snapshot).unwrap_or(Json::Null);
let back: UiSnapshot = serde_json::from_value(json).unwrap_or_default();
assert_eq!(snapshot, back);
let patch = UiPatch {
scope: Scope::Session,
session_id: Some("s1".into()),
rev: 8,
ops: vec![
RegistryOp::Put {
entry: snapshot.entries[0].clone(),
},
RegistryOp::Tree {
id: "ext/board".into(),
rev: 4,
ops: vec![],
},
RegistryOp::Del {
id: "ext/old".into(),
},
RegistryOp::Invalidate {
id: "ext/tf".into(),
turns: Some(vec!["t1".into()]),
},
RegistryOp::Dismiss {
id: "1".into(),
reason: DismissReason::Answered,
},
RegistryOp::SessionMeta {
set: UiMetaPatch {
title: Some("x".into()),
..UiMetaPatch::default()
},
},
RegistryOp::Effect {
request: UiRequest::default(),
},
RegistryOp::Meta {
id: "ext/board".into(),
set: EntryMetaPatch {
order: Some(5),
..EntryMetaPatch::default()
},
},
],
};
let json = serde_json::to_value(&patch).unwrap_or(Json::Null);
let back: UiPatch = serde_json::from_value(json).unwrap_or_default();
assert_eq!(patch, back);
}
#[test]
fn capabilities_round_trip() {
let caps = UiCapabilities::full();
let json = serde_json::to_value(&caps).unwrap_or(Json::Null);
let back: UiCapabilities = serde_json::from_value(json).unwrap_or_default();
assert_eq!(caps, back);
assert_eq!(caps.protocol, 2);
}
}