mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 03:36:41 +01:00
426 lines
14 KiB
Rust
426 lines
14 KiB
Rust
use crate::*;
|
|
use std::sync::{Arc, RwLock, atomic::{AtomicBool, Ordering::*}};
|
|
use std::io::{stdout, Stdout};
|
|
use std::time::Duration;
|
|
use std::thread::{spawn, JoinHandle};
|
|
|
|
pub use ::better_panic;
|
|
pub(crate) use better_panic::{Settings, Verbosity};
|
|
|
|
pub use ::crossterm;
|
|
pub(crate) use crossterm::{
|
|
ExecutableCommand,
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode, disable_raw_mode},
|
|
event::{KeyCode, KeyModifiers, KeyEvent, KeyEventKind, KeyEventState},
|
|
};
|
|
|
|
pub use ::ratatui;
|
|
pub(crate) use ratatui::{
|
|
prelude::{Color, Style, Buffer},
|
|
style::Modifier,
|
|
backend::{Backend, CrosstermBackend, ClearType},
|
|
layout::{Size, Rect},
|
|
buffer::Cell
|
|
};
|
|
|
|
impl Coordinate for u16 {}
|
|
|
|
pub struct Tui {
|
|
pub exited: Arc<AtomicBool>,
|
|
pub buffer: Buffer,
|
|
pub backend: CrosstermBackend<Stdout>,
|
|
pub area: [u16;4], // FIXME auto resize
|
|
}
|
|
|
|
impl Engine for Tui {
|
|
type Unit = u16;
|
|
type Size = [Self::Unit;2];
|
|
type Area = [Self::Unit;4];
|
|
type Input = TuiInput;
|
|
type Handled = bool;
|
|
type Output = TuiOutput;
|
|
fn exited (&self) -> bool {
|
|
self.exited.fetch_and(true, Relaxed)
|
|
}
|
|
fn setup (&mut self) -> Usually<()> {
|
|
let better_panic_handler = Settings::auto().verbosity(Verbosity::Full).create_panic_handler();
|
|
std::panic::set_hook(Box::new(move |info: &std::panic::PanicHookInfo|{
|
|
stdout().execute(LeaveAlternateScreen).unwrap();
|
|
CrosstermBackend::new(stdout()).show_cursor().unwrap();
|
|
disable_raw_mode().unwrap();
|
|
better_panic_handler(info);
|
|
}));
|
|
stdout().execute(EnterAlternateScreen)?;
|
|
self.backend.hide_cursor()?;
|
|
enable_raw_mode().map_err(Into::into)
|
|
}
|
|
fn teardown (&mut self) -> Usually<()> {
|
|
stdout().execute(LeaveAlternateScreen)?;
|
|
self.backend.show_cursor()?;
|
|
disable_raw_mode().map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl Tui {
|
|
/// Construct a new TUI engine and wrap it for shared ownership.
|
|
pub fn new () -> Usually<Arc<RwLock<Self>>> {
|
|
let backend = CrosstermBackend::new(stdout());
|
|
let Size { width, height } = backend.size()?;
|
|
Ok(Arc::new(RwLock::new(Self {
|
|
exited: Arc::new(AtomicBool::new(false)),
|
|
buffer: Buffer::empty(Rect { x: 0, y: 0, width, height }),
|
|
area: [0, 0, width, height],
|
|
backend,
|
|
})))
|
|
}
|
|
/// Update the display buffer.
|
|
fn flip (&mut self, mut buffer: Buffer, size: ratatui::prelude::Rect) -> Buffer {
|
|
if self.buffer.area != size {
|
|
self.backend.clear_region(ClearType::All).unwrap();
|
|
self.buffer.resize(size);
|
|
self.buffer.reset();
|
|
}
|
|
let updates = self.buffer.diff(&buffer);
|
|
self.backend.draw(updates.into_iter()).expect("failed to render");
|
|
self.backend.flush().expect("failed to flush output buffer");
|
|
std::mem::swap(&mut self.buffer, &mut buffer);
|
|
buffer.reset();
|
|
buffer
|
|
}
|
|
}
|
|
|
|
pub trait TuiRun<R: Content<Tui> + Handle<Tui> + Sized + 'static> {
|
|
/// Run an app in the main loop.
|
|
fn run (&self, state: &Arc<RwLock<R>>) -> Usually<()>;
|
|
/// Spawn the input thread.
|
|
fn run_input (&self, state: &Arc<RwLock<R>>, poll: Duration) -> JoinHandle<()>;
|
|
/// Spawn the output thread.
|
|
fn run_output (&self, state: &Arc<RwLock<R>>, sleep: Duration) -> JoinHandle<()>;
|
|
}
|
|
|
|
impl<T: Content<Tui> + Handle<Tui> + Sized + 'static> TuiRun<T> for Arc<RwLock<Tui>> {
|
|
fn run (&self, state: &Arc<RwLock<T>>) -> Usually<()> {
|
|
let _input_thread = self.run_input(state, Duration::from_millis(100));
|
|
self.write().unwrap().setup()?;
|
|
let render_thread = self.run_output(state, Duration::from_millis(10));
|
|
render_thread.join().expect("main thread failed");
|
|
self.write().unwrap().teardown()?;
|
|
Ok(())
|
|
}
|
|
fn run_input (&self, state: &Arc<RwLock<T>>, poll: Duration) -> JoinHandle<()> {
|
|
let exited = self.read().unwrap().exited.clone();
|
|
let state = state.clone();
|
|
spawn(move || loop {
|
|
if exited.fetch_and(true, Relaxed) {
|
|
break
|
|
}
|
|
if ::crossterm::event::poll(poll).is_ok() {
|
|
let event = ::crossterm::event::read().unwrap();
|
|
match event {
|
|
key_pat!(Ctrl-KeyCode::Char('c')) => {
|
|
exited.store(true, Relaxed);
|
|
},
|
|
_ => {
|
|
let exited = exited.clone();
|
|
if let Err(e) = state.write().unwrap().handle(&TuiInput { event, exited }) {
|
|
panic!("{e}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
fn run_output (&self, state: &Arc<RwLock<T>>, sleep: Duration) -> JoinHandle<()> {
|
|
let exited = self.read().unwrap().exited.clone();
|
|
let engine = self.clone();
|
|
let state = state.clone();
|
|
let Size { width, height } = engine.read().unwrap().backend.size().expect("get size failed");
|
|
let mut buffer = Buffer::empty(Rect { x: 0, y: 0, width, height });
|
|
spawn(move || loop {
|
|
if exited.fetch_and(true, Relaxed) {
|
|
break
|
|
}
|
|
let Size { width, height } = engine.read().unwrap().backend.size()
|
|
.expect("get size failed");
|
|
if let Ok(state) = state.try_read() {
|
|
let size = Rect { x: 0, y: 0, width, height };
|
|
if buffer.area != size {
|
|
engine.write().unwrap().backend.clear_region(ClearType::All)
|
|
.expect("clear failed");
|
|
buffer.resize(size);
|
|
buffer.reset();
|
|
}
|
|
let mut output = TuiOutput { buffer, area: [0, 0, width, height] };
|
|
state.render(&mut output);
|
|
buffer = engine.write().unwrap().flip(output.buffer, size);
|
|
}
|
|
std::thread::sleep(sleep);
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TuiInput {
|
|
pub(crate) exited: Arc<AtomicBool>,
|
|
pub(crate) event: crossterm::event::Event,
|
|
}
|
|
|
|
impl Input<Tui> for TuiInput {
|
|
type Event = crossterm::event::Event;
|
|
fn event (&self) -> &crossterm::event::Event {
|
|
&self.event
|
|
}
|
|
fn is_done (&self) -> bool {
|
|
self.exited.fetch_and(true, Relaxed)
|
|
}
|
|
fn done (&self) {
|
|
self.exited.store(true, Relaxed);
|
|
}
|
|
}
|
|
|
|
#[macro_export] macro_rules! key_pat {
|
|
(Ctrl-Alt-$code:pat) => { key_event_pat!($code, KeyModifiers::CONTROL | KeyModifiers::ALT) };
|
|
(Ctrl-$code:pat) => { key_event_pat!($code, KeyModifiers::CONTROL) };
|
|
(Alt-$code:pat) => { key_event_pat!($code, KeyModifiers::ALT) };
|
|
(Shift-$code:pat) => { key_event_pat!($code, KeyModifiers::SHIFT) };
|
|
($code:pat) => { crossterm::event::Event::Key(KeyEvent {
|
|
code: $code,
|
|
modifiers: KeyModifiers::NONE,
|
|
kind: KeyEventKind::Press,
|
|
state: KeyEventState::NONE
|
|
}) };
|
|
}
|
|
|
|
#[macro_export] macro_rules! key_event_pat {
|
|
($code:pat) => {
|
|
crossterm::event::Event::Key(KeyEvent {
|
|
code: $code,
|
|
modifiers: KeyModifiers::NONE,
|
|
kind: KeyEventKind::Press,
|
|
state: KeyEventState::NONE
|
|
})
|
|
};
|
|
($code:pat, $modifiers: pat) => {
|
|
crossterm::event::Event::Key(KeyEvent {
|
|
code: $code,
|
|
modifiers: $modifiers,
|
|
kind: KeyEventKind::Press,
|
|
state: KeyEventState::NONE
|
|
})
|
|
};
|
|
}
|
|
|
|
#[macro_export] macro_rules! kexp {
|
|
(Ctrl-Alt-$code:ident) => { key_event_expr!($code, KeyModifiers::from_bits(0b0000_0110).unwrap()) };
|
|
(Ctrl-$code:ident) => { key_event_expr!($code, KeyModifiers::CONTROL) };
|
|
(Alt-$code:ident) => { key_event_expr!($code, KeyModifiers::ALT) };
|
|
(Shift-$code:ident) => { key_event_expr!($code, KeyModifiers::SHIFT) };
|
|
($code:ident) => { key_event_expr!($code) };
|
|
($code:expr) => { key_event_expr!($code) };
|
|
}
|
|
|
|
#[macro_export] macro_rules! key_expr {
|
|
(Ctrl-Alt-$code:ident) => { key_event_expr!($code, KeyModifiers::CONTROL | KeyModifiers::ALT) };
|
|
(Ctrl-$code:ident) => { key_event_expr!($code, KeyModifiers::CONTROL) };
|
|
(Alt-$code:ident) => { key_event_expr!($code, KeyModifiers::ALT) };
|
|
(Shift-$code:ident) => { key_event_expr!($code, KeyModifiers::SHIFT) };
|
|
($code:ident) => { key_event_expr!($code) };
|
|
}
|
|
|
|
#[macro_export] macro_rules! key_event_expr {
|
|
($code:expr, $modifiers: expr) => {
|
|
crossterm::event::Event::Key(KeyEvent {
|
|
code: $code,
|
|
modifiers: $modifiers,
|
|
kind: KeyEventKind::Press,
|
|
state: KeyEventState::NONE
|
|
})
|
|
};
|
|
($code:expr) => {
|
|
crossterm::event::Event::Key(KeyEvent {
|
|
code: $code,
|
|
modifiers: KeyModifiers::NONE,
|
|
kind: KeyEventKind::Press,
|
|
state: KeyEventState::NONE
|
|
})
|
|
};
|
|
}
|
|
|
|
pub struct TuiOutput {
|
|
pub buffer: Buffer,
|
|
pub area: [u16;4]
|
|
}
|
|
|
|
impl Output<Tui> for TuiOutput {
|
|
#[inline] fn area (&self) -> [u16;4] { self.area }
|
|
#[inline] fn area_mut (&mut self) -> &mut [u16;4] { &mut self.area }
|
|
#[inline] fn place (&mut self, area: [u16;4], content: &impl Content<Tui>) {
|
|
let last = self.area().xywh().clone();
|
|
//panic!("a {last:?} {area:?} {:?}", self.area);
|
|
*self.area_mut() = area.xywh().clone();
|
|
//panic!("b {last:?} {area:?} {:?}", self.area);
|
|
content.render(self);
|
|
//panic!("c {last:?} {area:?} {:?}", self.area);
|
|
*self.area_mut() = last;
|
|
//panic!("placed");
|
|
}
|
|
}
|
|
|
|
impl TuiOutput {
|
|
pub fn buffer_update (&mut self, area: [u16;4], callback: &impl Fn(&mut Cell, u16, u16)) {
|
|
buffer_update(&mut self.buffer, area, callback);
|
|
}
|
|
pub fn fill_bold (&mut self, area: [u16;4], on: bool) {
|
|
if on {
|
|
self.buffer_update(area, &|cell,_,_|cell.modifier.insert(Modifier::BOLD))
|
|
} else {
|
|
self.buffer_update(area, &|cell,_,_|cell.modifier.remove(Modifier::BOLD))
|
|
}
|
|
}
|
|
pub fn fill_bg (&mut self, area: [u16;4], color: Color) {
|
|
self.buffer_update(area, &|cell,_,_|{cell.set_bg(color);})
|
|
}
|
|
pub fn fill_fg (&mut self, area: [u16;4], color: Color) {
|
|
self.buffer_update(area, &|cell,_,_|{cell.set_fg(color);})
|
|
}
|
|
pub fn fill_ul (&mut self, area: [u16;4], color: Color) {
|
|
self.buffer_update(area, &|cell,_,_|{
|
|
cell.modifier = ratatui::prelude::Modifier::UNDERLINED;
|
|
cell.underline_color = color;
|
|
})
|
|
}
|
|
pub fn fill_char (&mut self, area: [u16;4], c: char) {
|
|
self.buffer_update(area, &|cell,_,_|{cell.set_char(c);})
|
|
}
|
|
pub fn make_dim (&mut self) {
|
|
for cell in self.buffer.content.iter_mut() {
|
|
cell.bg = ratatui::style::Color::Rgb(30,30,30);
|
|
cell.fg = ratatui::style::Color::Rgb(100,100,100);
|
|
cell.modifier = ratatui::style::Modifier::DIM;
|
|
}
|
|
}
|
|
pub fn blit (
|
|
&mut self, text: &impl AsRef<str>, x: u16, y: u16, style: Option<Style>
|
|
) {
|
|
let text = text.as_ref();
|
|
let buf = &mut self.buffer;
|
|
if x < buf.area.width && y < buf.area.height {
|
|
buf.set_string(x, y, text, style.unwrap_or(Style::default()));
|
|
}
|
|
}
|
|
#[inline]
|
|
pub fn with_rect (&mut self, area: [u16;4]) -> &mut Self {
|
|
self.area = area;
|
|
self
|
|
}
|
|
}
|
|
|
|
//impl Area<u16> for Rect {
|
|
//fn x (&self) -> u16 { self.x }
|
|
//fn y (&self) -> u16 { self.y }
|
|
//fn w (&self) -> u16 { self.width }
|
|
//fn h (&self) -> u16 { self.height }
|
|
//}
|
|
|
|
pub fn half_block (lower: bool, upper: bool) -> Option<char> {
|
|
match (lower, upper) {
|
|
(true, true) => Some('█'),
|
|
(true, false) => Some('▄'),
|
|
(false, true) => Some('▀'),
|
|
_ => None
|
|
}
|
|
}
|
|
|
|
//impl<T: Content<Tui>> Render<Tui> for T {}
|
|
|
|
impl Content<Tui> for &str {
|
|
fn area (&self, to: [u16;4]) -> [u16;4] {
|
|
[to[0], to[1], self.chars().count() as u16, 1]
|
|
}
|
|
fn render (&self, to: &mut TuiOutput) {
|
|
to.blit(self, to.area.x(), to.area.y(), None)
|
|
}
|
|
}
|
|
|
|
impl Content<Tui> for String {
|
|
fn area (&self, to: [u16;4]) -> [u16;4] {
|
|
[to[0], to[1], self.chars().count() as u16, 1]
|
|
}
|
|
fn render (&self, to: &mut TuiOutput) {
|
|
to.blit(self, to.area.x(), to.area.y(), None)
|
|
}
|
|
}
|
|
|
|
pub fn buffer_update (buf: &mut Buffer, area: [u16;4], callback: &impl Fn(&mut Cell, u16, u16)) {
|
|
for row in 0..area.h() {
|
|
let y = area.y() + row;
|
|
for col in 0..area.w() {
|
|
let x = area.x() + col;
|
|
if x < buf.area.width && y < buf.area.height {
|
|
callback(buf.get_mut(x, y), col, row);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Define a key
|
|
pub const fn key (code: KeyCode) -> KeyEvent {
|
|
let modifiers = KeyModifiers::NONE;
|
|
let kind = KeyEventKind::Press;
|
|
let state = KeyEventState::NONE;
|
|
KeyEvent { code, modifiers, kind, state }
|
|
}
|
|
|
|
/// Add Ctrl modifier to key
|
|
pub const fn ctrl (key: KeyEvent) -> KeyEvent {
|
|
KeyEvent { modifiers: key.modifiers.union(KeyModifiers::CONTROL), ..key }
|
|
}
|
|
|
|
/// Add Alt modifier to key
|
|
pub const fn alt (key: KeyEvent) -> KeyEvent {
|
|
KeyEvent { modifiers: key.modifiers.union(KeyModifiers::ALT), ..key }
|
|
}
|
|
|
|
/// Add Shift modifier to key
|
|
pub const fn shift (key: KeyEvent) -> KeyEvent {
|
|
KeyEvent { modifiers: key.modifiers.union(KeyModifiers::SHIFT), ..key }
|
|
}
|
|
|
|
/*
|
|
/// Define a keymap
|
|
#[macro_export] macro_rules! keymap {
|
|
($T:ty { $([$k:ident $(($char:literal))?, $m:ident, $n: literal, $d: literal, $f: expr]),* $(,)? }) => {
|
|
&[
|
|
$((KeyCode::$k $(($char))?, KeyModifiers::$m, $n, $d, &$f as KeyHandler<$T>)),*
|
|
] as &'static [KeyBinding<$T>]
|
|
}
|
|
}
|
|
|
|
*/
|
|
|
|
/*
|
|
|
|
impl TuiInput {
|
|
// TODO remove
|
|
pub fn handle_keymap <T> (&self, state: &mut T, keymap: &KeyMap<T>) -> Usually<bool> {
|
|
match self.event() {
|
|
TuiEvent::Input(Key(event)) => {
|
|
for (code, modifiers, _, _, command) in keymap.iter() {
|
|
if *code == event.code && modifiers.bits() == event.modifiers.bits() {
|
|
return command(state)
|
|
}
|
|
}
|
|
},
|
|
_ => {}
|
|
};
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
pub type KeyHandler<T> = &'static dyn Fn(&mut T)->Usually<bool>;
|
|
|
|
pub type KeyBinding<T> = (KeyCode, KeyModifiers, &'static str, &'static str, KeyHandler<T>);
|
|
|
|
pub type KeyMap<T> = [KeyBinding<T>];
|
|
|
|
*/
|