use std::borrow::Cow;
use std::io::stdout;
use std::sync::Mutex;
use std::sync::mpsc::Sender;
use std::thread::ThreadId;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseButton, MouseEventKind,
};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Position, Rect},
widgets::Paragraph,
};
use crate::tui::theme::TuiTheme;
const POLL_INTERVAL: Duration = Duration::from_millis(50);
pub(crate) enum Tick<E> {
Idle,
Redraw,
Suspend(Sender<()>),
TakeOver,
Exit(E),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PaneId {
Left,
Right,
}
impl PaneId {
fn other(self) -> PaneId {
match self {
PaneId::Left => PaneId::Right,
PaneId::Right => PaneId::Left,
}
}
}
pub(crate) struct ShellConfig {
pub split: (u16, u16),
}
const SPLIT_MIN: u16 = 10;
const SPLIT_MAX: u16 = 90;
const SPLIT_STEP: u16 = 2;
pub(crate) enum KeyResult<E> {
Handled,
Exit(E),
}
pub(crate) trait ShellApp {
type Exit;
fn config(&self) -> ShellConfig;
fn theme(&self) -> &TuiTheme;
fn quit_exit(&mut self) -> Self::Exit;
fn handle_key(
&mut self,
focused: PaneId,
code: KeyCode,
modifiers: KeyModifiers,
) -> KeyResult<Self::Exit>;
fn handle_mouse(&mut self, pane: PaneId, kind: MouseEventKind, pos: Position, area: Rect);
fn render_pane(&mut self, frame: &mut Frame, pane: PaneId, area: Rect, focused: bool);
fn notice(&self) -> Option<&str> {
None
}
fn clear_notice(&mut self) {}
fn mode_hint(&self) -> Option<String> {
None
}
fn status_hints(&self, focused: PaneId) -> Vec<Cow<'static, str>>;
fn modal_active(&self) -> bool {
false
}
fn poll_background(&mut self) -> Tick<Self::Exit> {
Tick::Idle
}
fn wait_for_resume(&mut self) {}
fn take_over(&mut self, _terminal: &mut ratatui::DefaultTerminal) {}
fn render_overlay(&mut self, _frame: &mut Frame, _area: Rect) {}
}
static TUI_THREAD: Mutex<Option<ThreadId>> = Mutex::new(None);
fn set_tui_thread(id: Option<ThreadId>) -> Option<ThreadId> {
std::mem::replace(
&mut *TUI_THREAD.lock().unwrap_or_else(|e| e.into_inner()),
id,
)
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let tui_thread = *TUI_THREAD.lock().unwrap_or_else(|e| e.into_inner());
match tui_thread {
Some(id) if id == std::thread::current().id() => {
leave_terminal();
prev(info);
}
Some(_) => {}
None => prev(info),
}
}));
});
}
fn enter_terminal() -> Result<ratatui::DefaultTerminal> {
enable_raw_mode()?;
crossterm::execute!(stdout(), EnterAlternateScreen)?;
let _ = crossterm::execute!(stdout(), EnableMouseCapture);
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
terminal.clear()?;
Ok(terminal)
}
fn leave_terminal() {
let _ = crossterm::execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
pub(crate) struct Shell<A: ShellApp> {
pub app: A,
focus: PaneId,
split: Option<u16>,
areas: [Rect; 2],
}
impl<A: ShellApp> Shell<A> {
pub fn new(app: A) -> Self {
Shell {
app,
focus: PaneId::Left,
split: None,
areas: [Rect::default(); 2],
}
}
pub fn run(mut self) -> Result<(A, A::Exit)> {
install_panic_hook();
let outer = set_tui_thread(Some(std::thread::current().id()));
let result = enter_terminal().and_then(|mut terminal| self.event_loop(&mut terminal));
set_tui_thread(outer);
leave_terminal();
result.map(|exit| (self.app, exit))
}
pub fn run_nested(mut self, terminal: &mut ratatui::DefaultTerminal) -> Result<(A, A::Exit)> {
self.event_loop(terminal).map(|exit| (self.app, exit))
}
fn event_loop(&mut self, terminal: &mut ratatui::DefaultTerminal) -> Result<A::Exit> {
let mut dirty = true;
loop {
if dirty {
terminal.draw(|frame| self.render(frame))?;
dirty = false;
}
match self.app.poll_background() {
Tick::Idle => {}
Tick::Redraw => dirty = true,
Tick::Suspend(ack) => {
leave_terminal();
let _ = ack.send(());
self.app.wait_for_resume();
*terminal = enter_terminal()?;
dirty = true;
}
Tick::TakeOver => {
self.app.take_over(terminal);
dirty = true;
}
Tick::Exit(exit) => return Ok(exit),
}
if event::poll(POLL_INTERVAL)? {
if let Some(exit) = self.handle_event(event::read()?) {
return Ok(exit);
}
dirty = true;
}
}
}
pub fn render(&mut self, frame: &mut Frame) {
let left = match self.split {
Some(left) => left,
None => {
let left = self.app.config().split.0.clamp(SPLIT_MIN, SPLIT_MAX);
self.split = Some(left);
left
}
};
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(frame.area());
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(left),
Constraint::Percentage(100 - left),
])
.split(outer[0]);
self.areas = [panes[0], panes[1]];
self.app
.render_pane(frame, PaneId::Left, panes[0], self.focus == PaneId::Left);
self.app
.render_pane(frame, PaneId::Right, panes[1], self.focus == PaneId::Right);
self.render_status_bar(frame, outer[1]);
self.app.render_overlay(frame, frame.area());
}
fn render_status_bar(&mut self, frame: &mut Frame, area: Rect) {
let text = if let Some(notice) = self.app.notice() {
format!(" {}", notice)
} else if let Some(hint) = self.app.mode_hint() {
hint
} else {
format!(" {}", self.app.status_hints(self.focus).join(" | "))
};
frame.render_widget(
Paragraph::new(text).style(self.app.theme().status_bar),
area,
);
}
pub fn handle_event(&mut self, event: Event) -> Option<A::Exit> {
match event {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
return None;
}
self.app.clear_notice();
if self.app.modal_active() {
return match self.app.handle_key(self.focus, key.code, key.modifiers) {
KeyResult::Handled => None,
KeyResult::Exit(exit) => Some(exit),
};
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Some(self.app.quit_exit());
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Left | KeyCode::Right)
{
self.resize_split(if key.code == KeyCode::Left {
-(SPLIT_STEP as i32)
} else {
SPLIT_STEP as i32
});
return None;
}
match key.code {
KeyCode::Char('q') => Some(self.app.quit_exit()),
KeyCode::Tab | KeyCode::BackTab => {
self.focus = self.focus.other();
None
}
code => match self.app.handle_key(self.focus, code, key.modifiers) {
KeyResult::Handled => None,
KeyResult::Exit(exit) => Some(exit),
},
}
}
Event::Mouse(_) if self.app.modal_active() => None,
Event::Mouse(mouse) => {
let pos = Position {
x: mouse.column,
y: mouse.row,
};
let (pane, area) = if self.areas[0].contains(pos) {
(PaneId::Left, self.areas[0])
} else if self.areas[1].contains(pos) {
(PaneId::Right, self.areas[1])
} else {
return None;
};
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
self.focus = pane;
}
self.app.handle_mouse(pane, mouse.kind, pos, area);
None
}
_ => None,
}
}
fn resize_split(&mut self, delta: i32) {
let current = self
.split
.unwrap_or_else(|| self.app.config().split.0.clamp(SPLIT_MIN, SPLIT_MAX));
let next = (current as i32 + delta).clamp(SPLIT_MIN as i32, SPLIT_MAX as i32);
self.split = Some(next as u16);
}
#[cfg(test)]
pub fn split(&self) -> Option<u16> {
self.split
}
#[cfg(test)]
pub fn focus(&self) -> PaneId {
self.focus
}
#[cfg(test)]
pub fn areas(&self) -> [Rect; 2] {
self.areas
}
}
#[cfg(test)]
#[path = "shell_test.rs"]
mod tests;