Compare commits

...
Sign in to create a new pull request.

1 commit
main ... 03/tui

Author SHA1 Message Date
5c7230d2f4 initial commit of TUI sketching 2025-07-13 22:09:31 -06:00
9 changed files with 429 additions and 2 deletions

View file

@ -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 }

View file

@ -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}"

99
src/bin/entui/app.rs Normal file
View file

@ -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<Self, Error> {
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;
}
}

View file

@ -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<String>,
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<ListState>,
selected_issue: RefCell<Option<Entry>>,
}
impl IssuesList {
pub fn new() -> Result<Self, Error> {
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();
}
}

View file

@ -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<Entry> = 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::<List>()
.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);
}
}
}
}

View file

@ -0,0 +1 @@
pub mod entomologist;

126
src/bin/entui/event.rs Normal file
View file

@ -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>,
/// Event receiver channel.
receiver: mpsc::UnboundedReceiver<Event>,
}
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<Event> {
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<Event>,
}
impl EventTask {
/// Constructs a new instance of [`EventThread`].
fn new(sender: mpsc::UnboundedSender<Event>) -> 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);
}
}

16
src/bin/entui/main.rs Normal file
View file

@ -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
}

24
src/bin/entui/ui.rs Normal file
View file

@ -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);
}
}