sylveos

Toy Operating System
Log | Files | Refs

commit c7b64d2dcb5ffd1537a3f5cd9deb662128b7b7ec
parent 2042ced7180e13c605d2df007cd5bb108c2d9839
Author: Sylvia Ivory <git@sivory.net>
Date:   Sun, 15 Mar 2026 23:20:42 -0700

Add journal view

Diffstat:
Dtools/src/sylveos.rs | 275-------------------------------------------------------------------------------
Atools/src/sylveos/console.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/src/sylveos/journal.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/src/sylveos/mod.rs | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/src/sylveos/paragraph_view.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/src/sylveos/sylveos.rs | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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])) +}