Skip to content

Commit

Permalink
Refactor TUI to bundle TerminalGuard and TuiState
Browse files Browse the repository at this point in the history
  • Loading branch information
9999years committed Mar 1, 2024
1 parent 84800e8 commit 1678c29
Show file tree
Hide file tree
Showing 2 changed files with 199 additions and 117 deletions.
4 changes: 4 additions & 0 deletions src/buffers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ pub const TRACING_BUFFER_CAPACITY: usize = 1024;

/// Size of a buffer for `ghci` output. Used to implement the TUI.
pub const GHCI_BUFFER_CAPACITY: usize = 1024;

/// Initial capacity for the TUI scrollback buffer, containing data written from `ghci` and
/// `tracing` log messages.
pub const TUI_SCROLLBACK_CAPACITY: usize = 16 * 1024;
312 changes: 195 additions & 117 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod terminal;
use std::cmp::min;
use std::ops::Deref;
use std::ops::DerefMut;

use crate::ShutdownHandle;
use ansi_to_tui::IntoText;
use crossterm::event::Event;
use crossterm::event::EventStream;
Expand All @@ -15,24 +16,204 @@ use ratatui::prelude::Rect;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use ratatui::Frame;
use std::cmp::min;
use tap::Conv;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::io::DuplexStream;
use tokio_stream::StreamExt;
use tracing::instrument;

mod terminal;

use crate::buffers::TUI_SCROLLBACK_CAPACITY;
use crate::ShutdownHandle;
use terminal::TerminalGuard;

/// Default amount to scroll on mouse wheel events.
const SCROLL_AMOUNT: usize = 3;

/// State data for drawing the TUI.
#[derive(Default)]
struct Tui {
#[derive(Debug)]
struct TuiState {
quit: bool,
scrollback: Vec<u8>,
// TODO(evan): Follow output when scrolled to bottom
line_count: usize,
scroll_offset: usize,
}

impl Default for TuiState {
fn default() -> Self {
Self {
quit: false,
scrollback: Vec::with_capacity(TUI_SCROLLBACK_CAPACITY),
line_count: 1,
scroll_offset: 0,
}
}
}

impl TuiState {
#[instrument(level = "trace", skip_all)]
fn render_inner(&self, area: Rect, buffer: &mut Buffer) -> miette::Result<()> {
if area.width == 0 || area.height == 0 {
return Ok(());
}

let text = self.scrollback.into_text().into_diagnostic()?;

let scroll_offset = u16::try_from(self.scroll_offset).unwrap();

Paragraph::new(text)
.wrap(Wrap::default())
.scroll((scroll_offset, 0))
.render(area, buffer);

Ok(())
}
}

struct Tui {
terminal: TerminalGuard,
state: TuiState,
}

impl Deref for Tui {
type Target = TuiState;

fn deref(&self) -> &Self::Target {
&self.state
}
}

impl DerefMut for Tui {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.state
}
}

impl Tui {
fn new(terminal: TerminalGuard) -> Self {
Self {
terminal,
state: Default::default(),
}
}

fn size(&mut self) -> Rect {
self.terminal.get_frame().size()
}

fn half_height(&mut self) -> usize {
(self.size().height / 2) as usize
}

fn scroll_up(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}

fn scroll_down(&mut self, amount: usize) {
self.scroll_offset = min(self.scroll_max(), self.scroll_offset.saturating_add(amount));
}

fn scroll_max(&mut self) -> usize {
self.line_count.saturating_sub(self.half_height())
}

fn scroll_to(&mut self, scroll_offset: usize) {
self.scroll_offset = min(self.scroll_max(), scroll_offset);
}

fn maybe_follow(&mut self) {
let height = self.size().height as usize;
if self.scroll_offset >= self.line_count - height - 1 {
self.scroll_offset += 1;
}
}

fn push_line(&mut self, line: String) {
self.scrollback.extend(line.into_bytes());
self.scrollback.push(b'\n');
self.line_count += 1;
self.maybe_follow();
}

#[instrument(level = "trace", skip(self))]
fn render(&mut self) -> miette::Result<()> {
let mut render_result = Ok(());
self.terminal
.draw(|frame| {
let area = frame.size();
let buffer = frame.buffer_mut();
render_result = self.state.render_inner(area, buffer);
})
.into_diagnostic()
.wrap_err("Failed to draw to terminal")?;

Ok(())
}

#[instrument(level = "trace", skip(self))]
fn handle_event(&mut self, event: Event) -> miette::Result<()> {
// TODO: Steal Evan's declarative key matching macros?
// https://github.com/evanrelf/indigo/blob/7a5e8e47291585cae03cdf5a7c47ad3bcd8db3e6/crates/indigo-tui/src/key/macros.rs
match event {
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => {
self.scroll_up(SCROLL_AMOUNT);
}
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => {
self.scroll_down(SCROLL_AMOUNT);
}
Event::Key(key) => match key.modifiers {
KeyModifiers::NONE => match key.code {
KeyCode::Char('j') => {
self.scroll_down(1);
}
KeyCode::Char('k') => {
self.scroll_up(1);
}
KeyCode::Char('g') => {
self.scroll_to(0);
}
_ => {}
},

#[allow(clippy::single_match)]
KeyModifiers::SHIFT => match key.code {
KeyCode::Char('G') => {
self.scroll_to(usize::MAX);
}
_ => {}
},

KeyModifiers::CONTROL => match key.code {
KeyCode::Char('u') => {
let half_height = self.half_height();
self.scroll_up(half_height);
}
KeyCode::Char('d') => {
let half_height = self.half_height();
self.scroll_down(half_height);
}
KeyCode::Char('e') => {
self.scroll_down(1);
}
KeyCode::Char('y') => {
self.scroll_up(1);
}
KeyCode::Char('c') => {
self.quit = true;
}
_ => {}
},

_ => {}
},
_ => {}
}

Ok(())
}
}

