use crate::event::Rect;
use crate::theme::Theme;
use crate::types::{Color, Modifiers, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StageStatus {
Pending,
Active,
Done,
Failed,
Skipped,
Stale,
}
pub fn status_glyph(status: &StageStatus) -> &'static str {
match status {
StageStatus::Done => "✓",
StageStatus::Active => "●",
StageStatus::Failed => "✗",
StageStatus::Pending => "·",
StageStatus::Skipped => "─",
StageStatus::Stale => "↻",
}
}
pub fn status_color(status: &StageStatus, theme: &Theme) -> Color {
match status {
StageStatus::Done => theme.git_added,
StageStatus::Active => theme.accent_bg,
StageStatus::Failed => theme.error_fg,
StageStatus::Pending | StageStatus::Skipped | StageStatus::Stale => theme.muted_fg,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineStage {
pub label: String,
pub status: StageStatus,
#[serde(default)]
pub action: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineView {
pub id: WidgetId,
pub stages: Vec<PipelineStage>,
#[serde(default)]
pub focused_stage: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PipelineEvent {
StageAction { index: usize },
StageSelected { index: usize },
KeyPressed { key: String, modifiers: Modifiers },
}
#[derive(Debug, Clone, PartialEq)]
pub struct StageBounds {
pub index: usize,
pub box_bounds: Rect,
pub icon_bounds: Rect,
pub label_bounds: Rect,
pub action_bounds: Option<Rect>,
pub arrow_bounds: Option<Rect>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PipelineHit {
Action(usize),
Body(usize),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PipelineViewLayout {
pub bounds: Rect,
pub stages: Vec<StageBounds>,
pub stage_width: f32,
pub stage_height: f32,
pub arrow_width: f32,
}
impl PipelineViewLayout {
pub fn hit_test(&self, x: f32, y: f32) -> PipelineHit {
for sb in &self.stages {
if let Some(ab) = sb.action_bounds {
if contains(ab, x, y) {
return PipelineHit::Action(sb.index);
}
}
if contains(sb.box_bounds, x, y) {
return PipelineHit::Body(sb.index);
}
}
PipelineHit::Empty
}
}
fn contains(r: Rect, x: f32, y: f32) -> bool {
x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PipelineViewMeasure {
pub width: f32,
pub height: f32,
pub arrow_width: f32,
pub action_height: f32,
}
impl PipelineViewMeasure {
pub fn new(width: f32, height: f32, arrow_width: f32, action_height: f32) -> Self {
Self {
width,
height,
arrow_width,
action_height,
}
}
}
impl PipelineView {
pub fn layout(
&self,
origin_x: f32,
origin_y: f32,
measure: PipelineViewMeasure,
) -> PipelineViewLayout {
let n = self.stages.len();
let bounds = Rect::new(origin_x, origin_y, measure.width, measure.height);
if n == 0 {
return PipelineViewLayout {
bounds,
stages: vec![],
stage_width: 0.0,
stage_height: measure.height,
arrow_width: measure.arrow_width,
};
}
let arrow_total = measure.arrow_width * (n as f32 - 1.0).max(0.0);
let stage_width = ((measure.width - arrow_total) / n as f32).max(1.0);
let stage_height = measure.height;
let mut stages = Vec::with_capacity(n);
let mut x = origin_x;
for (i, _stage) in self.stages.iter().enumerate() {
let box_bounds = Rect::new(x, origin_y, stage_width, stage_height);
let icon_h = (stage_height * 0.4).max(1.0);
let icon_bounds = Rect::new(x, origin_y, stage_width, icon_h);
let label_y = origin_y + icon_h;
let label_h = if measure.action_height > 0.0 {
(stage_height - icon_h - measure.action_height).max(0.0)
} else {
(stage_height - icon_h).max(0.0)
};
let label_bounds = Rect::new(x, label_y, stage_width, label_h);
let action_bounds = if self.stages[i].action.is_some() && measure.action_height > 0.0 {
let ay = origin_y + stage_height - measure.action_height;
Some(Rect::new(x, ay, stage_width, measure.action_height))
} else {
None
};
let arrow_bounds = if i + 1 < n {
Some(Rect::new(
x + stage_width,
origin_y,
measure.arrow_width,
stage_height,
))
} else {
None
};
stages.push(StageBounds {
index: i,
box_bounds,
icon_bounds,
label_bounds,
action_bounds,
arrow_bounds,
});
x += stage_width + measure.arrow_width;
}
PipelineViewLayout {
bounds,
stages,
stage_width,
stage_height,
arrow_width: measure.arrow_width,
}
}
pub fn handle_key(&mut self, key: &str, modifiers: Modifiers) -> Option<PipelineEvent> {
let n = self.stages.len();
if n == 0 {
return None;
}
match key {
"ArrowLeft" | "Left" => {
let cur = self.focused_stage.unwrap_or(0);
self.focused_stage = Some(cur.saturating_sub(1));
None
}
"ArrowRight" | "Right" => {
let cur = self.focused_stage.unwrap_or(0);
self.focused_stage = Some((cur + 1).min(n - 1));
None
}
"Enter" => {
if let Some(idx) = self.focused_stage {
if idx < n {
if self.stages[idx].action.is_some() {
return Some(PipelineEvent::StageAction { index: idx });
} else {
return Some(PipelineEvent::StageSelected { index: idx });
}
}
}
None
}
_ => Some(PipelineEvent::KeyPressed {
key: key.to_string(),
modifiers,
}),
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{status_color, status_glyph, PipelineView, PipelineViewLayout};
use crate::event::{Point, Rect};
use crate::paint_surface::PaintSurface;
use crate::primitives::layout_metrics::{pixel, pixel_pipeline_view_layout};
use crate::theme::Theme;
const ACTION_TINT_ALPHA: f32 = 0.15;
pub(crate) fn paint(
view: &PipelineView,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
) -> PipelineViewLayout {
let layout = pixel_pipeline_view_layout(view, rect.x, rect.y, rect.width, rect.height);
if rect.width <= 0.0 || rect.height <= 0.0 {
return layout;
}
for sb in &layout.stages {
let stage = &view.stages[sb.index];
let is_focused = view.focused_stage == Some(sb.index);
let bb = sb.box_bounds;
if bb.width <= 0.0 || bb.height <= 0.0 {
continue;
}
surface.surface_fill_rect(bb, theme.surface_bg);
let border_color = status_color(&stage.status, theme);
surface.surface_stroke_rect(bb, border_color, pixel::PIPELINE_BORDER_WIDTH as f32);
if is_focused {
let ind_x = bb.x + bb.width / 2.0;
let tri_tip_y = bb.y - 1.0;
let tri_base_y = bb.y - pixel::PIPELINE_FOCUS_INDICATOR_H + 1.0;
let half_w = 5.0;
surface.surface_draw_line(
Point::new(ind_x - half_w, tri_base_y),
Point::new(ind_x, tri_tip_y),
theme.muted_fg,
1.5,
);
surface.surface_draw_line(
Point::new(ind_x, tri_tip_y),
Point::new(ind_x + half_w, tri_base_y),
theme.muted_fg,
1.5,
);
}
surface.surface_push_clip(bb);
let icon_text = status_glyph(&stage.status);
let icon_color = status_color(&stage.status, theme);
let (iw, ih) = surface.surface_measure_text(icon_text);
let icon_h = bb.height / 3.0;
let icon_cx = bb.x + bb.width / 2.0 - iw / 2.0;
let icon_cy = bb.y + icon_h / 2.0 - ih / 2.0;
surface.surface_draw_text_run(
Rect::new(icon_cx, icon_cy, iw.max(1.0), ih.max(1.0)),
icon_text,
icon_color,
);
if !stage.label.is_empty() {
let (lw, lh) = surface.surface_measure_text(&stage.label);
let avail_w = (bb.width - 2.0 * pixel::PIPELINE_H_PAD as f32).max(0.0);
let draw_w = lw.min(avail_w).max(1.0);
let label_cx = bb.x + bb.width / 2.0 - draw_w / 2.0;
let label_cy = bb.y + bb.height / 2.0 - lh / 2.0;
surface.surface_draw_text_run(
Rect::new(label_cx, label_cy, draw_w, lh.max(1.0)),
&stage.label,
theme.foreground,
);
}
if let (Some(ab), Some(action_text)) = (sb.action_bounds, &stage.action) {
surface.surface_fill_rect_alpha(ab, theme.accent_bg, ACTION_TINT_ALPHA);
surface.surface_stroke_rect(ab, theme.accent_bg, 1.0);
let (bw2, bh2) = surface.surface_measure_text(action_text);
let btn_cx = ab.x + ab.width / 2.0 - bw2 / 2.0;
let btn_cy = ab.y + ab.height / 2.0 - bh2 / 2.0;
surface.surface_draw_text_run(
Rect::new(btn_cx, btn_cy, bw2.max(1.0), bh2.max(1.0)),
action_text,
theme.accent_bg,
);
}
surface.surface_pop_clip();
if let Some(arrow) = sb.arrow_bounds {
let ax = arrow.x;
let mid_y = arrow.y + arrow.height / 2.0;
let aw = arrow.width;
surface.surface_draw_line(
Point::new(ax, mid_y),
Point::new(ax + aw - 6.0, mid_y),
theme.muted_fg,
1.0,
);
let tip_x = ax + aw - 1.0;
let tail_x = ax + aw - 7.0;
let half_h = 4.0;
surface.surface_draw_line(
Point::new(tail_x, mid_y - half_h),
Point::new(tip_x, mid_y),
theme.muted_fg,
1.0,
);
surface.surface_draw_line(
Point::new(tip_x, mid_y),
Point::new(tail_x, mid_y + half_h),
theme.muted_fg,
1.0,
);
}
}
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::pipeline_view::{PipelineStage, StageStatus};
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
strokes: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
lines: Vec<(Point, Point, Color)>,
clip_pushes: Vec<Rect>,
clip_pops: usize,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(300.0, 80.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, _stroke_width: f32) {
self.strokes.push((rect, color));
}
fn surface_stroke_rounded_rect(
&mut self,
rect: Rect,
_radius: f32,
color: Color,
_stroke_width: f32,
) {
self.strokes.push((rect, color));
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
from: crate::Point,
to: crate::Point,
color: Color,
_stroke_width: f32,
) {
self.lines.push((from, to, color));
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clip_pushes.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clip_pops += 1;
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn make_view() -> PipelineView {
PipelineView {
id: WidgetId::new("pipe"),
stages: vec![
PipelineStage {
label: "Build".into(),
status: StageStatus::Done,
action: None,
},
PipelineStage {
label: "Test".into(),
status: StageStatus::Active,
action: Some("Retry".into()),
},
],
focused_stage: Some(0),
}
}
#[test]
fn zero_size_rect_paints_nothing() {
let view = make_view();
let mut surface = RecordingSurface::default();
paint(
&view,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 0.0, 0.0),
);
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
}
#[test]
fn stage_box_border_is_a_straight_rect() {
let view = make_view();
let mut surface = RecordingSurface::default();
let layout = paint(
&view,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 300.0, 80.0),
);
let bb = layout.stages[0].box_bounds;
assert!(surface.strokes.iter().any(|(r, _)| *r == bb));
}
#[test]
fn focused_stage_paints_a_two_line_chevron() {
let view = make_view();
let mut surface = RecordingSurface::default();
paint(
&view,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 300.0, 80.0),
);
assert_eq!(
surface.lines.len(),
2 + 3,
"1 focus chevron (2 lines) + 1 arrow connector (3 lines)"
);
}
#[test]
fn each_stage_clips_its_content_to_the_box() {
let view = make_view();
let mut surface = RecordingSurface::default();
paint(
&view,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 300.0, 80.0),
);
assert_eq!(surface.clip_pushes.len(), surface.clip_pops);
assert_eq!(surface.clip_pushes.len(), view.stages.len());
}
#[test]
fn action_button_paints_a_translucent_tint_over_the_full_bounds() {
let view = make_view();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&view,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 300.0, 80.0),
);
let ab = layout.stages[1]
.action_bounds
.expect("stage 1 has an action button");
let tint = surface
.fills
.iter()
.find(|(r, _)| *r == ab)
.map(|(_, c)| *c)
.expect("action tint fill over the full action_bounds");
assert_eq!(tint.r, theme.accent_bg.r);
assert_eq!(tint.g, theme.accent_bg.g);
assert_eq!(tint.b, theme.accent_bg.b);
assert!(
tint.a < theme.accent_bg.a,
"tint alpha ({}) must be lower than accent_bg's own opaque alpha ({})",
tint.a,
theme.accent_bg.a,
);
}
#[test]
fn action_button_paints_a_border_not_bracket_text() {
let view = make_view();
let mut surface = RecordingSurface::default();
let layout = paint(
&view,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 300.0, 80.0),
);
let ab = layout.stages[1]
.action_bounds
.expect("stage 1 has an action button");
assert!(
surface.text_runs.iter().any(|(_, t, _)| t == "Retry"),
"the action label itself must still be painted; text_runs \
were {:?}",
surface.text_runs,
);
assert!(
!surface.text_runs.iter().any(|(_, t, _)| t.contains('[')),
"no bracket glyph should be painted for the action label; \
text_runs were {:?}",
surface.text_runs,
);
assert!(
surface.strokes.iter().any(|(r, _)| *r == ab),
"the action button must stroke a border around its full \
bounds; strokes were {:?}",
surface.strokes,
);
}
#[test]
fn icon_and_label_are_centred_using_their_measured_size() {
let view = make_view();
let mut surface = RecordingSurface::default();
let layout = paint(
&view,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 300.0, 80.0),
);
let bb = layout.stages[0].box_bounds;
let (_, icon_text, _) = surface
.text_runs
.iter()
.find(|(_, t, _)| t == status_glyph(&StageStatus::Done))
.expect("icon text painted");
assert_eq!(icon_text, status_glyph(&StageStatus::Done));
let (label_rect, _, _) = surface
.text_runs
.iter()
.find(|(_, t, _)| t == "Build")
.expect("label text painted");
let (lw, lh) = surface.surface_measure_text("Build");
assert!((label_rect.width - lw).abs() < 0.01);
let expected_cy = bb.y + bb.height / 2.0 - lh / 2.0;
assert!((label_rect.y - expected_cy).abs() < 0.01);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::WidgetId;
fn make_pipeline() -> PipelineView {
PipelineView {
id: WidgetId::new("pipe"),
stages: vec![
PipelineStage {
label: "Build".into(),
status: StageStatus::Done,
action: None,
},
PipelineStage {
label: "Test".into(),
status: StageStatus::Active,
action: Some("Retry".into()),
},
PipelineStage {
label: "Deploy".into(),
status: StageStatus::Pending,
action: Some("Go".into()),
},
],
focused_stage: None,
}
}
fn measure() -> PipelineViewMeasure {
PipelineViewMeasure::new(300.0, 60.0, 4.0, 10.0)
}
#[test]
fn construction_and_stage_count() {
let p = make_pipeline();
assert_eq!(p.stages.len(), 3);
assert_eq!(p.stages[0].label, "Build");
assert_eq!(p.stages[1].status, StageStatus::Active);
assert!(p.stages[2].action.is_some());
}
#[test]
fn stage_mutation() {
let mut p = make_pipeline();
p.stages[0].status = StageStatus::Failed;
assert_eq!(p.stages[0].status, StageStatus::Failed);
p.stages.push(PipelineStage {
label: "Notify".into(),
status: StageStatus::Pending,
action: None,
});
assert_eq!(p.stages.len(), 4);
}
#[test]
fn layout_equal_width_boxes() {
let p = make_pipeline();
let layout = p.layout(0.0, 0.0, measure());
let expected_w = (300.0 - 8.0) / 3.0;
assert!(
(layout.stage_width - expected_w).abs() < 0.01,
"stage_width = {}, expected ~{}",
layout.stage_width,
expected_w
);
for sb in &layout.stages {
assert!(
(sb.box_bounds.width - expected_w).abs() < 0.01,
"stage {} box width = {}, expected {}",
sb.index,
sb.box_bounds.width,
expected_w
);
}
}
#[test]
fn layout_origin_offset() {
let p = make_pipeline();
let layout = p.layout(10.0, 5.0, measure());
assert_eq!(layout.bounds.x, 10.0);
assert_eq!(layout.bounds.y, 5.0);
assert_eq!(layout.stages[0].box_bounds.x, 10.0);
assert_eq!(layout.stages[0].box_bounds.y, 5.0);
}
#[test]
fn layout_arrow_between_stages() {
let p = make_pipeline();
let layout = p.layout(0.0, 0.0, measure());
let arrow = layout.stages[0].arrow_bounds.expect("arrow after stage 0");
assert_eq!(arrow.x, layout.stages[0].box_bounds.x + layout.stage_width);
assert_eq!(arrow.width, 4.0);
assert!(layout.stages[2].arrow_bounds.is_none());
}
#[test]
fn layout_action_bounds_present_when_action_set() {
let p = make_pipeline();
let layout = p.layout(0.0, 0.0, measure());
assert!(layout.stages[0].action_bounds.is_none());
let ab = layout.stages[1]
.action_bounds
.expect("action bounds for stage 1");
assert_eq!(ab.height, 10.0);
assert!(layout.stages[2].action_bounds.is_some());
}
#[test]
fn layout_empty_pipeline_is_safe() {
let p = PipelineView {
id: WidgetId::new("empty"),
stages: vec![],
focused_stage: None,
};
let layout = p.layout(0.0, 0.0, PipelineViewMeasure::new(300.0, 60.0, 4.0, 10.0));
assert_eq!(layout.stages.len(), 0);
assert_eq!(layout.stage_width, 0.0);
}
#[test]
fn hit_test_action_button_returns_action_event() {
let p = make_pipeline();
let layout = p.layout(0.0, 0.0, measure());
let ab = layout.stages[1].action_bounds.unwrap();
let cx = ab.x + ab.width / 2.0;
let cy = ab.y + ab.height / 2.0;
assert_eq!(layout.hit_test(cx, cy), PipelineHit::Action(1));
}
#[test]
fn hit_test_stage_body_returns_body_event() {
let p = make_pipeline();
let layout = p.layout(0.0, 0.0, measure());
let bb = layout.stages[0].box_bounds;
let cx = bb.x + bb.width / 2.0;
let cy = bb.y + bb.height / 4.0;
assert_eq!(layout.hit_test(cx, cy), PipelineHit::Body(0));
}
#[test]
fn hit_test_miss_returns_empty() {
let p = make_pipeline();
let layout = p.layout(0.0, 0.0, measure());
assert_eq!(layout.hit_test(500.0, 30.0), PipelineHit::Empty);
assert_eq!(layout.hit_test(50.0, -1.0), PipelineHit::Empty);
}
#[test]
fn hit_test_arrow_region_returns_empty() {
let p = make_pipeline();
let layout = p.layout(0.0, 0.0, measure());
let arrow = layout.stages[0].arrow_bounds.unwrap();
let hit = layout.hit_test(arrow.x + arrow.width / 2.0, arrow.y + arrow.height / 2.0);
assert_eq!(hit, PipelineHit::Empty);
}
#[test]
fn keyboard_right_moves_focus() {
let mut p = make_pipeline();
p.focused_stage = Some(0);
p.handle_key("Right", Modifiers::default());
assert_eq!(p.focused_stage, Some(1));
p.handle_key("ArrowRight", Modifiers::default());
assert_eq!(p.focused_stage, Some(2));
}
#[test]
fn keyboard_right_clamps_at_last() {
let mut p = make_pipeline();
p.focused_stage = Some(2); p.handle_key("Right", Modifiers::default());
assert_eq!(p.focused_stage, Some(2));
}
#[test]
fn keyboard_left_moves_focus() {
let mut p = make_pipeline();
p.focused_stage = Some(2);
p.handle_key("Left", Modifiers::default());
assert_eq!(p.focused_stage, Some(1));
p.handle_key("ArrowLeft", Modifiers::default());
assert_eq!(p.focused_stage, Some(0));
}
#[test]
fn keyboard_left_clamps_at_zero() {
let mut p = make_pipeline();
p.focused_stage = Some(0);
p.handle_key("Left", Modifiers::default());
assert_eq!(p.focused_stage, Some(0));
}
#[test]
fn keyboard_enter_fires_action_when_present() {
let mut p = make_pipeline();
p.focused_stage = Some(1); let event = p.handle_key("Enter", Modifiers::default());
assert_eq!(event, Some(PipelineEvent::StageAction { index: 1 }));
}
#[test]
fn keyboard_enter_fires_selected_when_no_action() {
let mut p = make_pipeline();
p.focused_stage = Some(0); let event = p.handle_key("Enter", Modifiers::default());
assert_eq!(event, Some(PipelineEvent::StageSelected { index: 0 }));
}
#[test]
fn keyboard_enter_no_focus_is_noop() {
let mut p = make_pipeline();
p.focused_stage = None;
let event = p.handle_key("Enter", Modifiers::default());
assert_eq!(event, None);
}
#[test]
fn keyboard_unknown_key_passes_through() {
let mut p = make_pipeline();
p.focused_stage = Some(0);
let event = p.handle_key("Escape", Modifiers::default());
assert!(matches!(event, Some(PipelineEvent::KeyPressed { .. })));
}
#[test]
fn serde_roundtrip() {
let p = make_pipeline();
let json = serde_json::to_string(&p).unwrap();
let back: PipelineView = serde_json::from_str(&json).unwrap();
assert_eq!(p, back);
}
#[test]
fn event_serde_roundtrip() {
let events = vec![
PipelineEvent::StageAction { index: 2 },
PipelineEvent::StageSelected { index: 0 },
PipelineEvent::KeyPressed {
key: "Escape".into(),
modifiers: Modifiers::default(),
},
];
for e in &events {
let json = serde_json::to_string(e).unwrap();
let back: PipelineEvent = serde_json::from_str(&json).unwrap();
assert_eq!(e, &back);
}
}
}