feat: initial commit.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+2759
File diff suppressed because it is too large
Load Diff
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "rust-actix-ratatui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-web = "4.15.0"
|
||||||
|
anyhow = "1.0.104"
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
io::{self, Write},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tracing_subscriber::{
|
||||||
|
EnvFilter, fmt::MakeWriter, layer::SubscriberExt, util::SubscriberInitExt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_LOG_ENTRIES: usize = 500;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LogBuffer {
|
||||||
|
entries: Arc<Mutex<VecDeque<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogBuffer {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Vec<String> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.expect("log buffer mutex poisoned")
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, entry: String) {
|
||||||
|
let mut entries = self.entries.lock().expect("log buffer mutex poisoned");
|
||||||
|
if entries.len() == MAX_LOG_ENTRIES {
|
||||||
|
entries.pop_front();
|
||||||
|
}
|
||||||
|
entries.push_back(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init() -> LogBuffer {
|
||||||
|
let buffer = LogBuffer::new();
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(BufferMakeWriter(buffer.clone()));
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BufferMakeWriter(LogBuffer);
|
||||||
|
|
||||||
|
struct BufferWriter {
|
||||||
|
buffer: LogBuffer,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> MakeWriter<'a> for BufferMakeWriter {
|
||||||
|
type Writer = BufferWriter;
|
||||||
|
|
||||||
|
fn make_writer(&'a self) -> Self::Writer {
|
||||||
|
BufferWriter {
|
||||||
|
buffer: self.0.clone(),
|
||||||
|
bytes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for BufferWriter {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
|
self.bytes.extend_from_slice(bytes);
|
||||||
|
self.push_complete_lines();
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.push_complete_lines();
|
||||||
|
|
||||||
|
if !self.bytes.is_empty() {
|
||||||
|
let message = String::from_utf8_lossy(&self.bytes).trim().to_owned();
|
||||||
|
if !message.is_empty() {
|
||||||
|
self.buffer.push(message);
|
||||||
|
}
|
||||||
|
self.bytes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferWriter {
|
||||||
|
fn push_complete_lines(&mut self) {
|
||||||
|
while let Some(position) = self.bytes.iter().position(|byte| *byte == b'\n') {
|
||||||
|
let line = self.bytes.drain(..=position).collect::<Vec<_>>();
|
||||||
|
let line = String::from_utf8_lossy(&line).trim().to_owned();
|
||||||
|
if !line.is_empty() {
|
||||||
|
self.buffer.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
mod logging;
|
||||||
|
mod server;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let logs = logging::init();
|
||||||
|
tracing::info!("Application started");
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
res = server::start() => {
|
||||||
|
res.context("HTTP server failed")?;
|
||||||
|
tracing::info!("Server stopped");
|
||||||
|
},
|
||||||
|
res = ui::start(logs) => {
|
||||||
|
res.context("terminal UI failed")?;
|
||||||
|
tracing::info!("UI exited");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use actix_web::{App, HttpResponse, HttpServer, web};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub async fn start() -> Result<()> {
|
||||||
|
tracing::info!("Starting HTTP server on http://localhost:8080");
|
||||||
|
|
||||||
|
HttpServer::new(|| {
|
||||||
|
App::new()
|
||||||
|
.wrap(actix_web::middleware::Logger::default())
|
||||||
|
.route("/", web::get().to(index))
|
||||||
|
})
|
||||||
|
.bind(("localhost", 8080))?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> HttpResponse {
|
||||||
|
HttpResponse::Ok().body("rust-actix-ratatui is running")
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use ratatui::{
|
||||||
|
Terminal,
|
||||||
|
backend::Backend,
|
||||||
|
crossterm::{
|
||||||
|
self,
|
||||||
|
event::{KeyCode, KeyEvent, KeyModifiers},
|
||||||
|
},
|
||||||
|
layout::{Constraint, Direction, Layout},
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::logging::LogBuffer;
|
||||||
|
|
||||||
|
const BUTTONS: [&str; 2] = ["Dummy", "Quit"];
|
||||||
|
|
||||||
|
enum UiAction {
|
||||||
|
Continue,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(logs: LogBuffer) -> Result<()> {
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
let mut selected_index = 0;
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
draw(&mut terminal, &logs, selected_index, &mut list_state)?;
|
||||||
|
|
||||||
|
if crossterm::event::poll(Duration::from_millis(100))?
|
||||||
|
&& let crossterm::event::Event::Key(key) = crossterm::event::read()?
|
||||||
|
&& key.is_press()
|
||||||
|
{
|
||||||
|
match handle_key(key, &mut selected_index) {
|
||||||
|
UiAction::Continue => {}
|
||||||
|
UiAction::Quit => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ratatui::restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw<B: Backend>(
|
||||||
|
terminal: &mut Terminal<B>,
|
||||||
|
logs: &LogBuffer,
|
||||||
|
selected_index: usize,
|
||||||
|
list_state: &mut ListState,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
B::Error: Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
let columns = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
let log_entries = logs.snapshot();
|
||||||
|
let log_height = columns[1].height.saturating_sub(4) as usize;
|
||||||
|
let scroll = log_entries.len().saturating_sub(log_height) as u16;
|
||||||
|
|
||||||
|
list_state.select(Some(selected_index));
|
||||||
|
frame.render_stateful_widget(control_list(), columns[0], list_state);
|
||||||
|
frame.render_widget(log_view(&log_entries, scroll), columns[1]);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn control_list() -> List<'static> {
|
||||||
|
List::new(BUTTONS.map(ListItem::new))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.padding(Padding::uniform(1))
|
||||||
|
.title(" Control ")
|
||||||
|
.title_style(Style::default().fg(Color::Green)),
|
||||||
|
)
|
||||||
|
.highlight_style(Style::default().fg(Color::Black).bg(Color::Green))
|
||||||
|
.highlight_symbol(">> ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_view(entries: &[String], scroll: u16) -> Paragraph<'static> {
|
||||||
|
Paragraph::new(entries.join("\n"))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.padding(Padding::uniform(1))
|
||||||
|
.title(" Log ")
|
||||||
|
.title_style(Style::default().fg(Color::Green)),
|
||||||
|
)
|
||||||
|
.scroll((scroll, 0))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(key: KeyEvent, selected_index: &mut usize) -> UiAction {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
*selected_index = if *selected_index == 0 {
|
||||||
|
BUTTONS.len() - 1
|
||||||
|
} else {
|
||||||
|
*selected_index - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
*selected_index = (*selected_index + 1) % BUTTONS.len();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => match *selected_index {
|
||||||
|
0 => tracing::info!("Dummy button pressed"),
|
||||||
|
1 => return UiAction::Quit,
|
||||||
|
_ => unreachable!(),
|
||||||
|
},
|
||||||
|
KeyCode::Char('q') => return UiAction::Quit,
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
return UiAction::Quit;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
UiAction::Continue
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user