/// Start the terminal event loop, reading output from the given readers.
#[instrument(level = "debug", skip_all)]
pub async fn run_tui(
Expand All @@ -43,25 +224,15 @@ pub async fn run_tui(
let mut ghci_reader = BufReader::new(ghci_reader).lines();
let mut tracing_reader = BufReader::new(tracing_reader).lines();

let mut terminal = terminal::enter()?;

let mut tui = Tui::default();
let terminal = terminal::enter()?;
let mut tui = Tui::new(terminal);

let mut event_stream = EventStream::new();

tracing::warn!("`--tui` mode is experimental and may contain bugs or change drastically in future releases.");

while !tui.quit {
let mut render_result = Ok(());
terminal
.draw(|frame| {
let area = frame.size();
let buffer = frame.buffer_mut();
render_result = render(&tui, area, buffer);
})
.into_diagnostic()
.wrap_err("Failed to draw to terminal")?;
render_result?;
tui.render()?;

tokio::select! {
_ = shutdown.on_shutdown_requested() => {
Expand All @@ -72,8 +243,7 @@ pub async fn run_tui(
let line = line.into_diagnostic().wrap_err("Failed to read line from GHCI")?;
match line {
Some(line) => {
tui.scrollback.extend(line.bytes());
tui.scrollback.push(b'\n');
tui.push_line(line);
},
None => {
tui.quit = true;
Expand All @@ -84,8 +254,7 @@ pub async fn run_tui(
line = tracing_reader.next_line() => {
let line = line.into_diagnostic().wrap_err("Failed to read line from tracing")?;
if let Some(line) = line {
tui.scrollback.extend(line.bytes());
tui.scrollback.push(b'\n');
tui.push_line(line);
}
}

Expand All @@ -96,7 +265,7 @@ pub async fn run_tui(
.wrap_err("Failed to get next crossterm event")?;
// TODO: `get_frame` is an expensive call, delay if possible.
// https://github.com/MercuryTechnologies/ghciwatch/pull/206#discussion_r1508364135
handle_event(&mut tui, event, terminal.get_frame())?;
tui.handle_event(event)?;
}
}
}
Expand All @@ -105,94 +274,3 @@ pub async fn run_tui(

Ok(())
}

#[instrument(level = "trace", skip_all)]
fn render(tui: &Tui, area: Rect, buffer: &mut Buffer) -> miette::Result<()> {
if area.width == 0 || area.height == 0 {
return Ok(());
}

let text = tui.scrollback.into_text().into_diagnostic()?;

let scroll_offset = u16::try_from(tui.scroll_offset)
.expect("Failed to convert `scroll_offset` from usize to u16");

Paragraph::new(text)
.wrap(Wrap::default())
.scroll((scroll_offset, 0))
.render(area, buffer);

Ok(())
}

const SCROLL_AMOUNT: usize = 1;

#[instrument(level = "trace", skip(tui, frame))]
fn handle_event(tui: &mut Tui, event: Event, frame: Frame<'_>) -> miette::Result<()> {
let last_line = tui
.scrollback
.split(|byte| *byte == b'\n')
.count()
.saturating_sub(1);

// TODO: Steal Evan's declarative key matching macros?
// https://github.com/evanrelf/indigo/blob/7a5e8e47291585cae03cdf5a7c47ad3bcd8db3e6/crates/indigo-tui/src/key/macros.rs
match event {
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => {
tui.scroll_offset = tui.scroll_offset.saturating_sub(SCROLL_AMOUNT);
}
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => {
tui.scroll_offset += SCROLL_AMOUNT;
}
Event::Key(key) => match key.modifiers {
KeyModifiers::NONE => match key.code {
KeyCode::Char('j') => {
tui.scroll_offset += 1;
}
KeyCode::Char('k') => {
tui.scroll_offset = tui.scroll_offset.saturating_sub(1);
}
KeyCode::Char('g') => {
tui.scroll_offset = 0;
}
_ => {}
},

#[allow(clippy::single_match)]
KeyModifiers::SHIFT => match key.code {
KeyCode::Char('G') => {
tui.scroll_offset = last_line;
}
_ => {}
},

KeyModifiers::CONTROL => match key.code {
KeyCode::Char('u') => {
tui.scroll_offset = tui
.scroll_offset
.saturating_sub((frame.size().height / 2).into());
}
KeyCode::Char('d') => {
tui.scroll_offset += (frame.size().height / 2).conv::<usize>();
}
KeyCode::Char('e') => {
tui.scroll_offset += 1;
}
KeyCode::Char('y') => {
tui.scroll_offset = tui.scroll_offset.saturating_sub(1);
}
KeyCode::Char('c') => {
tui.quit = true;
}
_ => {}
},

_ => {}
},
_ => {}
}

tui.scroll_offset = min(last_line, tui.scroll_offset);

Ok(())
}

0 comments on commit 1678c29

Please sign in to comment.