From 5c7230d2f412131ecd17447c8d7b7d7533099ecc Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 13 Jul 2025 21:45:05 -0600 Subject: [PATCH] initial commit of TUI sketching --- Cargo.toml | 14 +++ install.sh | 5 +- src/bin/entui/app.rs | 99 +++++++++++++++ src/bin/entui/components/entomologist/mod.rs | 65 ++++++++++ src/bin/entui/components/entomologist/ui.rs | 81 ++++++++++++ src/bin/entui/components/mod.rs | 1 + src/bin/entui/event.rs | 126 +++++++++++++++++++ src/bin/entui/main.rs | 16 +++ src/bin/entui/ui.rs | 24 ++++ 9 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 src/bin/entui/app.rs create mode 100644 src/bin/entui/components/entomologist/mod.rs create mode 100644 src/bin/entui/components/entomologist/ui.rs create mode 100644 src/bin/entui/components/mod.rs create mode 100644 src/bin/entui/event.rs create mode 100644 src/bin/entui/main.rs create mode 100644 src/bin/entui/ui.rs diff --git a/Cargo.toml b/Cargo.toml index 864691a..d1fa065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [features] default = [] log = ["dep:log", "dep:simple_logger"] +# tui = ["dep:crossterm", "dep:futures", "dep:ratatui", "dep:tokio", "dep:color-eyre"] [dependencies] anyhow = "1.0.95" @@ -18,3 +19,16 @@ simple_logger = { version = "5.0.0", optional = true } tempfile = "3.20.0" thiserror = "2.0.11" toml = "0.8.19" + +crossterm = { version = "0.28.1", features = ["event-stream"] } +futures = { version = "0.3.31" } +ratatui = { version = "0.29.0" } +tokio = { version = "1.40.0", features = ["full"] } +color-eyre = { version = "0.6.3" } + + +#crossterm = { version = "0.28.1", features = ["event-stream"], optional = true } +#futures = { version = "0.3.31", optional = true } +#ratatui = { version = "0.29.0", optional = true } +#tokio = { version = "1.40.0", features = ["full"], optional = true } +#color-eyre = { version = "0.6.3", optional = true } diff --git a/install.sh b/install.sh index ba8faf0..4224e80 100755 --- a/install.sh +++ b/install.sh @@ -2,9 +2,10 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) BINFILE="${SCRIPT_DIR}/target/release/ent" +ENTUI_BINFILE="${SCRIPT_DIR}/target/release/entui" INSTALL_DIR="/usr/local/bin" cargo build --release -echo "copying ent to ${INSTALL_DIR}" -sudo cp $BINFILE $INSTALL_DIR +echo "copying ent + entui to ${INSTALL_DIR}" +sudo cp $BINFILE $ENTUI_BINFILE $INSTALL_DIR echo "ent installed to ${INSTALL_DIR}" diff --git a/src/bin/entui/app.rs b/src/bin/entui/app.rs new file mode 100644 index 0000000..3617e7a --- /dev/null +++ b/src/bin/entui/app.rs @@ -0,0 +1,99 @@ +use crate::event::{AppEvent, Event, EventHandler}; +use crate::components::entomologist::IssuesList; +use ratatui::{ + DefaultTerminal, + crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, +}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + EntError(#[from] crate::components::entomologist::Error) +} + +/// Application. +#[derive(Debug)] +pub struct App { + /// Is the application running? + pub running: bool, + /// Event handler. + pub events: EventHandler, + + pub issues_list: IssuesList, +} + +impl Default for App { + fn default() -> Self { + Self { + running: true, + events: EventHandler::new(), + // TODO: .unwrap() as laziness + issues_list: IssuesList::new().unwrap(), + } + } +} + +impl App { + /// Constructs a new instance of [`App`]. + pub fn new() -> Result { + Ok(Self { + running: true, + events: EventHandler::new(), + // TODO: .unwrap() as laziness + issues_list: IssuesList::new()?, + }) + } + + /// Run the application's main loop. + pub async fn run(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> { + while self.running { + terminal.draw(|frame| frame.render_widget(&self, frame.area()))?; + match self.events.next().await? { + Event::Tick => self.tick(), + Event::Crossterm(event) => match event { + crossterm::event::Event::Key(key_event) => self.handle_key_events(key_event)?, + _ => {} + }, + Event::App(app_event) => match app_event { + AppEvent::Quit => self.quit(), + }, + } + } + Ok(()) + } + + /// Handles the key events and updates the state of [`App`]. + pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { + match key_event.code { + KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit), + KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => { + self.events.send(AppEvent::Quit) + } + KeyCode::Down => { + self.issues_list.select_next(); + } + KeyCode::Up => { + self.issues_list.select_previous(); + } + KeyCode::Enter => { + // TODO: view issue here + } + // Other handlers you could add here. + _ => {} + } + Ok(()) + } + + /// Handles the tick event of the terminal. + /// + /// The tick event is where you can update the state of your application with any logic that + /// needs to be updated at a fixed frame rate. E.g. polling a server, updating an animation. + pub fn tick(&self) {} + + /// Set running to false to quit the application. + pub fn quit(&mut self) { + self.running = false; + } +} diff --git a/src/bin/entui/components/entomologist/mod.rs b/src/bin/entui/components/entomologist/mod.rs new file mode 100644 index 0000000..76601dd --- /dev/null +++ b/src/bin/entui/components/entomologist/mod.rs @@ -0,0 +1,65 @@ +pub mod ui; + +use core::cell::RefCell; +use ratatui::widgets::ListState; +use thiserror::Error; +use entomologist::{issue::{Issue, IssueHandle, State}, issues::Issues}; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + EntIssuesError(#[from] entomologist::issues::ReadIssuesError), + #[error(transparent)] + EntDbError(#[from] entomologist::database::Error), + +} + +#[derive(Debug, Clone)] +pub struct Entry { + title: String, + id: IssueHandle, + state: String, + assignee: Option, + description: String, +} + +impl Entry { + pub fn new_from_id_issue(id: &IssueHandle, issue: &Issue) -> Self { + Entry { + title: String::from(issue.title()), + id: id.clone(), + state: issue.state.to_string(), + assignee: issue.assignee.clone(), + description: issue.description.clone(), + } + } +} + + +#[derive(Debug)] +pub struct IssuesList { + issues: Issues, + // safety: this is only accessed from the UI thread + list_state: RefCell, + selected_issue: RefCell>, +} + +impl IssuesList { + pub fn new() -> Result { + let issues_db_source = entomologist::database::IssuesDatabaseSource::Branch("entomologist-data"); + let issues = entomologist::database::read_issues_database(&issues_db_source)?; + Ok(Self { + issues, + list_state: RefCell::new(ListState::default()), + selected_issue: RefCell::new(None), + }) + } + + pub fn select_previous(&self) { + self.list_state.borrow_mut().select_previous(); + } + + pub fn select_next(&self) { + self.list_state.borrow_mut().select_next(); + } +} \ No newline at end of file diff --git a/src/bin/entui/components/entomologist/ui.rs b/src/bin/entui/components/entomologist/ui.rs new file mode 100644 index 0000000..a58b3f0 --- /dev/null +++ b/src/bin/entui/components/entomologist/ui.rs @@ -0,0 +1,81 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + widgets::{Block, BorderType, List, ListDirection, ListItem, Paragraph, StatefulWidget, Widget}, +}; + +use entomologist::issue::Issue; + +use crate::components::entomologist::{Entry, IssuesList}; + +fn generate_list_item<'a>(id: &String, issue: &Issue) -> ListItem<'a> { + let title = issue.title(); + ListItem::new(format!("{title}")) +} + +// have to do this since neither Widget nor Issue were defined in this crate +impl Widget for &Entry { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized { + let block = Block::bordered().title("PREVIEW"); + let text = format!("TITLE: {}\nID: {}\nSTATE: {}", self.title, self.id, self.state); + let text = match &self.assignee { + Some(assignee) => format!("{text}\nASSIGNEE: {}", assignee), + None => format!("{text}\nASSIGNEE: NONE") + }; + let text = format!("{text}\n\nDESCRIPTION:\n{}", self.description); + let pg = Paragraph::new(text).block(block); + + pg.render(area, buf); + } +} + +impl Widget for &IssuesList { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized { + + let issues_list: Vec = self.issues.issues.iter().map(|(id, issue)| Entry::new_from_id_issue(id, issue)).collect(); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Fill(1), Constraint::Length(20)]) + .split(area); + + + // ISSUE LIST + let issue_list_area = layout[0]; + + let issues_list_widget = self.issues.issues.iter().map(|(id, issue)| generate_list_item(id, issue)).collect::() + .block(Block::bordered().title("ISSUES")) + .style(Style::new().white()) + .highlight_style(Style::new().bg(Color::White).fg(Color::Black)) + .direction(ListDirection::TopToBottom); + + // wooooooooof :( + let state = &mut *self.list_state.borrow_mut(); + StatefulWidget::render(issues_list_widget, issue_list_area, buf, state); + + match state.selected() { + Some(index) => self.selected_issue.replace(Some(issues_list[index].clone())), + None => self.selected_issue.replace(None), + }; + + // ISSUE PREVIEW + let preview_area = layout[1]; + match &(*self.selected_issue.borrow()) { + Some(entry) => { + // .unwrap() as this should never fail and i can't handle an error + // inside this trait rn (lazy) + entry.render(preview_area, buf); + } + None => { + let text = "NO ISSUE SELECTED"; + let preview_block = Block::bordered().title("PREVIEW"); + let pg = Paragraph::new(text).block(preview_block).alignment(Alignment::Center); + pg.render(preview_area, buf); + } + } + } +} \ No newline at end of file diff --git a/src/bin/entui/components/mod.rs b/src/bin/entui/components/mod.rs new file mode 100644 index 0000000..146de96 --- /dev/null +++ b/src/bin/entui/components/mod.rs @@ -0,0 +1 @@ +pub mod entomologist; \ No newline at end of file diff --git a/src/bin/entui/event.rs b/src/bin/entui/event.rs new file mode 100644 index 0000000..071200c --- /dev/null +++ b/src/bin/entui/event.rs @@ -0,0 +1,126 @@ +use color_eyre::eyre::OptionExt; +use futures::{FutureExt, StreamExt}; +use ratatui::crossterm::event::Event as CrosstermEvent; +use std::time::Duration; +use tokio::sync::mpsc; + +/// The frequency at which tick events are emitted. +const TICK_FPS: f64 = 30.0; + +/// Representation of all possible events. +#[derive(Clone, Debug)] +pub enum Event { + /// An event that is emitted on a regular schedule. + /// + /// Use this event to run any code which has to run outside of being a direct response to a user + /// event. e.g. polling exernal systems, updating animations, or rendering the UI based on a + /// fixed frame rate. + Tick, + /// Crossterm events. + /// + /// These events are emitted by the terminal. + Crossterm(CrosstermEvent), + /// Application events. + /// + /// Use this event to emit custom events that are specific to your application. + App(AppEvent), +} + +/// Application events. +/// +/// You can extend this enum with your own custom events. +#[derive(Clone, Debug)] +pub enum AppEvent { + /// Quit the application. + Quit, +} + +/// Terminal event handler. +#[derive(Debug)] +pub struct EventHandler { + /// Event sender channel. + sender: mpsc::UnboundedSender, + /// Event receiver channel. + receiver: mpsc::UnboundedReceiver, +} + +impl EventHandler { + /// Constructs a new instance of [`EventHandler`] and spawns a new thread to handle events. + pub fn new() -> Self { + let (sender, receiver) = mpsc::unbounded_channel(); + let actor = EventTask::new(sender.clone()); + tokio::spawn(async { actor.run().await }); + Self { sender, receiver } + } + + /// Receives an event from the sender. + /// + /// This function blocks until an event is received. + /// + /// # Errors + /// + /// This function returns an error if the sender channel is disconnected. This can happen if an + /// error occurs in the event thread. In practice, this should not happen unless there is a + /// problem with the underlying terminal. + pub async fn next(&mut self) -> color_eyre::Result { + self.receiver + .recv() + .await + .ok_or_eyre("Failed to receive event") + } + + /// Queue an app event to be sent to the event receiver. + /// + /// This is useful for sending events to the event handler which will be processed by the next + /// iteration of the application's event loop. + pub fn send(&mut self, app_event: AppEvent) { + // Ignore the result as the reciever cannot be dropped while this struct still has a + // reference to it + let _ = self.sender.send(Event::App(app_event)); + } +} + +/// A thread that handles reading crossterm events and emitting tick events on a regular schedule. +struct EventTask { + /// Event sender channel. + sender: mpsc::UnboundedSender, +} + +impl EventTask { + /// Constructs a new instance of [`EventThread`]. + fn new(sender: mpsc::UnboundedSender) -> Self { + Self { sender } + } + + /// Runs the event thread. + /// + /// This function emits tick events at a fixed rate and polls for crossterm events in between. + async fn run(self) -> color_eyre::Result<()> { + let tick_rate = Duration::from_secs_f64(1.0 / TICK_FPS); + let mut reader = crossterm::event::EventStream::new(); + let mut tick = tokio::time::interval(tick_rate); + loop { + let tick_delay = tick.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = self.sender.closed() => { + break; + } + _ = tick_delay => { + self.send(Event::Tick); + } + Some(Ok(evt)) = crossterm_event => { + self.send(Event::Crossterm(evt)); + } + }; + } + Ok(()) + } + + /// Sends an event to the receiver. + fn send(&self, event: Event) { + // Ignores the result because shutting down the app drops the receiver, which causes the send + // operation to fail. This is expected behavior and should not panic. + let _ = self.sender.send(event); + } +} diff --git a/src/bin/entui/main.rs b/src/bin/entui/main.rs new file mode 100644 index 0000000..0283e14 --- /dev/null +++ b/src/bin/entui/main.rs @@ -0,0 +1,16 @@ +use crate::app::App; + +pub mod app; +pub mod event; +pub mod ui; +pub mod components; + +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + let app = App::new()?; + let terminal = ratatui::init(); + let result = app.run(terminal).await; + ratatui::restore(); + result +} diff --git a/src/bin/entui/ui.rs b/src/bin/entui/ui.rs new file mode 100644 index 0000000..8d572ff --- /dev/null +++ b/src/bin/entui/ui.rs @@ -0,0 +1,24 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect, Layout, Direction, Constraint}, + style::{Color, Stylize}, + widgets::{Block, BorderType, Paragraph, Widget}, +}; + +use crate::app::App; + +impl Widget for &App { + fn render(self, area: Rect, buf: &mut Buffer) { + // LAYOUT + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![ + Constraint::Percentage(100), + // Constraint::Percentage(50), + ]) + .split(area); + + // BLOCK 0 - ISSUE LIST + self.issues_list.render(layout[0], buf); + } +}