use std::{
io::{Stdout, stdout},
ops::{Deref, DerefMut},
time::Duration,
};
use anyhow::Result;
use crossterm::{
cursor,
event::{
DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyEventKind,
},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use ratatui::{Frame, backend::CrosstermBackend as Backend, layout::Rect};
use tokio::time::{Interval, interval};
use crate::tui::{color_depth::ColorDepth, events::Event, theme::OverlayPalette};
pub struct UI {
pub terminal: ratatui::Terminal<Backend<Stdout>>,
pub tick_rate: f64,
pub frame_rate: f64,
pub mouse: bool,
color_depth: ColorDepth,
tick_interval: Interval,
render_interval: Interval,
crossterm_events: EventStream,
}
impl UI {
pub fn new() -> Result<Self> {
let tick_rate = 4.0;
let frame_rate = 60.0;
Ok(Self {
terminal: ratatui::Terminal::new(Backend::new(stdout()))?,
tick_rate,
frame_rate,
mouse: false,
color_depth: ColorDepth::detect(),
tick_interval: interval(Duration::from_secs_f64(1.0 / tick_rate)),
render_interval: interval(Duration::from_secs_f64(1.0 / frame_rate)),
crossterm_events: EventStream::new(),
})
}
pub fn tick_rate(mut self, tick_rate: f64) -> Self {
self.tick_rate = tick_rate;
self.tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
self
}
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
self.frame_rate = frame_rate;
self.render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
self
}
pub fn size(&self) -> Result<Rect> {
let size = self.terminal.size()?;
Ok(Rect::new(0, 0, size.width, size.height))
}
pub fn draw(
&mut self,
palette: &OverlayPalette,
render: impl FnOnce(&mut Frame),
) -> std::io::Result<()> {
let color_depth = self.color_depth;
self.terminal.draw(|frame| {
render(frame);
color_depth.fit(frame.buffer_mut(), palette);
})?;
Ok(())
}
pub fn resize(&mut self, area: Rect) -> Result<()> {
self.terminal.resize(area)?;
Ok(())
}
pub async fn next_event(&mut self) -> Option<Event> {
loop {
let event = tokio::select! {
_ = self.tick_interval.tick() => Some(Event::Tick),
_ = self.render_interval.tick() => Some(Event::Render),
maybe_event = self.crossterm_events.next() => match maybe_event {
Some(Ok(event)) => map_crossterm_event(event),
Some(Err(_)) => None,
None => return None,
},
};
if let Some(event) = event {
return Some(event);
}
}
}
pub fn enter(&mut self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?;
if self.mouse {
crossterm::execute!(stdout(), EnableMouseCapture)?;
}
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?;
if self.mouse {
crossterm::execute!(stdout(), DisableMouseCapture)?;
}
crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
}
Ok(())
}
}
impl Deref for UI {
type Target = ratatui::Terminal<Backend<Stdout>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for UI {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl Drop for UI {
fn drop(&mut self) {
let _ = self.exit();
}
}
pub fn restore_terminal() {
if !crossterm::terminal::is_raw_mode_enabled().unwrap_or(true) {
return;
}
let _ = crossterm::execute!(
stdout(),
DisableMouseCapture,
LeaveAlternateScreen,
cursor::Show
);
let _ = crossterm::terminal::disable_raw_mode();
}
pub fn install_panic_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().name() == Some(crate::tui::app::DIFF_THREAD_NAME) {
return;
}
restore_terminal();
default_hook(info);
eprintln!(
"\nomnidiff {} crashed. Please report this, with the lines above and the two files \
being diffed if you can share them, at {}",
env!("CARGO_PKG_VERSION"),
crate::tui::ISSUE_TRACKER_URL
);
}));
}
fn map_crossterm_event(event: CrosstermEvent) -> Option<Event> {
match event {
CrosstermEvent::Key(key) if key.kind == KeyEventKind::Release => None,
CrosstermEvent::Key(key) => Some(Event::Key(key)),
CrosstermEvent::Mouse(mouse) => Some(Event::Mouse(mouse)),
CrosstermEvent::Resize(w, h) => Some(Event::Resize(w, h)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn maps_key_event() {
let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(
map_crossterm_event(CrosstermEvent::Key(key)),
Some(Event::Key(key))
);
}
#[test]
fn drops_key_release_events_but_keeps_presses_and_repeats() {
let press = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
let mut repeat = press;
repeat.kind = KeyEventKind::Repeat;
let mut release = press;
release.kind = KeyEventKind::Release;
assert_eq!(
map_crossterm_event(CrosstermEvent::Key(press)),
Some(Event::Key(press))
);
assert_eq!(
map_crossterm_event(CrosstermEvent::Key(repeat)),
Some(Event::Key(repeat))
);
assert_eq!(map_crossterm_event(CrosstermEvent::Key(release)), None);
}
#[test]
fn restore_terminal_is_a_no_op_outside_raw_mode() {
restore_terminal();
}
#[test]
fn maps_resize_event() {
assert_eq!(
map_crossterm_event(CrosstermEvent::Resize(80, 24)),
Some(Event::Resize(80, 24))
);
}
#[test]
fn drops_focus_and_paste_events() {
assert_eq!(map_crossterm_event(CrosstermEvent::FocusGained), None);
assert_eq!(map_crossterm_event(CrosstermEvent::FocusLost), None);
assert_eq!(
map_crossterm_event(CrosstermEvent::Paste("hi".to_string())),
None
);
}
#[test]
fn maps_mouse_event() {
let mouse = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Moved,
column: 1,
row: 2,
modifiers: KeyModifiers::NONE,
};
assert_eq!(
map_crossterm_event(CrosstermEvent::Mouse(mouse)),
Some(Event::Mouse(mouse))
);
}
}