blizz-ui 3.0.0-dev.12

Self-rendering terminal UI components for the blizz wizard
Documentation
use std::io;
use std::io::Write;

use crossterm::{
  cursor::{Hide, MoveTo},
  queue,
  terminal::{Clear, ClearType},
};
use rand::Rng;

use crate::layout_panel::LayoutPanel;
use crate::render_context::RenderContext;

/// Bundles writer, RNG, per-frame context, and current panel for rendering.
///
/// Created once at wizard startup. Per-frame context is updated via
/// [`frame()`](Renderer::frame), which scopes each render pass.
/// The active [`LayoutPanel`] is set by [`draw()`](Renderer::draw).
pub struct Renderer<W: Write> {
  pub writer: W,
  pub rng: Box<dyn Rng>,
  ctx: RenderContext,
  panel: LayoutPanel,
}

impl<W: Write> Renderer<W> {
  pub fn new(writer: W, rng: Box<dyn Rng>, ctx: RenderContext) -> Self {
    Self {
      writer,
      rng,
      ctx,
      panel: LayoutPanel {
        row: 0,
        column: 0,
        width: 0,
      },
    }
  }

  pub fn ctx(&self) -> &RenderContext {
    &self.ctx
  }

  pub fn panel(&self) -> LayoutPanel {
    self.panel
  }

  /// Scope a render frame: update the context, clear the screen, run `f`,
  /// then flush.
  ///
  /// Analogous to the shellops `with_cursor` pattern — scoped mutation
  /// with a single point of context update.
  #[cfg(not(tarpaulin_include))]
  pub fn frame<F>(&mut self, ctx: RenderContext, f: F) -> io::Result<()>
  where
    F: FnOnce(&mut Self) -> io::Result<()>,
  {
    self.ctx = ctx;
    queue!(self.writer, Hide, Clear(ClearType::All))?;
    f(self)?;
    self.writer.flush()
  }

  /// Set the active panel and render a component.
  #[cfg(not(tarpaulin_include))]
  pub fn draw(&mut self, component: &impl Component, panel: LayoutPanel) -> io::Result<u16> {
    self.panel = panel;
    component.render(self)
  }

  /// Destructure the renderer for a component render at the active panel.
  ///
  /// Positions the cursor, then passes writer, panel, and RNG to the
  /// closure. The panel is read from the renderer (set by [`draw`](Renderer::draw)).
  #[cfg(not(tarpaulin_include))]
  pub fn with_panel<F>(&mut self, f: F) -> io::Result<u16>
  where
    F: FnOnce(&mut W, LayoutPanel, &mut Box<dyn Rng>) -> io::Result<u16>,
  {
    let panel = self.panel;
    queue!(self.writer, MoveTo(panel.column, panel.row))?;
    f(&mut self.writer, panel, &mut self.rng)
  }
}

/// A self-rendering UI element.
///
/// Components read their panel from the [`Renderer`] (set by
/// [`Renderer::draw`]). Returns the number of terminal rows consumed,
/// so compositors can stack components.
pub trait Component {
  fn render<W: Write>(&self, renderer: &mut Renderer<W>) -> io::Result<u16>;
}

#[cfg(test)]
mod tests {
  use crate::test_helpers::test_renderer;

  #[test]
  fn ctx_returns_terminal_size_and_elapsed() {
    let renderer = test_renderer();
    let ctx = renderer.ctx();
    assert_eq!(ctx.terminal_size.width, 80);
    assert_eq!(ctx.terminal_size.height, 24);
  }

  #[test]
  fn panel_defaults_to_zero() {
    let renderer = test_renderer();
    let panel = renderer.panel();
    assert_eq!(panel.row, 0);
    assert_eq!(panel.column, 0);
    assert_eq!(panel.width, 0);
  }
}