mirror of
https://codeberg.org/unspeaker/tengri.git
synced 2026-02-21 10:39:03 +01:00
523 lines
18 KiB
Rust
523 lines
18 KiB
Rust
#![feature(type_changing_struct_update, trait_alias)]
|
|
|
|
use std::{time::Duration, thread::{spawn, JoinHandle}};
|
|
|
|
use unicode_width::*;
|
|
|
|
pub use ::{
|
|
dizzle,
|
|
tengri_input,
|
|
tengri_output,
|
|
ratatui,
|
|
crossterm,
|
|
palette,
|
|
better_panic,
|
|
};
|
|
|
|
pub(crate) use ::{
|
|
dizzle::*,
|
|
tengri_input::*,
|
|
tengri_output::*,
|
|
atomic_float::AtomicF64,
|
|
std::{io::{stdout, Stdout}, sync::{Arc, RwLock, atomic::{AtomicBool, Ordering::*}}},
|
|
better_panic::{Settings, Verbosity},
|
|
palette::{*, convert::*, okhsl::*},
|
|
ratatui::{
|
|
prelude::{Color, Style, Buffer, Position},
|
|
style::{Stylize, Modifier, Color::*},
|
|
backend::{Backend, CrosstermBackend, ClearType},
|
|
layout::{Size, Rect},
|
|
buffer::Cell
|
|
},
|
|
crossterm::{
|
|
ExecutableCommand,
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode, disable_raw_mode},
|
|
event::{poll, read, Event, KeyEvent, KeyCode, KeyModifiers, KeyEventKind, KeyEventState},
|
|
}
|
|
};
|
|
|
|
#[macro_export] macro_rules! tui_main {
|
|
($expr:expr) => {
|
|
fn main () -> Usually<()> {
|
|
let state = Arc::new(RwLock::new($expr));
|
|
tengri_tui::Tui::new().unwrap().run(&state)?;
|
|
Ok(())
|
|
}
|
|
};
|
|
}
|
|
|
|
#[macro_export] macro_rules! has_color {
|
|
(|$self:ident:$Struct:ident$(<$($L:lifetime),*$($T:ident$(:$U:path)?),*>)?|$cb:expr) => {
|
|
impl $(<$($L),*$($T $(: $U)?),*>)? HasColor for $Struct $(<$($L),*$($T),*>)? {
|
|
fn color (&$self) -> ItemColor { $cb }
|
|
}
|
|
}
|
|
}
|
|
|
|
macro_rules! border {
|
|
($($T:ident {
|
|
$nw:literal $n:literal $ne:literal $w:literal $e:literal $sw:literal $s:literal $se:literal
|
|
$($x:tt)*
|
|
}),+) => {$(
|
|
impl BorderStyle for $T {
|
|
const NW: &'static str = $nw;
|
|
const N: &'static str = $n;
|
|
const NE: &'static str = $ne;
|
|
const W: &'static str = $w;
|
|
const E: &'static str = $e;
|
|
const SW: &'static str = $sw;
|
|
const S: &'static str = $s;
|
|
const SE: &'static str = $se;
|
|
$($x)*
|
|
fn enabled (&self) -> bool { self.0 }
|
|
}
|
|
#[derive(Copy, Clone)] pub struct $T(pub bool, pub Style);
|
|
impl Layout<TuiOut> for $T {}
|
|
impl Draw<TuiOut> for $T {
|
|
fn draw (&self, to: &mut TuiOut) {
|
|
if self.enabled() { let _ = BorderStyle::draw(self, to); }
|
|
}
|
|
}
|
|
)+}
|
|
}
|
|
|
|
mod tui_structs; pub use self::tui_structs::*;
|
|
mod tui_traits; pub use self::tui_traits::*;
|
|
mod tui_impls; pub use self::tui_impls::*;
|
|
|
|
/// Run an app in the main loop.
|
|
pub fn tui_run <T>(state: &Arc<RwLock<T>>) -> Usually<Arc<RwLock<Tui>>> {
|
|
let backend = CrosstermBackend::new(stdout());
|
|
let Size { width, height } = backend.size()?;
|
|
let tui = Arc::new(RwLock::new(Tui {
|
|
exited: Arc::new(AtomicBool::new(false)),
|
|
buffer: Buffer::empty(Rect { x: 0, y: 0, width, height }),
|
|
area: [0, 0, width, height],
|
|
perf: Default::default(),
|
|
backend,
|
|
}));
|
|
let _input_thread = tui_input(tui, state, Duration::from_millis(100));
|
|
tui.write().unwrap().setup()?;
|
|
let render_thread = tui_output(tui, state, Duration::from_millis(10))?;
|
|
match render_thread.join() {
|
|
Ok(result) => {
|
|
tui.write().unwrap().teardown()?;
|
|
println!("\n\rRan successfully: {result:?}\n\r");
|
|
},
|
|
Err(error) => {
|
|
tui.write().unwrap().teardown()?;
|
|
panic!("\n\rDraw thread failed: error={error:?}.\n\r")
|
|
},
|
|
}
|
|
Ok(tui)
|
|
}
|
|
|
|
pub fn tui_setup (backend: &mut CrosstermBackend) -> 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)?;
|
|
backend.hide_cursor()?;
|
|
enable_raw_mode().map_err(Into::into)
|
|
}
|
|
|
|
pub fn tui_teardown (backend: &mut CrosstermBackend) -> Usually<()> {
|
|
stdout().execute(LeaveAlternateScreen)?;
|
|
backend.show_cursor()?;
|
|
disable_raw_mode().map_err(Into::into)
|
|
}
|
|
|
|
pub fn tui_resized (
|
|
backend: &mut CrosstermBackend, buffer: &mut Buffer, size: ratatui::prelude::Rect
|
|
) {
|
|
if buffer.area != size {
|
|
backend.clear_region(ClearType::All).unwrap();
|
|
buffer.resize(size);
|
|
buffer.reset();
|
|
}
|
|
}
|
|
|
|
pub fn tui_redrawn (
|
|
backend: &mut CrosstermBackend, buffer: &mut Buffer, new_buffer: &mut Buffer
|
|
) {
|
|
let updates = buffer.diff(&new_buffer);
|
|
backend.draw(updates.into_iter()).expect("failed to render");
|
|
backend.flush().expect("failed to flush output new_buffer");
|
|
std::mem::swap(&mut buffer, &mut new_buffer);
|
|
new_buffer.reset();
|
|
}
|
|
|
|
pub fn tui_update (buf: &mut Buffer, area: XY<u16>, 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 {
|
|
if let Some(cell) = buf.cell_mut(ratatui::prelude::Position { x, y }) {
|
|
callback(cell, col, row);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawn the output thread.
|
|
pub fn tui_output <T: Draw<TuiOut> + Send + Sync + 'static> (
|
|
engine: &Arc<RwLock<Tui>>, state: &Arc<RwLock<T>>, timer: Duration
|
|
) -> Result<JoinHandle<()>, std::io::Error> {
|
|
let exited = engine.read().unwrap().exited.clone();
|
|
let engine = engine.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 });
|
|
std::thread::Builder::new()
|
|
.name("tui output thread".into())
|
|
.spawn(move || loop {
|
|
if exited.fetch_and(true, Relaxed) {
|
|
break
|
|
}
|
|
let t0 = engine.read().unwrap().perf.get_t0();
|
|
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 = TuiOut { buffer, area: XYWH(0, 0, width, height) };
|
|
state.draw(&mut output);
|
|
buffer = engine.write().unwrap().flip(output.buffer, size);
|
|
}
|
|
let t1 = (*engine.read().unwrap()).perf.get_t1(t0).unwrap();
|
|
buffer.set_string(0, 0, &format!("{:>3}.{:>3}ms", t1.as_millis(), t1.as_micros() % 1000), Style::default());
|
|
std::thread::sleep(timer);
|
|
})
|
|
}
|
|
|
|
/// Spawn the input thread.
|
|
pub fn tui_input <T: Handle<TuiIn> + Send + Sync + 'static> (
|
|
engine: &Arc<RwLock<Tui>>, state: &Arc<RwLock<T>>, timer: Duration
|
|
) -> JoinHandle<()> {
|
|
let exited = engine.read().unwrap().exited.clone();
|
|
let state = state.clone();
|
|
spawn(move || loop {
|
|
if exited.fetch_and(true, Relaxed) {
|
|
break
|
|
}
|
|
if poll(timer).is_ok() {
|
|
let event = read().unwrap();
|
|
match event {
|
|
Event::Key(KeyEvent {
|
|
code: KeyCode::Char('c'),
|
|
modifiers: KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
state: KeyEventState::NONE
|
|
}) => {
|
|
exited.store(true, Relaxed);
|
|
},
|
|
_ => {
|
|
let exited = exited.clone();
|
|
let event = TuiEvent::from_crossterm(event);
|
|
if let Err(e) = state.write().unwrap().handle(&TuiIn { exited, event }) {
|
|
panic!("{e}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(feature = "dsl")] pub fn evaluate_output_expression_tui <'a, S> (
|
|
state: &S, output: &mut TuiOut, expr: impl Expression + 'a
|
|
) -> Usually<bool> where
|
|
S: View<TuiOut, ()>
|
|
+ for<'b>Namespace<'b, bool>
|
|
+ for<'b>Namespace<'b, u16>
|
|
+ for<'b>Namespace<'b, Color>
|
|
{
|
|
// See `tengri_output::evaluate_output_expression`
|
|
let head = expr.head()?;
|
|
let mut frags = head.src()?.unwrap_or_default().split("/");
|
|
let args = expr.tail();
|
|
let arg0 = args.head();
|
|
let tail0 = args.tail();
|
|
let arg1 = tail0.head();
|
|
let tail1 = tail0.tail();
|
|
let _arg2 = tail1.head();
|
|
match frags.next() {
|
|
|
|
Some("text") => if let Some(src) = args?.src()? { output.place(&src) },
|
|
|
|
Some("fg") => {
|
|
let arg0 = arg0?.expect("fg: expected arg 0 (color)");
|
|
output.place(&Tui::fg(
|
|
Namespace::<Color>::resolve(state, arg0)?.unwrap_or_else(||panic!("fg: {arg0:?}: not a color")),
|
|
Thunk::new(move|output: &mut TuiOut|state.view(output, &arg1).unwrap()),
|
|
))
|
|
},
|
|
|
|
Some("bg") => {
|
|
let arg0 = arg0?.expect("bg: expected arg 0 (color)");
|
|
output.place(&Tui::bg(
|
|
Namespace::<Color>::resolve(state, arg0)?.unwrap_or_else(||panic!("bg: {arg0:?}: not a color")),
|
|
Thunk::new(move|output: &mut TuiOut|state.view(output, &arg1).unwrap()),
|
|
))
|
|
},
|
|
|
|
_ => return Ok(false)
|
|
|
|
};
|
|
Ok(true)
|
|
}
|
|
|
|
pub fn named_key (token: &str) -> Option<KeyCode> {
|
|
use KeyCode::*;
|
|
Some(match token {
|
|
"up" => Up,
|
|
"down" => Down,
|
|
"left" => Left,
|
|
"right" => Right,
|
|
"esc" | "escape" => Esc,
|
|
"enter" | "return" => Enter,
|
|
"delete" | "del" => Delete,
|
|
"backspace" => Backspace,
|
|
"tab" => Tab,
|
|
"space" => Char(' '),
|
|
"comma" => Char(','),
|
|
"period" => Char('.'),
|
|
"plus" => Char('+'),
|
|
"minus" | "dash" => Char('-'),
|
|
"equal" | "equals" => Char('='),
|
|
"underscore" => Char('_'),
|
|
"backtick" => Char('`'),
|
|
"lt" => Char('<'),
|
|
"gt" => Char('>'),
|
|
"cbopen" | "openbrace" => Char('{'),
|
|
"cbclose" | "closebrace" => Char('}'),
|
|
"bropen" | "openbracket" => Char('['),
|
|
"brclose" | "closebracket" => Char(']'),
|
|
"pgup" | "pageup" => PageUp,
|
|
"pgdn" | "pagedown" => PageDown,
|
|
"f1" => F(1),
|
|
"f2" => F(2),
|
|
"f3" => F(3),
|
|
"f4" => F(4),
|
|
"f5" => F(5),
|
|
"f6" => F(6),
|
|
"f7" => F(7),
|
|
"f8" => F(8),
|
|
"f9" => F(9),
|
|
"f10" => F(10),
|
|
"f11" => F(11),
|
|
"f12" => F(12),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
pub fn button_2 <'a> (key: impl Content<TuiOut>, label: impl Content<TuiOut>, editing: bool) -> impl Content<TuiOut> {
|
|
Tui::bold(true, Bsp::e(
|
|
Tui::fg_bg(Tui::orange(), Tui::g(0), Bsp::e(Tui::fg(Tui::g(0), &"▐"), Bsp::e(key, Tui::fg(Tui::g(96), &"▐")))),
|
|
When::new(!editing, Tui::fg_bg(Tui::g(255), Tui::g(96), label))))
|
|
}
|
|
|
|
pub fn button_3 <'a> (
|
|
key: impl Content<TuiOut>, label: impl Content<TuiOut>, value: impl Content<TuiOut>, editing: bool,
|
|
) -> impl Content<TuiOut> {
|
|
Tui::bold(true, Bsp::e(
|
|
Tui::fg_bg(Tui::orange(), Tui::g(0),
|
|
Bsp::e(Tui::fg(Tui::g(0), &"▐"), Bsp::e(key, Tui::fg(if editing { Tui::g(128) } else { Tui::g(96) }, "▐")))),
|
|
Bsp::e(
|
|
When::new(!editing, Bsp::e(Tui::fg_bg(Tui::g(255), Tui::g(96), label), Tui::fg_bg(Tui::g(128), Tui::g(96), &"▐"),)),
|
|
Bsp::e(Tui::fg_bg(Tui::g(224), Tui::g(128), value), Tui::fg_bg(Tui::g(128), Reset, &"▌"), ))))
|
|
}
|
|
|
|
border! {
|
|
Square {
|
|
"┌" "─" "┐"
|
|
"│" "│"
|
|
"└" "─" "┘" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
SquareBold {
|
|
"┏" "━" "┓"
|
|
"┃" "┃"
|
|
"┗" "━" "┛" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
TabLike {
|
|
"╭" "─" "╮"
|
|
"│" "│"
|
|
"│" " " "│" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Lozenge {
|
|
"╭" "─" "╮"
|
|
"│" "│"
|
|
"╰" "─" "╯" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Brace {
|
|
"╭" "" "╮"
|
|
"│" "│"
|
|
"╰" "" "╯" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
LozengeDotted {
|
|
"╭" "┅" "╮"
|
|
"┇" "┇"
|
|
"╰" "┅" "╯" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Quarter {
|
|
"▎" "▔" "🮇"
|
|
"▎" "🮇"
|
|
"▎" "▁" "🮇" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
QuarterV {
|
|
"▎" "" "🮇"
|
|
"▎" "🮇"
|
|
"▎" "" "🮇" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Chamfer {
|
|
"🭂" "▔" "🭍"
|
|
"▎" "🮇"
|
|
"🭓" "▁" "🭞" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Corners {
|
|
"🬆" "" "🬊" // 🬴 🬸
|
|
"" ""
|
|
"🬱" "" "🬵" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
CornersTall {
|
|
"🭽" "" "🭾"
|
|
"" ""
|
|
"🭼" "" "🭿" fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Outer {
|
|
"🭽" "▔" "🭾"
|
|
"▏" "▕"
|
|
"🭼" "▁" "🭿"
|
|
const W0: &'static str = "[";
|
|
const E0: &'static str = "]";
|
|
const N0: &'static str = "⎴";
|
|
const S0: &'static str = "⎵";
|
|
fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Thick {
|
|
"▄" "▄" "▄"
|
|
"█" "█"
|
|
"▀" "▀" "▀"
|
|
fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Rugged {
|
|
"▄" "▂" "▄"
|
|
"▐" "▌"
|
|
"▀" "🮂" "▀"
|
|
fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Skinny {
|
|
"▗" "▄" "▖"
|
|
"▐" "▌"
|
|
"▝" "▀" "▘"
|
|
fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Brackets {
|
|
"⎡" "" "⎤"
|
|
"" ""
|
|
"⎣" "" "⎦"
|
|
const W0: &'static str = "[";
|
|
const E0: &'static str = "]";
|
|
const N0: &'static str = "⎴";
|
|
const S0: &'static str = "⎵";
|
|
fn style (&self) -> Option<Style> { Some(self.1) }
|
|
},
|
|
Reticle {
|
|
"⎡" "" "⎤"
|
|
"" ""
|
|
"⎣" "" "⎦"
|
|
const W0: &'static str = "╟";
|
|
const E0: &'static str = "╢";
|
|
const N0: &'static str = "┯";
|
|
const S0: &'static str = "┷";
|
|
fn style (&self) -> Option<Style> { Some(self.1) }
|
|
}
|
|
}
|
|
|
|
pub fn okhsl_to_rgb (color: Okhsl<f32>) -> Color {
|
|
let Srgb { red, green, blue, .. }: Srgb<f32> = Srgb::from_color_unclamped(color);
|
|
Color::Rgb((red * 255.0) as u8, (green * 255.0) as u8, (blue * 255.0) as u8,)
|
|
}
|
|
|
|
pub fn rgb_to_okhsl (color: Color) -> Okhsl<f32> {
|
|
if let Color::Rgb(r, g, b) = color {
|
|
Okhsl::from_color(Srgb::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0))
|
|
} else {
|
|
unreachable!("only Color::Rgb is supported")
|
|
}
|
|
}
|
|
|
|
/// Trim string with [unicode_width].
|
|
pub fn trim_string (max_width: usize, input: impl AsRef<str>) -> String {
|
|
let input = input.as_ref();
|
|
let mut output = Vec::with_capacity(input.len());
|
|
let mut width: usize = 1;
|
|
let mut chars = input.chars();
|
|
while let Some(c) = chars.next() {
|
|
if width > max_width {
|
|
break
|
|
}
|
|
output.push(c);
|
|
width += c.width().unwrap_or(0);
|
|
}
|
|
return output.into_iter().collect()
|
|
}
|
|
|
|
pub(crate) fn width_chars_max (max: u16, text: impl AsRef<str>) -> u16 {
|
|
let mut width: u16 = 0;
|
|
let mut chars = text.as_ref().chars();
|
|
while let Some(c) = chars.next() {
|
|
width += c.width().unwrap_or(0) as u16;
|
|
if width > max {
|
|
break
|
|
}
|
|
}
|
|
return width
|
|
}
|
|
|
|
#[cfg(test)] mod tui_test {
|
|
use crate::*;
|
|
#[test] fn test_tui_engine () -> Usually<()> {
|
|
//use std::sync::{Arc, RwLock};
|
|
struct TestComponent(String);
|
|
impl HasContent<TuiOut> for TestComponent {
|
|
fn content (&self) -> impl Content<TuiOut> {
|
|
Some(self.0.as_str())
|
|
}
|
|
}
|
|
impl Handle<TuiIn> for TestComponent {
|
|
fn handle (&mut self, _from: &TuiIn) -> Perhaps<bool> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
let engine = Tui::new()?;
|
|
engine.read().unwrap().exited.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
let state = TestComponent("hello world".into());
|
|
let _state = std::sync::Arc::new(std::sync::RwLock::new(state));
|
|
//engine.run(&state)?;
|
|
Ok(())
|
|
}
|
|
//#[test] fn test_parse_key () {
|
|
////use KeyModifiers as Mods;
|
|
//let _test = |x: &str, y|assert_eq!(KeyMatcher::new(x).build(), Some(Event::Key(y)));
|
|
////test(":x",
|
|
////KeyEvent::new(KeyCode::Char('x'), Mods::NONE));
|
|
////test(":ctrl-x",
|
|
////KeyEvent::new(KeyCode::Char('x'), Mods::CONTROL));
|
|
////test(":alt-x",
|
|
////KeyEvent::new(KeyCode::Char('x'), Mods::ALT));
|
|
////test(":shift-x",
|
|
////KeyEvent::new(KeyCode::Char('x'), Mods::SHIFT));
|
|
////test(":ctrl-alt-shift-x",
|
|
////KeyEvent::new(KeyCode::Char('x'), Mods::CONTROL | Mods::ALT | Mods::SHIFT ));
|
|
//}
|
|
}
|