use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const PLUGIN_ACTION_INVOKED_KIND: &str = "plugin_action_invoked";
pub const OVERLAY_EVENT_KIND: &str = "overlay_event";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScriptEvent {
pub kind: String,
pub data: ScriptEventData,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "data_type")]
pub enum ScriptEventData {
Empty {},
CwdChanged {
cwd: String,
},
CommandComplete {
command: String,
exit_code: Option<i32>,
},
TitleChanged {
title: String,
},
SizeChanged {
cols: usize,
rows: usize,
},
VariableChanged {
name: String,
value: String,
old_value: Option<String>,
},
EnvironmentChanged {
key: String,
value: String,
old_value: Option<String>,
},
BadgeChanged {
text: Option<String>,
},
TriggerMatched {
pattern: String,
matched_text: String,
line: usize,
},
ZoneEvent {
zone_id: u64,
zone_type: String,
event: String,
},
PluginActionInvoked {
action: String,
},
Generic {
fields: HashMap<String, serde_json::Value>,
},
OverlayEvent {
overlay: String,
widget: String,
event: OverlayWidgetEvent,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum OverlayWidgetEvent {
Click {},
TextChanged {
value: String,
},
Select {
index: usize,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum ScriptCommand {
WriteText {
text: String,
},
Notify {
title: String,
body: String,
},
SetBadge {
text: String,
},
SetVariable {
name: String,
value: String,
},
RunCommand {
command: String,
},
ChangeConfig {
key: String,
value: serde_json::Value,
},
Log {
level: String,
message: String,
},
SetPanel {
title: String,
content: String,
},
ClearPanel {},
SetOverlay {
id: String,
position: OverlayPosition,
size: OverlaySize,
#[serde(default = "overlay_default_opacity")]
opacity: f32,
#[serde(default)]
interactive: bool,
content: OverlayScene,
},
ClearOverlay {
id: String,
},
SetWidget {
text: String,
},
}
fn overlay_default_opacity() -> f32 {
1.0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", untagged)]
pub enum OverlayPosition {
Anchor(OverlayAnchor),
Free {
#[serde(default)]
x: f32,
#[serde(default)]
y: f32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OverlayAnchor {
TopLeft,
Top,
TopRight,
Left,
Center,
Right,
BottomLeft,
Bottom,
BottomRight,
TopStrip,
BottomStrip,
LeftStrip,
RightStrip,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct OverlaySize {
pub w: f32,
pub h: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum OverlayScene {
Text { text: String },
Row { children: Vec<OverlayScene> },
Markdown { text: String },
Button {
id: String,
label: String,
},
TextInput {
id: String,
value: String,
#[serde(default)]
placeholder: String,
},
List {
id: String,
items: Vec<String>,
#[serde(default)]
selected: Option<usize>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct PluginOverlay {
pub id: String,
pub position: OverlayPosition,
pub size: OverlaySize,
pub opacity: f32,
pub interactive: bool,
pub content: OverlayScene,
}
pub fn strip_vt_sequences(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c != '\x1b' {
result.push(c);
continue;
}
match chars.peek().copied() {
Some('[') => {
chars.next(); while let Some(&ch) = chars.peek() {
chars.next();
if ch.is_ascii_alphabetic() || ch == '@' || ch == '`' {
break;
}
}
}
Some(']') => {
chars.next(); while let Some(ch) = chars.next() {
if ch == '\x07' {
break;
}
if ch == '\x1b' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
Some('P') | Some('_') | Some('^') | Some('X') => {
chars.next(); while let Some(ch) = chars.next() {
if ch == '\x1b' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
Some('(') | Some(')') | Some('*') | Some('+') => {
chars.next();
chars.next();
}
Some(_) => {
chars.next();
}
None => {}
}
}
result
}
impl ScriptCommand {
pub fn requires_permission(&self) -> bool {
matches!(
self,
ScriptCommand::RunCommand { .. }
| ScriptCommand::WriteText { .. }
| ScriptCommand::ChangeConfig { .. }
)
}
pub fn permission_flag_name(&self) -> Option<&'static str> {
match self {
ScriptCommand::RunCommand { .. } => Some("allow_run_command"),
ScriptCommand::WriteText { .. } => Some("allow_write_text"),
ScriptCommand::ChangeConfig { .. } => Some("allow_change_config"),
_ => None,
}
}
pub fn is_rate_limited(&self) -> bool {
matches!(
self,
ScriptCommand::RunCommand { .. } | ScriptCommand::WriteText { .. }
)
}
pub fn command_name(&self) -> &'static str {
match self {
ScriptCommand::WriteText { .. } => "WriteText",
ScriptCommand::Notify { .. } => "Notify",
ScriptCommand::SetBadge { .. } => "SetBadge",
ScriptCommand::SetVariable { .. } => "SetVariable",
ScriptCommand::RunCommand { .. } => "RunCommand",
ScriptCommand::ChangeConfig { .. } => "ChangeConfig",
ScriptCommand::Log { .. } => "Log",
ScriptCommand::SetPanel { .. } => "SetPanel",
ScriptCommand::ClearPanel {} => "ClearPanel",
ScriptCommand::SetOverlay { .. } => "SetOverlay",
ScriptCommand::ClearOverlay { .. } => "ClearOverlay",
ScriptCommand::SetWidget { .. } => "SetWidget",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_overlay_round_trips_through_serde() {
let line = r##"{"type":"SetOverlay","id":"hud","position":"top-right","size":{"w":0.2,"h":0.1},"opacity":0.8,"content":{"type":"markdown","text":"# hello"}}"##;
let cmd: ScriptCommand = serde_json::from_str(line).expect("parse SetOverlay line");
match &cmd {
ScriptCommand::SetOverlay {
id,
position,
size,
opacity,
interactive,
content,
} => {
assert_eq!(id, "hud");
assert_eq!(position, &OverlayPosition::Anchor(OverlayAnchor::TopRight));
assert_eq!((size.w, size.h), (0.2, 0.1));
assert_eq!(*opacity, 0.8);
assert!(!interactive, "interactive defaults off");
assert_eq!(
content,
&OverlayScene::Markdown {
text: "# hello".to_string()
}
);
}
other => panic!("wrong variant: {other:?}"),
}
let re = serde_json::to_string(&cmd).expect("serialize SetOverlay");
let back: ScriptCommand = serde_json::from_str(&re).expect("round-trip");
assert_eq!(back, cmd);
}
#[test]
fn clear_overlay_parses() {
let line = r#"{"type":"ClearOverlay","id":"hud"}"#;
let cmd: ScriptCommand = serde_json::from_str(line).expect("parse ClearOverlay line");
assert_eq!(
cmd,
ScriptCommand::ClearOverlay {
id: "hud".to_string()
}
);
}
#[test]
fn set_overlay_free_rect_position_parses() {
let line = r#"{"type":"SetOverlay","id":"f","position":{"x":0.1,"y":0.2},"size":{"w":0.3,"h":0.3},"content":{"type":"text","text":"t"}}"#;
let cmd: ScriptCommand = serde_json::from_str(line).expect("parse free-rect SetOverlay");
match cmd {
ScriptCommand::SetOverlay { position, .. } => {
assert_eq!(position, OverlayPosition::Free { x: 0.1, y: 0.2 });
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn set_widget_round_trips_through_serde() {
let line = r#"{"type":"SetWidget","text":"🕒 14:32"}"#;
let cmd: ScriptCommand = serde_json::from_str(line).expect("parse SetWidget line");
assert_eq!(
cmd,
ScriptCommand::SetWidget {
text: "🕒 14:32".into()
}
);
let re = serde_json::to_string(&cmd).expect("serialize SetWidget");
let back: ScriptCommand = serde_json::from_str(&re).expect("round-trip");
assert_eq!(back, cmd);
}
#[test]
fn set_widget_requires_no_permission_and_is_not_rate_limited() {
let cmd = ScriptCommand::SetWidget {
text: String::new(),
};
assert!(!cmd.requires_permission());
assert!(cmd.permission_flag_name().is_none());
assert!(!cmd.is_rate_limited());
assert_eq!(cmd.command_name(), "SetWidget");
}
#[test]
fn interactive_scene_nodes_round_trip_through_serde() {
let scene = OverlayScene::Row {
children: vec![
OverlayScene::Button {
id: "deploy".to_string(),
label: "Deploy".to_string(),
},
OverlayScene::TextInput {
id: "filter".to_string(),
value: "par".to_string(),
placeholder: "filter…".to_string(),
},
OverlayScene::List {
id: "jobs".to_string(),
items: vec!["one".to_string(), "two".to_string()],
selected: Some(1),
},
],
};
let json = serde_json::to_string(&scene).expect("serialize scene");
let back: OverlayScene = serde_json::from_str(&json).expect("parse scene back");
assert_eq!(back, scene);
assert!(json.contains(r#""type":"button""#), "got: {json}");
assert!(json.contains(r#""type":"text_input""#), "got: {json}");
assert!(json.contains(r#""type":"list""#), "got: {json}");
let no_optional: OverlayScene =
serde_json::from_str(r#"{"type":"text_input","id":"f","value":""}"#)
.expect("optional fields default");
assert_eq!(
no_optional,
OverlayScene::TextInput {
id: "f".to_string(),
value: String::new(),
placeholder: String::new(),
}
);
}
#[test]
fn overlay_event_round_trips_through_serde() {
for data in [
ScriptEventData::OverlayEvent {
overlay: "hud".to_string(),
widget: "deploy".to_string(),
event: OverlayWidgetEvent::Click {},
},
ScriptEventData::OverlayEvent {
overlay: "hud".to_string(),
widget: "filter".to_string(),
event: OverlayWidgetEvent::TextChanged {
value: "par".to_string(),
},
},
ScriptEventData::OverlayEvent {
overlay: "hud".to_string(),
widget: "jobs".to_string(),
event: OverlayWidgetEvent::Select { index: 2 },
},
] {
let json = serde_json::to_string(&data).expect("serialize event data");
let back: ScriptEventData = serde_json::from_str(&json).expect("parse back");
assert_eq!(back, data);
}
let json = serde_json::to_string(&ScriptEventData::OverlayEvent {
overlay: "hud".to_string(),
widget: "deploy".to_string(),
event: OverlayWidgetEvent::Click {},
})
.expect("serialize");
assert_eq!(
json,
r#"{"data_type":"OverlayEvent","overlay":"hud","widget":"deploy","event":{"type":"Click"}}"#
);
}
#[test]
fn plugin_action_invoked_round_trips_through_serde() {
let data = ScriptEventData::PluginActionInvoked {
action: "greet".to_string(),
};
let json = serde_json::to_string(&data).expect("serialize event data");
assert_eq!(
json, r#"{"data_type":"PluginActionInvoked","action":"greet"}"#,
"the wire shape plugin processes parse must stay exact"
);
let back: ScriptEventData = serde_json::from_str(&json).expect("parse event data back");
assert_eq!(back, data);
let event = ScriptEvent {
kind: PLUGIN_ACTION_INVOKED_KIND.to_string(),
data,
};
let line = serde_json::to_string(&event).expect("serialize event");
assert_eq!(
line,
r#"{"kind":"plugin_action_invoked","data":{"data_type":"PluginActionInvoked","action":"greet"}}"#
);
let parsed: ScriptEvent = serde_json::from_str(&line).expect("parse event line back");
assert_eq!(parsed, event);
}
}