feat: initial commit.

This commit is contained in:
2026-09-14 01:12:47 +02:00
commit 32893424a0
7 changed files with 3052 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+2759
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+20
View File
@@ -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")
}
+126
View File
@@ -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
}