chore: initialise rust project and TUI
This commit is contained in:
parent
828ddc596d
commit
f84d0137c5
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
1604
Cargo.lock
generated
Normal file
1604
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -4,3 +4,10 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
crossterm = { version = "0.27.0", features = [ "event-stream" ] }
|
||||
futures = "0.3.30"
|
||||
ratatui = "0.26.3"
|
||||
reqwest = { version = "0.12.4", features = [ "json" ] }
|
||||
serde = { version = "1.0.203", features = [ "derive" ] }
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tui-input = "0.8.0"
|
||||
|
|
28
src/api.rs
Normal file
28
src/api.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
use reqwest::Response;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct SearchResults {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub image: String,
|
||||
pub releaseDate: String,
|
||||
pub subOrDub: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct Search {
|
||||
pub currentPage: String,
|
||||
pub hasNextPage: bool,
|
||||
pub results: Vec<SearchResults>
|
||||
}
|
||||
|
||||
pub async fn query_anime() -> Result<Search, reqwest::Error> {
|
||||
let resp = reqwest::get("https://altoku-api.vercel.app/anime/gogoanime/mushoku?page=1")
|
||||
.await.expect("Failed to get from API")
|
||||
.json::<Search>()
|
||||
.await;
|
||||
return resp;
|
||||
}
|
41
src/app.rs
Normal file
41
src/app.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use std::error;
|
||||
|
||||
use tui_input::Input;
|
||||
|
||||
/// Application result type.
|
||||
pub type AppResult<T> = std::result::Result<T, Box<dyn error::Error>>;
|
||||
|
||||
/// Application state
|
||||
pub struct App {
|
||||
/// Current value of the input box
|
||||
pub input: Input,
|
||||
/// Search query
|
||||
pub query: String,
|
||||
/// Is the app running?
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> App {
|
||||
App {
|
||||
input: Input::default(),
|
||||
query: "".to_string(),
|
||||
running: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Constructs a new instance of [`App`].
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Handles the tick event of the terminal.
|
||||
pub fn tick(&self) {}
|
||||
|
||||
/// Set running to false to quit the application.
|
||||
pub fn quit(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
}
|
97
src/event.rs
Normal file
97
src/event.rs
Normal file
|
@ -0,0 +1,97 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{Event as CrosstermEvent, KeyEvent, MouseEvent};
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::app::AppResult;
|
||||
|
||||
/// Terminal events.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Event {
|
||||
/// Terminal tick.
|
||||
Tick,
|
||||
/// Key press.
|
||||
Key(KeyEvent),
|
||||
/// Mouse click/scroll.
|
||||
Mouse(MouseEvent),
|
||||
/// Terminal resize.
|
||||
Resize(u16, u16),
|
||||
}
|
||||
|
||||
/// Terminal event handler.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct EventHandler {
|
||||
/// Event sender channel.
|
||||
sender: mpsc::UnboundedSender<Event>,
|
||||
/// Event receiver channel.
|
||||
receiver: mpsc::UnboundedReceiver<Event>,
|
||||
/// Event handler thread.
|
||||
handler: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
/// Constructs a new instance of [`EventHandler`].
|
||||
pub fn new(tick_rate: u64) -> Self {
|
||||
let tick_rate = Duration::from_millis(tick_rate);
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
let _sender = sender.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
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! {
|
||||
_ = _sender.closed() => {
|
||||
break;
|
||||
}
|
||||
_ = tick_delay => {
|
||||
_sender.send(Event::Tick).unwrap();
|
||||
}
|
||||
Some(Ok(evt)) = crossterm_event => {
|
||||
match evt {
|
||||
CrosstermEvent::Key(key) => {
|
||||
if key.kind == crossterm::event::KeyEventKind::Press {
|
||||
_sender.send(Event::Key(key)).unwrap();
|
||||
}
|
||||
},
|
||||
CrosstermEvent::Mouse(mouse) => {
|
||||
_sender.send(Event::Mouse(mouse)).unwrap();
|
||||
},
|
||||
CrosstermEvent::Resize(x, y) => {
|
||||
_sender.send(Event::Resize(x, y)).unwrap();
|
||||
},
|
||||
CrosstermEvent::FocusLost => {
|
||||
},
|
||||
CrosstermEvent::FocusGained => {
|
||||
},
|
||||
CrosstermEvent::Paste(_) => {
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
Self {
|
||||
sender,
|
||||
receiver,
|
||||
handler,
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive the next event from the handler thread.
|
||||
///
|
||||
/// This function will always block the current thread if
|
||||
/// there is no data available and it's possible for more data to be sent.
|
||||
pub async fn next(&mut self) -> AppResult<Event> {
|
||||
self.receiver
|
||||
.recv()
|
||||
.await
|
||||
.ok_or(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"This is an IO error",
|
||||
)))
|
||||
}
|
||||
}
|
30
src/handler.rs
Normal file
30
src/handler.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use crate::app::{App, AppResult};
|
||||
use crate::api::query_anime;
|
||||
|
||||
use crossterm::event::*;
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
|
||||
/// Handles the key events and updates the state of [`App`].
|
||||
pub async fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> {
|
||||
match key_event.code {
|
||||
// Exit application on `ESC`
|
||||
KeyCode::Esc => {
|
||||
app.quit();
|
||||
}
|
||||
// Exit application on `Ctrl-C`
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => {
|
||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
query_anime().await.unwrap().currentPage;
|
||||
app.input.reset();
|
||||
}
|
||||
_ => {
|
||||
app.input.handle_event(&Event::Key(key_event));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
17
src/lib.rs
Normal file
17
src/lib.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
/// Application.
|
||||
pub mod app;
|
||||
|
||||
/// Terminal events handler.
|
||||
pub mod event;
|
||||
|
||||
/// Widget renderer.
|
||||
pub mod ui;
|
||||
|
||||
/// Terminal user interface.
|
||||
pub mod tui;
|
||||
|
||||
/// Event handler.
|
||||
pub mod handler;
|
||||
|
||||
/// Interactions with the altoku API (consumet).
|
||||
pub mod api;
|
39
src/main.rs
39
src/main.rs
|
@ -1,3 +1,38 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use std::io;
|
||||
|
||||
use ratatui::{
|
||||
prelude::*,
|
||||
backend::{Backend, CrosstermBackend},
|
||||
};
|
||||
|
||||
use altoku::app::{App, AppResult};
|
||||
use altoku::tui::Tui;
|
||||
use altoku::event::{Event, EventHandler};
|
||||
use altoku::handler::handle_key_events;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> AppResult<()> {
|
||||
let mut app = App::new();
|
||||
|
||||
let backend = CrosstermBackend::new(io::stderr());
|
||||
let terminal = Terminal::new(backend)?;
|
||||
let events = EventHandler::new(250);
|
||||
let mut tui = Tui::new(terminal, events);
|
||||
tui.init()?;
|
||||
|
||||
// Start the main loop.
|
||||
while app.running {
|
||||
// Render the user interface.
|
||||
tui.draw(&mut app)?;
|
||||
// Handle events.
|
||||
match tui.events.next().await? {
|
||||
Event::Tick => app.tick(),
|
||||
Event::Key(key_event) => handle_key_events(key_event, &mut app).await?,
|
||||
Event::Mouse(_) => {}
|
||||
Event::Resize(_, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
tui.exit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
73
src/tui.rs
Normal file
73
src/tui.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use crate::app::{App, AppResult};
|
||||
use crate::event::EventHandler;
|
||||
use crate::ui;
|
||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
||||
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::Terminal;
|
||||
use std::io;
|
||||
use std::panic;
|
||||
|
||||
/// Representation of a terminal user interface.
|
||||
///
|
||||
/// It is responsible for setting up the terminal,
|
||||
/// initializing the interface and handling the draw events.
|
||||
#[derive(Debug)]
|
||||
pub struct Tui<B: Backend> {
|
||||
/// Interface to the Terminal.
|
||||
terminal: Terminal<B>,
|
||||
/// Terminal event handler.
|
||||
pub events: EventHandler,
|
||||
}
|
||||
|
||||
impl<B: Backend> Tui<B> {
|
||||
/// Constructs a new instance of [`Tui`].
|
||||
pub fn new(terminal: Terminal<B>, events: EventHandler) -> Self {
|
||||
Self { terminal, events }
|
||||
}
|
||||
|
||||
/// Initializes the terminal interface.
|
||||
///
|
||||
/// It enables the raw mode and sets terminal properties.
|
||||
pub fn init(&mut self) -> AppResult<()> {
|
||||
terminal::enable_raw_mode()?;
|
||||
crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let panic_hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |panic| {
|
||||
Self::reset().expect("failed to reset the terminal");
|
||||
panic_hook(panic);
|
||||
}));
|
||||
|
||||
self.terminal.hide_cursor()?;
|
||||
self.terminal.clear()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Draw`] the terminal interface by [`rendering`] the widgets.
|
||||
///
|
||||
/// [`Draw`]: ratatui::Terminal::draw
|
||||
/// [`rendering`]: crate::ui::render
|
||||
pub fn draw(&mut self, app: &mut App) -> AppResult<()> {
|
||||
self.terminal.draw(|frame| ui::render(app, frame))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the terminal interface.
|
||||
///
|
||||
/// This function is also used for the panic hook to revert
|
||||
/// the terminal properties if unexpected errors occur.
|
||||
fn reset() -> AppResult<()> {
|
||||
terminal::disable_raw_mode()?;
|
||||
crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exits the terminal interface.
|
||||
///
|
||||
/// It disables the raw mode and reverts back the terminal properties.
|
||||
pub fn exit(&mut self) -> AppResult<()> {
|
||||
Self::reset()?;
|
||||
self.terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
25
src/ui.rs
Normal file
25
src/ui.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use ratatui::{
|
||||
layout::Alignment,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, BorderType, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
/// Renders the user interface widgets.
|
||||
pub fn render(app: &mut App, frame: &mut Frame) {
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"This is just a placeholder."
|
||||
))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title("Template")
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_type(BorderType::Rounded),
|
||||
)
|
||||
.centered(),
|
||||
frame.size(),
|
||||
)
|
||||
}
|
Reference in a new issue