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