initial commit of TUI sketching
This commit is contained in:
parent
61df7ede8f
commit
30fad9c81e
9 changed files with 429 additions and 2 deletions
14
Cargo.toml
14
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 }
|
||||
|
|
|
|||
|
|
@ -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
99
src/bin/entui/app.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/bin/entui/components/entomologist/mod.rs
Normal file
65
src/bin/entui/components/entomologist/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
81
src/bin/entui/components/entomologist/ui.rs
Normal file
81
src/bin/entui/components/entomologist/ui.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/bin/entui/components/mod.rs
Normal file
1
src/bin/entui/components/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod entomologist;
|
||||
126
src/bin/entui/event.rs
Normal file
126
src/bin/entui/event.rs
Normal 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
16
src/bin/entui/main.rs
Normal 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
24
src/bin/entui/ui.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue