openapi-tui 0.10.2

This TUI allows you to list and browse APIs described by the openapi specification.
use color_eyre::eyre::Result;
use ratatui::{
  prelude::*,
  widgets::{block::*, *},
};

use crate::{action::Action, components::schema_viewer::SchemaViewer, panes::Pane, state::State, tui::Frame};

pub struct ResponseType {
  status: String,
  media_type: String,
  schema: serde_json::Value,
}

#[derive(Default)]
pub struct ResponsePane {
  focused: bool,
  focused_border_style: Style,

  schemas: Vec<ResponseType>,
  schemas_index: usize,
  schema_viewer: SchemaViewer,
}

impl ResponsePane {
  pub fn new(focused: bool, focused_border_style: Style) -> Self {
    Self {
      focused,
      focused_border_style,
      schemas: Vec::default(),
      schemas_index: 0,
      schema_viewer: SchemaViewer::default(),
    }
  }

  fn border_style(&self) -> Style {
    match self.focused {
      true => self.focused_border_style,
      false => Style::default(),
    }
  }

  fn border_type(&self) -> BorderType {
    match self.focused {
      true => BorderType::Thick,
      false => BorderType::Plain,
    }
  }

  fn status_color(&self, status: &str) -> Color {
    if status.starts_with('2') || status.starts_with("default") {
      return Color::LightCyan;
    }
    if status.starts_with('3') {
      return Color::LightBlue;
    }
    if status.starts_with('4') {
      return Color::LightYellow;
    }
    if status.starts_with('5') {
      return Color::LightRed;
    }
    Color::default()
  }

  fn nested_schema_path_line(&self) -> Line {
    let schema_path = self.schema_viewer.schema_path();
    if schema_path.is_empty() {
      return Line::default();
    }
    let mut line = String::from("[ ");
    line.push_str(&schema_path.join(" > "));
    line.push_str(" ]");
    Line::from(line)
  }

  fn init_schema(&mut self, state: &State) -> Result<()> {
    {
      self.schemas = vec![];

      if let Some(operation_item) = state.active_operation() {
        self.schemas = operation_item
          .operation
          .responses
          .iter()
          .flatten()
          .filter_map(|(status, value)| {
            value.resolve(&state.openapi_spec).map_or(None, |v| Some((status.to_string(), v)))
          })
          .flat_map(|(status, response)| {
            response
              .content
              .iter()
              .flatten()
              .filter_map(|(media_type, media)| {
                media.schema.as_ref().map(|schema| {
                  Some(ResponseType {
                    status: status.clone(),
                    media_type: media_type.to_string(),
                    schema: schema.clone(),
                  })
                })
              })
              .flatten()
              .collect::<Vec<ResponseType>>()
          })
          .collect();
      }
    }
    if let Some(response_type) = self.schemas.get(self.schemas_index) {
      self.schema_viewer.set(response_type.schema.clone())?;
    } else {
      self.schema_viewer.clear();
    }
    Ok(())
  }
}

impl Pane for ResponsePane {
  fn init(&mut self, state: &State) -> Result<()> {
    self.schema_viewer.set_components(state);
    self.init_schema(state)?;
    Ok(())
  }

  fn height_constraint(&self) -> Constraint {
    if self.schemas.get(self.schemas_index).is_none() {
      return Constraint::Min(2);
    }

    match self.focused {
      true => Constraint::Fill(30),
      false => Constraint::Fill(10),
    }
  }

  fn update(&mut self, action: Action, state: &mut State) -> Result<Option<Action>> {
    match action {
      Action::Update => {
        self.schemas_index = 0;
        self.init_schema(state)?;
      },
      Action::Down => {
        self.schema_viewer.down();
      },
      Action::Up => {
        self.schema_viewer.up();
      },
      Action::Tab(index) if index < self.schemas.len().try_into()? => {
        self.schemas_index = index.try_into()?;
        self.init_schema(state)?;
      },
      Action::TabNext => {
        let next_tab_index = self.schemas_index + 1;
        self.schemas_index = if next_tab_index < self.schemas.len() { next_tab_index } else { 0 };
        self.init_schema(state)?;
      },
      Action::TabPrev => {
        self.schemas_index = if self.schemas_index > 0 { self.schemas_index - 1 } else { self.schemas.len() - 1 };
        self.init_schema(state)?;
      },
      Action::Focus => {
        self.focused = true;
        static STATUS_LINE: &str = "[1-9 → select tab] [g,b → go/back definitions]";
        return Ok(Some(Action::TimedStatusLine(STATUS_LINE.into(), 3)));
      },
      Action::UnFocus => {
        self.focused = false;
      },
      Action::Go => self.schema_viewer.go()?,
      Action::Back => {
        if let Some(response_type) = self.schemas.get(self.schemas_index) {
          self.schema_viewer.back(response_type.schema.clone())?;
        }
      },
      _ => {},
    }

    Ok(None)
  }

  fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, _state: &State) -> Result<()> {
    let inner = area.inner(Margin { horizontal: 1, vertical: 1 });
    frame.render_widget(
      Tabs::new(self.schemas.iter().map(|resp| {
        Span::styled(
          format!("{} [{}]", resp.status, resp.media_type),
          Style::default().fg(self.status_color(resp.status.as_str())).dim(),
        )
      }))
      .highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED).not_dim())
      .select(self.schemas_index),
      inner,
    );

    let mut inner = inner.inner(Margin { horizontal: 1, vertical: 1 });
    inner.height = inner.height.saturating_add(1);
    self.schema_viewer.render_widget(frame, inner);

    frame.render_widget(
      Block::default()
        .title("Responses")
        .borders(Borders::ALL)
        .border_style(self.border_style())
        .border_type(self.border_type())
        .title_bottom(
          self
            .nested_schema_path_line()
            .style(Style::default().fg(Color::White).dim().add_modifier(Modifier::ITALIC))
            .left_aligned(),
        ),
      area,
    );
    Ok(())
  }
}