commit c7b64d2dcb5ffd1537a3f5cd9deb662128b7b7ec
parent 2042ced7180e13c605d2df007cd5bb108c2d9839
Author: Sylvia Ivory <git@sivory.net>
Date: Sun, 15 Mar 2026 23:20:42 -0700
Add journal view
Diffstat:
6 files changed, 836 insertions(+), 275 deletions(-)
diff --git a/tools/src/sylveos.rs b/tools/src/sylveos.rs
@@ -1,275 +0,0 @@
-// 3 pane layout:
-// 1. kernel console
-// 2. kernel journal
-// 3. tracing
-use std::{sync::Arc, time::Duration};
-
-use anyhow::Result;
-use crossterm::event::{Event, EventStream, KeyCode};
-use ratatui::{
- DefaultTerminal, Frame,
- buffer::Buffer,
- layout::{Constraint, Layout, Rect},
- style::Stylize,
- symbols::scrollbar,
- text::Line,
- widgets::{
- Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget,
- },
-};
-use tokio::io::{self, AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
-use tokio_serial::SerialStream;
-use tokio_stream::StreamExt;
-
-pub async fn start(port: SerialStream) -> Result<()> {
- let terminal = ratatui::init();
- let app_result = App::new(port).run(terminal).await;
- ratatui::restore();
- app_result
-}
-
-#[derive(Debug)]
-struct App {
- should_quit: bool,
- console_widget: ConsoleWidget,
-}
-
-impl App {
- pub fn new(port: SerialStream) -> Self {
- let (read_port, write_port) = io::split(port);
-
- return Self {
- should_quit: false,
- console_widget: ConsoleWidget {
- state: Default::default(),
- read_port: Arc::new(tokio::sync::RwLock::new(read_port)),
- write_port: Arc::new(tokio::sync::RwLock::new(write_port)),
- },
- };
- }
- pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
- self.console_widget.run();
-
- let period = Duration::from_secs_f32(1.0 / 60.0);
- let mut interval = tokio::time::interval(period);
- let mut events = EventStream::new();
-
- while !self.should_quit {
- tokio::select! {
- _ = interval.tick() => { terminal.draw(|frame| self.render(frame))?; },
- Some(Ok(event)) = events.next() => self.handle_event(&event).await?,
- }
- }
- Ok(())
- }
-
- fn render(&self, frame: &mut Frame) {
- let layout = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]);
- let [title_area, body_area] = frame.area().layout(&layout);
- let title = Line::from("SylveOS").centered().bold();
- frame.render_widget(title, title_area);
- frame.render_widget(&self.console_widget, body_area);
- }
-
- async fn handle_event(&mut self, event: &Event) -> Result<()> {
- if let Some(key) = event.as_key_press_event() {
- match key.code {
- KeyCode::Esc => self.should_quit = true,
- KeyCode::Down => self.console_widget.scroll_down(),
- KeyCode::Up => self.console_widget.scroll_up(),
- KeyCode::Left => self.console_widget.move_cursor_left(),
- KeyCode::Right => self.console_widget.move_cursor_right(),
- KeyCode::Enter => self.console_widget.submit().await?,
- KeyCode::Char(to_insert) => self.console_widget.enter_char(to_insert),
- KeyCode::Backspace => self.console_widget.delete_char(),
- _ => {}
- }
- }
-
- Ok(())
- }
-}
-
-#[derive(Debug, Clone)]
-struct ConsoleWidget {
- state: Arc<std::sync::RwLock<ConsoleState>>,
- read_port: Arc<tokio::sync::RwLock<ReadHalf<SerialStream>>>,
- write_port: Arc<tokio::sync::RwLock<WriteHalf<SerialStream>>>,
-}
-
-#[derive(Debug, Default)]
-struct ConsoleState {
- buffer: Vec<u8>,
- user_input: String,
- character_index: usize,
- disconnected: bool,
- vertical_scroll_state: ScrollbarState,
- vertical_scroll: usize,
-}
-
-impl ConsoleWidget {
- fn run(&self) {
- let this = self.clone();
- tokio::spawn(this.fetch_logs());
- }
-
- // TODO; should be moved to App
- // then app just sends us the non-journal logs
- async fn fetch_logs(self) {
- loop {
- let mut port = self.read_port.write().await;
- let byte = match port.read_u8().await {
- Ok(b) => {
- if b == 0 {
- let mut state = self.state.write().unwrap();
- state.disconnected = true;
- return;
- } else {
- b
- }
- }
- Err(_) => {
- let mut state = self.state.write().unwrap();
- state.disconnected = true;
- return;
- }
- };
- let mut state = self.state.write().unwrap();
- state.buffer.push(byte);
- }
- }
-
- async fn submit(&self) -> Result<()> {
- let mut state = self.state.write().unwrap();
- let mut port = self.write_port.write().await;
- port.write_all(state.user_input.as_bytes()).await?;
- port.write_all(b"\n").await?;
- port.flush().await?;
-
- let user_input = state.user_input.clone();
- state.buffer.extend(user_input.as_bytes());
- state.buffer.push(b'\n');
- state.user_input.clear();
- state.character_index = 0;
-
- Ok(())
- }
-
- fn move_cursor_left(&self) {
- let new_index = {
- let state = self.state.read().unwrap();
- let cursor_moved_right = state.character_index.saturating_add(1);
- self.clamp_cursor(cursor_moved_right)
- };
-
- let mut state = self.state.write().unwrap();
- state.character_index = new_index;
- }
-
- fn move_cursor_right(&self) {
- let new_index = {
- let state = self.state.read().unwrap();
- let cursor_moved_right = state.character_index.saturating_add(1);
- self.clamp_cursor(cursor_moved_right)
- };
-
- let mut state = self.state.write().unwrap();
- state.character_index = new_index;
- }
-
- fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
- let state = self.state.read().unwrap();
- new_cursor_pos.clamp(0, state.user_input.chars().count())
- }
-
- fn enter_char(&self, new_char: char) {
- let index = self.byte_index();
- {
- let mut state = self.state.write().unwrap();
- state.user_input.insert(index, new_char);
- }
-
- self.move_cursor_right();
- }
-
- fn byte_index(&self) -> usize {
- let state = self.state.read().unwrap();
- state
- .user_input
- .char_indices()
- .map(|(i, _)| i)
- .nth(state.character_index)
- .unwrap_or(state.user_input.len())
- }
-
- fn delete_char(&self) {
- let is_not_cursor_leftmost = {
- let state = self.state.read().unwrap();
- state.character_index != 0
- };
-
- if is_not_cursor_leftmost {
- // Method "remove" is not used on the saved text for deleting the selected char.
- // Reason: Using remove on String works on bytes instead of the chars.
- // Using remove would require special care because of char boundaries.
-
- let (user_input, current_index) = {
- let state = self.state.read().unwrap();
- (state.user_input.clone(), state.character_index)
- };
-
- let from_left_to_current_index = current_index - 1;
-
- // Getting all characters before the selected character.
- let before_char_to_delete = user_input.chars().take(from_left_to_current_index);
- // Getting all characters after selected character.
- let after_char_to_delete = user_input.chars().skip(current_index);
-
- // Put all characters together except the selected one.
- // By leaving the selected one out, it is forgotten and therefore deleted.
-
- {
- let mut state = self.state.write().unwrap();
- state.user_input = before_char_to_delete.chain(after_char_to_delete).collect();
- }
-
- self.move_cursor_left();
- }
- }
-
- fn scroll_down(&self) {
- let mut state = self.state.write().unwrap();
-
- state.vertical_scroll = state.vertical_scroll.saturating_add(1);
- }
-
- fn scroll_up(&self) {
- let mut state = self.state.write().unwrap();
-
- state.vertical_scroll = state.vertical_scroll.saturating_sub(1);
- }
-}
-
-impl Widget for &ConsoleWidget {
- fn render(self, area: Rect, buf: &mut Buffer) {
- let mut state = self.state.write().unwrap();
-
- let mut block = Block::bordered().title("Console Log");
-
- if state.disconnected {
- block = block.title_bottom("Disconnected");
- }
-
- let text = String::from_utf8_lossy(&state.buffer).to_string();
-
- let paragraph = Paragraph::new(format!("{text}{}", &state.user_input))
- .block(block)
- .scroll((state.vertical_scroll as u16, 0));
-
- let scrollbar =
- Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(scrollbar::VERTICAL);
-
- Widget::render(paragraph, area, buf);
- StatefulWidget::render(scrollbar, area, buf, &mut state.vertical_scroll_state)
- }
-}
diff --git a/tools/src/sylveos/console.rs b/tools/src/sylveos/console.rs
@@ -0,0 +1,170 @@
+use std::sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+};
+
+use anyhow::Result;
+use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
+use tokio::io::{AsyncWriteExt, WriteHalf};
+use tokio_serial::SerialStream;
+
+use crate::sylveos::{ParagraphView, ParagraphViewState};
+
+#[derive(Debug, Clone)]
+pub struct ConsoleWidget {
+ state: Arc<std::sync::RwLock<ConsoleState>>,
+ paragraph_state: Arc<std::sync::RwLock<ParagraphViewState>>,
+ write_port: Arc<tokio::sync::RwLock<WriteHalf<SerialStream>>>,
+ disconnected: Arc<AtomicBool>,
+}
+
+impl ConsoleWidget {
+ pub fn new(write_port: WriteHalf<SerialStream>, disconnected: Arc<AtomicBool>) -> Self {
+ Self {
+ state: Arc::new(std::sync::RwLock::new(ConsoleState::default())),
+ paragraph_state: Arc::new(std::sync::RwLock::new(ParagraphViewState::default())),
+ write_port: Arc::new(tokio::sync::RwLock::new(write_port)),
+ disconnected,
+ }
+ }
+
+ pub fn state(&self) -> Arc<std::sync::RwLock<ConsoleState>> {
+ self.state.clone()
+ }
+}
+
+#[derive(Debug, Default)]
+pub struct ConsoleState {
+ buffer: Vec<u8>,
+ user_input: String,
+ character_index: usize,
+}
+
+impl ConsoleWidget {
+ pub async fn submit(&self) -> Result<()> {
+ let mut state = self.state.write().unwrap();
+ let mut port = self.write_port.write().await;
+ port.write_all(state.user_input.as_bytes()).await?;
+ port.write_all(b"\n").await?;
+ port.flush().await?;
+
+ let user_input = state.user_input.clone();
+ state.buffer.extend(user_input.as_bytes());
+ state.buffer.push(b'\n');
+ state.user_input.clear();
+ state.character_index = 0;
+
+ Ok(())
+ }
+
+ pub fn move_cursor_left(&self) {
+ let new_index = {
+ let state = self.state.read().unwrap();
+ let cursor_moved_left = state.character_index.saturating_sub(1);
+ self.clamp_cursor(cursor_moved_left)
+ };
+
+ let mut state = self.state.write().unwrap();
+ state.character_index = new_index;
+ }
+
+ pub fn move_cursor_right(&self) {
+ let new_index = {
+ let state = self.state.read().unwrap();
+ let cursor_moved_right = state.character_index.saturating_add(1);
+ self.clamp_cursor(cursor_moved_right)
+ };
+
+ let mut state = self.state.write().unwrap();
+ state.character_index = new_index;
+ }
+
+ fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
+ let state = self.state.read().unwrap();
+ new_cursor_pos.clamp(0, state.user_input.chars().count())
+ }
+
+ pub fn enter_char(&self, new_char: char) {
+ let index = self.byte_index();
+ {
+ let mut state = self.state.write().unwrap();
+ state.user_input.insert(index, new_char);
+ }
+
+ self.move_cursor_right();
+ }
+
+ fn byte_index(&self) -> usize {
+ let state = self.state.read().unwrap();
+ state
+ .user_input
+ .char_indices()
+ .map(|(i, _)| i)
+ .nth(state.character_index)
+ .unwrap_or(state.user_input.len())
+ }
+
+ pub fn delete_char(&self) {
+ let is_not_cursor_leftmost = {
+ let state = self.state.read().unwrap();
+ state.character_index != 0
+ };
+
+ if is_not_cursor_leftmost {
+ let (user_input, current_index) = {
+ let state = self.state.read().unwrap();
+ (state.user_input.clone(), state.character_index)
+ };
+
+ let from_left_to_current_index = current_index - 1;
+
+ let before_char_to_delete = user_input.chars().take(from_left_to_current_index);
+ let after_char_to_delete = user_input.chars().skip(current_index);
+
+ {
+ let mut state = self.state.write().unwrap();
+ state.user_input = before_char_to_delete.chain(after_char_to_delete).collect();
+ }
+
+ self.move_cursor_left();
+ }
+ }
+}
+
+impl ConsoleState {
+ pub fn append(&mut self, byte: u8) {
+ self.buffer.push(byte);
+ }
+}
+
+impl ParagraphView for ConsoleWidget {
+ fn get_view_state(&self) -> Arc<std::sync::RwLock<ParagraphViewState>> {
+ self.paragraph_state.clone()
+ }
+
+ fn get_title(&self) -> String {
+ String::from("Console")
+ }
+
+ fn get_subtitle(&self) -> Option<String> {
+ let disconnected = self.disconnected.load(Ordering::Relaxed);
+
+ if disconnected {
+ Some(String::from("Disconnected"))
+ } else {
+ None
+ }
+ }
+
+ fn get_paragraph_contexts(&self) -> String {
+ let state = self.state.read().unwrap();
+ let text = String::from_utf8_lossy(&state.buffer).to_string();
+ format!("{text}{}", &state.user_input)
+ }
+}
+
+impl Widget for &ConsoleWidget {
+ fn render(self, area: Rect, buf: &mut Buffer) {
+ self.render_paragraph(area, buf);
+ }
+}
diff --git a/tools/src/sylveos/journal.rs b/tools/src/sylveos/journal.rs
@@ -0,0 +1,114 @@
+use std::sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+};
+
+use chrono::{DateTime, Local};
+use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
+
+use crate::sylveos::{ParagraphView, ParagraphViewState};
+
+#[derive(Debug, Clone)]
+pub struct JournalWidget {
+ state: Arc<std::sync::RwLock<JournalState>>,
+ paragraph_state: Arc<std::sync::RwLock<ParagraphViewState>>,
+ disconnected: Arc<AtomicBool>,
+}
+
+#[derive(Debug, Default)]
+pub struct JournalState {
+ lines: Vec<JournalEntry>,
+}
+
+impl JournalWidget {
+ pub fn new(disconnected: Arc<AtomicBool>) -> Self {
+ Self {
+ state: Arc::new(std::sync::RwLock::new(JournalState::default())),
+ paragraph_state: Arc::new(std::sync::RwLock::new(ParagraphViewState::default())),
+ disconnected,
+ }
+ }
+
+ pub fn state(&self) -> Arc<std::sync::RwLock<JournalState>> {
+ self.state.clone()
+ }
+}
+
+impl JournalState {
+ pub fn append(&mut self, entry: JournalEntry) {
+ self.lines.push(entry);
+ }
+}
+
+impl ParagraphView for JournalWidget {
+ fn get_view_state(&self) -> Arc<std::sync::RwLock<ParagraphViewState>> {
+ self.paragraph_state.clone()
+ }
+
+ fn get_title(&self) -> String {
+ String::from("Journal")
+ }
+
+ fn get_subtitle(&self) -> Option<String> {
+ let disconnected = self.disconnected.load(Ordering::Relaxed);
+
+ if disconnected {
+ Some(String::from("Disconnected"))
+ } else {
+ None
+ }
+ }
+
+ fn get_paragraph_contexts(&self) -> String {
+ let state = self.state.read().unwrap();
+
+ let lines = state
+ .lines
+ .iter()
+ .map(|e| {
+ let timestamp = e.time.format("%H:%M:%S%.3f");
+
+ format!("[{}] {} {}", timestamp, e.level.repr(), e.msg)
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ lines
+ }
+}
+
+impl Widget for &JournalWidget {
+ fn render(self, area: Rect, buf: &mut Buffer) {
+ self.render_paragraph(area, buf);
+ }
+}
+
+#[derive(Debug)]
+pub struct JournalEntry {
+ pub time: DateTime<Local>,
+ pub level: LogLevel,
+ pub msg: String,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum LogLevel {
+ Critical,
+ Error,
+ Warning,
+ Info,
+ Trace,
+ Debug,
+}
+
+impl LogLevel {
+ fn repr(self) -> &'static str {
+ match self {
+ LogLevel::Critical => "critical",
+ LogLevel::Error => "error ",
+ LogLevel::Warning => "warning ",
+ LogLevel::Info => "info ",
+ LogLevel::Trace => "trace ",
+ LogLevel::Debug => "debug ",
+ }
+ }
+}
diff --git a/tools/src/sylveos/mod.rs b/tools/src/sylveos/mod.rs
@@ -0,0 +1,228 @@
+mod console;
+mod journal;
+mod paragraph_view;
+
+use std::{
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+ time::Duration,
+};
+
+pub use console::ConsoleWidget;
+use crossterm::event::{Event, EventStream, KeyCode};
+pub use journal::JournalWidget;
+pub use paragraph_view::*;
+
+use anyhow::Result;
+use ratatui::{
+ DefaultTerminal, Frame,
+ layout::{Constraint, Layout},
+ style::{Color, Modifier, Style, Stylize},
+ text::Line,
+ widgets::Tabs,
+};
+use tokio::io::{self, AsyncReadExt, ReadHalf};
+use tokio_serial::SerialStream;
+use tokio_stream::StreamExt;
+
+use crate::sylveos::{
+ console::ConsoleState,
+ journal::{JournalEntry, JournalState},
+};
+
+pub async fn start(port: SerialStream) -> Result<()> {
+ let terminal = ratatui::init();
+ let app_result = App::new(port).run(terminal).await;
+ ratatui::restore();
+ app_result
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum TabIndex {
+ Console = 0,
+ Journal = 1,
+}
+
+impl TabIndex {
+ fn next(self) -> Self {
+ match self {
+ TabIndex::Console => TabIndex::Journal,
+ TabIndex::Journal => TabIndex::Console,
+ }
+ }
+
+ fn previous(self) -> Self {
+ match self {
+ TabIndex::Console => TabIndex::Journal,
+ TabIndex::Journal => TabIndex::Console,
+ }
+ }
+}
+
+#[derive(Debug)]
+struct App {
+ should_quit: bool,
+ current_tab: TabIndex,
+ disconnected: Arc<AtomicBool>,
+ console_widget: ConsoleWidget,
+ journal_widget: JournalWidget,
+}
+
+impl App {
+ pub fn new(port: SerialStream) -> Self {
+ let (read_port, write_port) = io::split(port);
+
+ let disconnected = Arc::new(AtomicBool::new(false));
+
+ let app = Self {
+ should_quit: false,
+ current_tab: TabIndex::Console,
+ console_widget: ConsoleWidget::new(write_port, disconnected.clone()),
+ journal_widget: JournalWidget::new(disconnected.clone()),
+ disconnected: disconnected.clone(),
+ };
+
+ tokio::spawn(Self::route_messages(
+ read_port,
+ app.console_widget.state(),
+ app.journal_widget.state(),
+ disconnected.clone(),
+ ));
+
+ app
+ }
+
+ async fn route_messages(
+ mut read_port: ReadHalf<SerialStream>,
+ console_state: Arc<std::sync::RwLock<ConsoleState>>,
+ journal_state: Arc<std::sync::RwLock<JournalState>>,
+ disconnected: Arc<AtomicBool>,
+ ) {
+ loop {
+ match parse_message(&mut read_port).await {
+ Ok(message) => match message {
+ Message::Console(byte) => {
+ let mut state = console_state.write().unwrap();
+ state.append(byte);
+ }
+ Message::Journal(entry) => {
+ let mut state = journal_state.write().unwrap();
+ state.append(entry);
+ }
+ Message::Disconnect => {
+ disconnected.store(true, Ordering::SeqCst);
+ return;
+ }
+ },
+ Err(_) => {
+ disconnected.store(true, Ordering::SeqCst);
+ return;
+ }
+ }
+ }
+ }
+
+ pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
+ let period = Duration::from_secs_f32(1.0 / 60.0);
+ let mut interval = tokio::time::interval(period);
+ let mut events = EventStream::new();
+
+ while !self.should_quit {
+ tokio::select! {
+ _ = interval.tick() => { terminal.draw(|frame| self.render(frame))?; },
+ Some(Ok(event)) = events.next() => self.handle_event(&event).await?,
+ }
+ }
+ Ok(())
+ }
+
+ fn render(&self, frame: &mut Frame) {
+ let layout = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Fill(1),
+ ]);
+ let [title_area, tabs_area, body_area] = frame.area().layout(&layout);
+
+ let title = Line::from("SylveOS").centered().bold();
+ frame.render_widget(title, title_area);
+
+ let tabs = Tabs::new(vec!["Console", "Journal"])
+ .select(self.current_tab as usize)
+ .style(Style::default().fg(Color::White))
+ .highlight_style(
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )
+ .divider(" | ");
+ frame.render_widget(tabs, tabs_area);
+
+ // Render active tab content
+ match self.current_tab {
+ TabIndex::Console => frame.render_widget(&self.console_widget, body_area),
+ TabIndex::Journal => frame.render_widget(&self.journal_widget, body_area),
+ }
+ }
+
+ async fn handle_event(&mut self, event: &Event) -> Result<()> {
+ let disconnected = self.disconnected.load(Ordering::Relaxed);
+ if let Some(key) = event.as_key_press_event() {
+ match key.code {
+ KeyCode::Esc => self.should_quit = true,
+ KeyCode::Tab => self.current_tab = self.current_tab.next(),
+ KeyCode::BackTab => self.current_tab = self.current_tab.previous(),
+ KeyCode::Down => match self.current_tab {
+ TabIndex::Console => self.console_widget.scroll_down(),
+ TabIndex::Journal => self.journal_widget.scroll_down(),
+ },
+ KeyCode::Up => match self.current_tab {
+ TabIndex::Console => self.console_widget.scroll_up(),
+ TabIndex::Journal => self.journal_widget.scroll_up(),
+ },
+ KeyCode::PageDown => match self.current_tab {
+ TabIndex::Console => self.console_widget.scroll_to_bottom(),
+ TabIndex::Journal => self.journal_widget.scroll_to_bottom(),
+ },
+ KeyCode::Left if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.move_cursor_left()
+ }
+ KeyCode::Right if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.move_cursor_right()
+ }
+ KeyCode::Enter if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.submit().await?
+ }
+ KeyCode::Char(to_insert)
+ if self.current_tab == TabIndex::Console && !disconnected =>
+ {
+ self.console_widget.enter_char(to_insert)
+ }
+ KeyCode::Backspace if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.delete_char()
+ }
+ _ => {}
+ }
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Debug)]
+enum Message {
+ Console(u8),
+ Journal(JournalEntry),
+ Disconnect,
+}
+
+async fn parse_message(port: &mut ReadHalf<SerialStream>) -> Result<Message> {
+ let byte = port.read_u8().await?;
+ if byte == 0 {
+ return Ok(Message::Disconnect);
+ }
+
+ Ok(Message::Console(byte))
+}
diff --git a/tools/src/sylveos/paragraph_view.rs b/tools/src/sylveos/paragraph_view.rs
@@ -0,0 +1,91 @@
+use std::sync::{Arc, RwLock};
+
+use ratatui::{
+ buffer::Buffer,
+ layout::Rect,
+ symbols::scrollbar,
+ widgets::{
+ Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget,
+ },
+};
+
+#[derive(Debug, Clone)]
+pub struct ParagraphViewState {
+ vertical_scroll_state: ScrollbarState,
+ vertical_scroll: usize,
+ autoscroll: bool,
+}
+
+impl Default for ParagraphViewState {
+ fn default() -> Self {
+ Self {
+ vertical_scroll_state: ScrollbarState::default(),
+ vertical_scroll: 0,
+ autoscroll: true,
+ }
+ }
+}
+
+pub trait ParagraphView {
+ fn get_view_state(&self) -> Arc<RwLock<ParagraphViewState>>;
+ fn get_paragraph_contexts(&self) -> String;
+ fn get_title(&self) -> String;
+ fn get_subtitle(&self) -> Option<String> {
+ None
+ }
+
+ fn scroll_down(&self) {
+ let this = self.get_view_state();
+ let mut state = this.write().unwrap();
+ state.vertical_scroll = state.vertical_scroll.saturating_add(1);
+ state.autoscroll = false;
+ }
+
+ fn scroll_up(&self) {
+ let this = self.get_view_state();
+ let mut state = this.write().unwrap();
+ state.vertical_scroll = state.vertical_scroll.saturating_sub(1);
+ state.autoscroll = false;
+ }
+
+ fn scroll_to_bottom(&self) {
+ let this = self.get_view_state();
+ let mut state = this.write().unwrap();
+ state.autoscroll = true;
+ }
+
+ fn render_paragraph(&self, area: Rect, buf: &mut Buffer) {
+ let this = self.get_view_state();
+ let mut state = this.write().unwrap();
+
+ let mut block = Block::bordered().title(self.get_title());
+
+ if let Some(subtitle) = self.get_subtitle() {
+ block = block.title_bottom(subtitle);
+ }
+
+ let text = self.get_paragraph_contexts();
+
+ let inner_height = area.height.saturating_sub(2) as usize;
+ let content_lines = text.lines().count();
+ let max_scroll = content_lines.saturating_sub(inner_height);
+
+ if state.vertical_scroll >= max_scroll {
+ state.autoscroll = true;
+ }
+
+ if state.autoscroll {
+ state.vertical_scroll = max_scroll;
+ }
+
+ let paragraph = Paragraph::new(text)
+ .block(block)
+ .scroll((state.vertical_scroll as u16, 0));
+
+ let scrollbar =
+ Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(scrollbar::VERTICAL);
+
+ Widget::render(paragraph, area, buf);
+ StatefulWidget::render(scrollbar, area, buf, &mut state.vertical_scroll_state)
+ }
+}
diff --git a/tools/src/sylveos/sylveos.rs b/tools/src/sylveos/sylveos.rs
@@ -0,0 +1,233 @@
+use std::{
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+ time::Duration,
+};
+
+use anyhow::Result;
+use crossterm::event::{Event, EventStream, KeyCode};
+use ratatui::{
+ DefaultTerminal, Frame,
+ buffer::Buffer,
+ layout::{Constraint, Layout, Rect},
+ style::{Color, Modifier, Style, Stylize},
+ symbols::scrollbar,
+ text::Line,
+ widgets::{
+ Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Tabs,
+ Widget,
+ },
+};
+use tokio::io::{self, AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
+use tokio_serial::SerialStream;
+use tokio_stream::StreamExt;
+
+pub async fn start(port: SerialStream) -> Result<()> {
+ let terminal = ratatui::init();
+ let app_result = App::new(port).run(terminal).await;
+ ratatui::restore();
+ app_result
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum TabIndex {
+ Console = 0,
+ Journal = 1,
+}
+
+impl TabIndex {
+ fn next(self) -> Self {
+ match self {
+ TabIndex::Console => TabIndex::Journal,
+ TabIndex::Journal => TabIndex::Console,
+ }
+ }
+
+ fn previous(self) -> Self {
+ match self {
+ TabIndex::Console => TabIndex::Journal,
+ TabIndex::Journal => TabIndex::Console,
+ }
+ }
+}
+
+#[derive(Debug)]
+struct App {
+ should_quit: bool,
+ current_tab: TabIndex,
+ disconnected: Arc<AtomicBool>,
+ console_widget: ConsoleWidget,
+ journal_widget: JournalWidget,
+}
+
+impl App {
+ pub fn new(port: SerialStream) -> Self {
+ let (read_port, write_port) = io::split(port);
+
+ let console_state = Arc::new(std::sync::RwLock::new(ConsoleState::default()));
+ let journal_state = Arc::new(std::sync::RwLock::new(LogState::default()));
+ let disconnected = Arc::new(AtomicBool::new(false));
+
+ let app = Self {
+ should_quit: false,
+ current_tab: TabIndex::Console,
+ console_widget: ConsoleWidget {
+ state: console_state,
+ write_port: Arc::new(tokio::sync::RwLock::new(write_port)),
+ disconnected: disconnected.clone(),
+ },
+ journal_widget: JournalWidget {
+ state: journal_state,
+ disconnected: disconnected.clone(),
+ },
+ disconnected: disconnected.clone(),
+ };
+
+ // Start the message router
+ let console_state = app.console_widget.state.clone();
+ let journal_state = app.journal_widget.state.clone();
+
+ tokio::spawn(Self::route_messages(
+ read_port,
+ console_state,
+ journal_state,
+ disconnected.clone(),
+ ));
+
+ app
+ }
+
+ async fn route_messages(
+ mut read_port: ReadHalf<SerialStream>,
+ console_state: Arc<std::sync::RwLock<ConsoleState>>,
+ journal_state: Arc<std::sync::RwLock<LogState>>,
+ disconnected: Arc<AtomicBool>,
+ ) {
+ loop {
+ match parse_message(&mut read_port).await {
+ Ok(message) => match message {
+ Message::Console(data) => {
+ let mut state = console_state.write().unwrap();
+ state.buffer.extend(data);
+ }
+ Message::Journal(data) => {
+ let mut state = journal_state.write().unwrap();
+ state.buffer.extend(data);
+ }
+ Message::Disconnect => {
+ disconnected.store(true, Ordering::SeqCst);
+ return;
+ }
+ },
+ Err(_) => {
+ disconnected.store(true, Ordering::SeqCst);
+ return;
+ }
+ }
+ }
+ }
+
+ pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
+ let period = Duration::from_secs_f32(1.0 / 60.0);
+ let mut interval = tokio::time::interval(period);
+ let mut events = EventStream::new();
+
+ while !self.should_quit {
+ tokio::select! {
+ _ = interval.tick() => { terminal.draw(|frame| self.render(frame))?; },
+ Some(Ok(event)) = events.next() => self.handle_event(&event).await?,
+ }
+ }
+ Ok(())
+ }
+
+ fn render(&self, frame: &mut Frame) {
+ let layout = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Fill(1),
+ ]);
+ let [title_area, tabs_area, body_area] = frame.area().layout(&layout);
+
+ let title = Line::from("SylveOS").centered().bold();
+ frame.render_widget(title, title_area);
+
+ let tabs = Tabs::new(vec!["Console", "Journal"])
+ .select(self.current_tab as usize)
+ .style(Style::default().fg(Color::White))
+ .highlight_style(
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )
+ .divider(" | ");
+ frame.render_widget(tabs, tabs_area);
+
+ // Render active tab content
+ match self.current_tab {
+ TabIndex::Console => frame.render_widget(&self.console_widget, body_area),
+ TabIndex::Journal => frame.render_widget(&self.journal_widget, body_area),
+ }
+ }
+
+ async fn handle_event(&mut self, event: &Event) -> Result<()> {
+ let disconnected = self.disconnected.load(Ordering::Relaxed);
+ if let Some(key) = event.as_key_press_event() {
+ match key.code {
+ KeyCode::Esc => self.should_quit = true,
+ KeyCode::Tab => self.current_tab = self.current_tab.next(),
+ KeyCode::BackTab => self.current_tab = self.current_tab.previous(),
+ KeyCode::Down => match self.current_tab {
+ TabIndex::Console => self.console_widget.scroll_down(),
+ TabIndex::Journal => self.journal_widget.scroll_down(),
+ },
+ KeyCode::Up => match self.current_tab {
+ TabIndex::Console => self.console_widget.scroll_up(),
+ TabIndex::Journal => self.journal_widget.scroll_up(),
+ },
+ KeyCode::PageDown => match self.current_tab {
+ TabIndex::Console => self.console_widget.scroll_to_bottom(),
+ TabIndex::Journal => self.journal_widget.scroll_to_bottom(),
+ },
+ KeyCode::Left if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.move_cursor_left()
+ }
+ KeyCode::Right if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.move_cursor_right()
+ }
+ KeyCode::Enter if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.submit().await?
+ }
+ KeyCode::Char(to_insert)
+ if self.current_tab == TabIndex::Console && !disconnected =>
+ {
+ self.console_widget.enter_char(to_insert)
+ }
+ KeyCode::Backspace if self.current_tab == TabIndex::Console && !disconnected => {
+ self.console_widget.delete_char()
+ }
+ _ => {}
+ }
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Debug)]
+enum Message {
+ Console(Vec<u8>),
+ Journal(Vec<u8>),
+ Disconnect,
+}
+
+async fn parse_message(port: &mut ReadHalf<SerialStream>) -> Result<Message> {
+ let byte = port.read_u8().await?;
+ if byte == 0 {
+ return Ok(Message::Disconnect);
+ }
+
+ Ok(Message::Console(vec![byte]))
+}