Add textarea support
This commit is contained in:
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -1442,6 +1442,15 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
@@ -1734,6 +1743,7 @@ dependencies = [
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"time",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
@@ -2232,6 +2242,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tokio-utils",
|
||||
"tui-input",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -2348,7 +2359,9 @@ checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
@@ -2568,6 +2581,16 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tui-input"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19"
|
||||
dependencies = [
|
||||
"crossterm 0.29.0",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.19"
|
||||
|
||||
18
Cargo.toml
18
Cargo.toml
@@ -8,7 +8,6 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
color-eyre = "0.6.3"
|
||||
ratatui = "0.29.0"
|
||||
futures = "0.3.28"
|
||||
tokio-util = "0.7.9"
|
||||
tokio-utils = "0.1.2"
|
||||
@@ -18,6 +17,19 @@ rust-ini = "0.21.3"
|
||||
robotstxt = "0.3.0"
|
||||
scraper = "0.24.0"
|
||||
|
||||
[dependencies.tui-input]
|
||||
version = "0.14.0"
|
||||
features = ["crossterm"]
|
||||
default-features = false
|
||||
|
||||
[dependencies.crossterm]
|
||||
version = "0.29.0"
|
||||
features = ["event-stream"]
|
||||
|
||||
[dependencies.ratatui]
|
||||
version = "0.29.0"
|
||||
features = ["all-widgets"]
|
||||
|
||||
[dependencies.clap]
|
||||
version = "4.5.48"
|
||||
features = ["derive", "cargo"]
|
||||
@@ -26,10 +38,6 @@ features = ["derive", "cargo"]
|
||||
version = "0.12.23"
|
||||
features = ["blocking"]
|
||||
|
||||
[dependencies.crossterm]
|
||||
version = "0.29.0"
|
||||
features = ["event-stream"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.47.1"
|
||||
features = ["full"]
|
||||
|
||||
108
src/app.rs
108
src/app.rs
@@ -1,19 +1,22 @@
|
||||
use crate::event::{Event, EventHandler};
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use crate::event::{AppEvent, EventHandler};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::{DefaultTerminal};
|
||||
use ratatui::{DefaultTerminal, Frame};
|
||||
use std::time::Duration;
|
||||
use color_eyre::Result;
|
||||
use crossterm::event;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
|
||||
use crossterm::event::KeyCode::Char;
|
||||
use crossterm::event::Event as CrosstermEvent;
|
||||
use diesel::{Connection, SqliteConnection};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::prelude::{Widget};
|
||||
use ratatui::prelude::{StatefulWidget, Widget};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use crate::config::types::ApplicationConfig;
|
||||
use crate::constants::{APP_CONFIG_DIR, APP_CONIFG_FILE_PATH, APP_DATA_DIR};
|
||||
use crate::widgets::textarea::TextArea;
|
||||
|
||||
enum AppState {
|
||||
Running,
|
||||
@@ -32,15 +35,16 @@ pub(crate) struct App {
|
||||
events: EventHandler,
|
||||
db_connection: SqliteConnection,
|
||||
app_config: ApplicationConfig,
|
||||
current_event: Option<Event>
|
||||
stateful_widgets: HashMap<String, Box<dyn Any>>,
|
||||
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
pub async fn create() -> Self {
|
||||
let app_conf =
|
||||
if APP_CONIFG_FILE_PATH.exists() { ApplicationConfig::from_file(&APP_CONIFG_FILE_PATH).unwrap() }
|
||||
else { ApplicationConfig::new() };
|
||||
Self::initialize();
|
||||
Self::initialize_folders();
|
||||
let db_conn = Self::establish_db_connection(app_conf.clone());
|
||||
Self {
|
||||
state: AppState::Running,
|
||||
@@ -48,11 +52,11 @@ impl App {
|
||||
events: EventHandler::new(Duration::from_millis(app_conf.basic_config.tick_rate)),
|
||||
db_connection: db_conn,
|
||||
app_config: app_conf,
|
||||
current_event: None
|
||||
stateful_widgets: HashMap::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize() {
|
||||
fn initialize_folders() {
|
||||
if !APP_CONFIG_DIR.exists() {
|
||||
std::fs::create_dir_all(APP_CONFIG_DIR.as_path()).unwrap();
|
||||
}
|
||||
@@ -67,9 +71,9 @@ impl App {
|
||||
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
|
||||
}
|
||||
|
||||
pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
|
||||
pub async fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
let event = self.events.next().await?;
|
||||
self.update(event)?;
|
||||
if matches!(self.state, AppState::Exiting) {
|
||||
@@ -78,20 +82,27 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, event: Event) -> Result<()> {
|
||||
self.current_event = Some(event.clone());
|
||||
if let Event::Key(key) = event && matches!(self.state, AppState::Input) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.state = AppState::Running;
|
||||
},
|
||||
_ => {}
|
||||
fn update(&mut self, event: AppEvent) -> Result<()> {
|
||||
if matches!(self.state, AppState::Input) && let AppEvent::Raw(raw) = &event {
|
||||
self.stateful_widgets.iter_mut()
|
||||
.map(|(_, widget)| widget.downcast_mut::<TextArea>())
|
||||
.filter(|widget| widget.is_some())
|
||||
.map(|widget| widget.unwrap())
|
||||
.filter(|widget| widget.active)
|
||||
.for_each(|textarea| textarea.handle_input(raw));
|
||||
}
|
||||
if let AppEvent::Raw(cross_event) = event &&
|
||||
let CrosstermEvent::Key(key) = cross_event{
|
||||
self.handle_key_event(&key)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if let Event::Key(key) = event &&
|
||||
event::KeyEventKind::is_press(&key.kind) &&
|
||||
!matches!(self.state, AppState::Input) {
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> Result<()> {
|
||||
if matches!(self.state, AppState::Input) && matches!(key.code, KeyCode::Esc) {
|
||||
self.state = AppState::Running;
|
||||
}
|
||||
if matches!(key.kind, KeyEventKind::Press) && !matches!(self.state, AppState::Input) {
|
||||
match key.code {
|
||||
Char('q') => self.quit()?,
|
||||
Char('a') => self.folder_popup(),
|
||||
@@ -100,6 +111,10 @@ impl App {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
frame.render_widget(self, frame.area())
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &mut App {
|
||||
@@ -119,15 +134,8 @@ impl Widget for &mut App {
|
||||
self.render_game_list(chunks[1], buf);
|
||||
self.render_footer(chunks[2], buf);
|
||||
|
||||
let popup_area = Rect {
|
||||
x: area.width / 4,
|
||||
y: area.height / 3,
|
||||
width: area.width / 2,
|
||||
height: area.height / 3,
|
||||
};
|
||||
|
||||
match self.popup {
|
||||
AppPopUp::AddFolder => self.render_folder_popup(popup_area, buf),
|
||||
AppPopUp::AddFolder => self.render_folder_popup(area, buf),
|
||||
AppPopUp::None => {},
|
||||
};
|
||||
}
|
||||
@@ -140,8 +148,7 @@ impl App {
|
||||
.title(Line::raw("Games"))
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default());
|
||||
|
||||
game_list.render(area, buf);
|
||||
self.render_widget(game_list, area, buf);
|
||||
}
|
||||
|
||||
fn render_header(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -151,7 +158,7 @@ impl App {
|
||||
Style::default().fg(Color::Green),
|
||||
)
|
||||
);
|
||||
title.render(area, buf);
|
||||
self.render_widget(title, area, buf);
|
||||
}
|
||||
|
||||
fn render_footer(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -163,11 +170,39 @@ impl App {
|
||||
}
|
||||
let line = Line::from(navigation_text);
|
||||
let footer = Paragraph::new(line);
|
||||
footer.render(area, buf);
|
||||
self.render_widget(footer, area, buf);
|
||||
}
|
||||
|
||||
fn render_folder_popup(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
todo!()
|
||||
let popup_area = Rect {
|
||||
x: area.width / 4,
|
||||
y: area.height / 3,
|
||||
width: area.width / 2,
|
||||
height: area.height / 3,
|
||||
};
|
||||
let mut textarea = TextArea::new("New Folder", "test");
|
||||
textarea.active = true;
|
||||
self.render_stateful_widget("folder_textarea", textarea, popup_area, buf);
|
||||
}
|
||||
|
||||
fn render_widget<T>(&mut self, widget: T, area: Rect, buf: &mut Buffer)
|
||||
where T: Widget + Clone + 'static
|
||||
{
|
||||
widget.clone().render(area, buf);
|
||||
}
|
||||
|
||||
fn render_stateful_widget<T>(&mut self, id: &str, widget: T, area: Rect, buf: &mut Buffer)
|
||||
where T: StatefulWidget + Clone + 'static
|
||||
{
|
||||
if !self.stateful_widgets.contains_key(id) {
|
||||
self.stateful_widgets.insert(id.to_string(), Box::new(widget.clone()));
|
||||
}
|
||||
let cached_widget = self.stateful_widgets
|
||||
.get_mut(id)
|
||||
.unwrap()
|
||||
.downcast_mut::<T::State>()
|
||||
.unwrap();
|
||||
widget.render(area, buf, cached_widget);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +215,7 @@ impl App {
|
||||
self.app_config
|
||||
.clone()
|
||||
.write_to_file(&APP_CONIFG_FILE_PATH.to_path_buf())?;
|
||||
self.events.task.abort();
|
||||
}
|
||||
else {
|
||||
self.popup = AppPopUp::None;
|
||||
|
||||
39
src/cli.rs
39
src/cli.rs
@@ -1,6 +1,11 @@
|
||||
use std::io::stdout;
|
||||
use std::path::PathBuf;
|
||||
use clap::{command, Args, Command, Parser, Subcommand};
|
||||
use color_eyre::Result;
|
||||
use crossterm::cursor::{Hide, Show};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use ratatui::crossterm;
|
||||
use crate::app;
|
||||
use crate::config::types::ApplicationConfig;
|
||||
use crate::constants::APP_CONIFG_FILE_PATH;
|
||||
@@ -35,13 +40,15 @@ pub(crate) struct Cli {
|
||||
|
||||
impl Subcommand for Cli {
|
||||
fn augment_subcommands(cmd: Command) -> Command {
|
||||
cmd.subcommand(FolderCommand::augment_args(Command::new("folder")))
|
||||
.subcommand_required(true)
|
||||
cmd.subcommand(FolderCommand::augment_args(
|
||||
Command::new("folder"))
|
||||
).subcommand_required(true)
|
||||
}
|
||||
|
||||
fn augment_subcommands_for_update(cmd: Command) -> Command {
|
||||
cmd.subcommand(FolderCommand::augment_args(Command::new("folder")))
|
||||
.subcommand_required(true)
|
||||
cmd.subcommand(FolderCommand::augment_args
|
||||
(Command::new("folder"))
|
||||
).subcommand_required(true)
|
||||
}
|
||||
|
||||
fn has_subcommand(name: &str) -> bool {
|
||||
@@ -51,13 +58,15 @@ impl Subcommand for Cli {
|
||||
|
||||
impl Subcommand for FolderCommand {
|
||||
fn augment_subcommands(cmd: Command) -> Command {
|
||||
cmd.subcommand(FolderAddCommand::augment_args(Command::new("add")))
|
||||
.subcommand_required(true)
|
||||
cmd.subcommand(FolderAddCommand::augment_args(
|
||||
Command::new("add"))
|
||||
).subcommand_required(true)
|
||||
}
|
||||
|
||||
fn augment_subcommands_for_update(cmd: Command) -> Command {
|
||||
cmd.subcommand(FolderAddCommand::augment_args(Command::new("add")))
|
||||
.subcommand_required(true)
|
||||
cmd.subcommand(FolderAddCommand::augment_args(
|
||||
Command::new("add"))
|
||||
).subcommand_required(true)
|
||||
}
|
||||
|
||||
fn has_subcommand(name: &str) -> bool {
|
||||
@@ -77,10 +86,14 @@ impl Cli {
|
||||
}
|
||||
|
||||
async fn start_tui(&self) -> Result<()> {
|
||||
let terminal = ratatui::init();
|
||||
let app = app::App::new();
|
||||
let result = app.run(terminal).await;
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
let app = app::App::create().await;
|
||||
let result = app.run(&mut terminal).await;
|
||||
ratatui::restore();
|
||||
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -88,9 +101,7 @@ impl Cli {
|
||||
impl CliSubCommand {
|
||||
pub async fn handle(&self) -> Result<()> {
|
||||
match self {
|
||||
CliSubCommand::Folder(cmd) => {
|
||||
cmd.subcommand.handle().await
|
||||
}
|
||||
CliSubCommand::Folder(cmd) => cmd.subcommand.handle().await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
src/event.rs
36
src/event.rs
@@ -1,29 +1,28 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use crossterm;
|
||||
use crossterm::event;
|
||||
use futures::FutureExt;
|
||||
use futures::StreamExt;
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use std::time::Duration;
|
||||
use crossterm::event::EventStream;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Event {
|
||||
pub(crate) enum AppEvent {
|
||||
Error,
|
||||
Tick,
|
||||
Key(event::KeyEvent),
|
||||
Raw(crossterm::event::Event),
|
||||
}
|
||||
|
||||
pub(crate) struct EventHandler {
|
||||
_tx: UnboundedSender<Event>,
|
||||
rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
|
||||
task: Option<JoinHandle<()>>,
|
||||
_tx: UnboundedSender<AppEvent>,
|
||||
rx: tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
pub task: JoinHandle<()>
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new(tick_rate: Duration) -> Self {
|
||||
let mut interval = tokio::time::interval(tick_rate);
|
||||
let mut event_reader = event::EventStream::new();
|
||||
let mut event_reader = EventStream::new();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let _tx = tx.clone();
|
||||
@@ -35,28 +34,23 @@ impl EventHandler {
|
||||
tokio::select! {
|
||||
maybe_event = crossterm_event => {
|
||||
if let Some(Err(_)) = maybe_event {
|
||||
tx.send(Event::Error).unwrap()
|
||||
}
|
||||
else if let Some(Ok(event)) = maybe_event &&
|
||||
let event::Event::Key(key) = event
|
||||
{
|
||||
tx.send(Event::Key(key)).unwrap()
|
||||
tx.send(AppEvent::Error).unwrap()
|
||||
} else if let Some(Ok(event)) = maybe_event {
|
||||
tx.send(AppEvent::Raw(event)).unwrap();
|
||||
}
|
||||
}
|
||||
_ = delay => {
|
||||
tx.send(Event::Tick).unwrap()
|
||||
tx.send(AppEvent::Tick).unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
_tx,
|
||||
rx,
|
||||
task: Some(task),
|
||||
_tx, rx, task
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn next(&mut self) -> Result<Event> {
|
||||
pub(crate) async fn next(&mut self) -> Result<AppEvent> {
|
||||
self.rx.recv().await.ok_or(eyre!("Unable to get event"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ mod helpers;
|
||||
mod crawler;
|
||||
mod constants;
|
||||
mod cli;
|
||||
mod widgets;
|
||||
|
||||
use clap::{command, Command, Parser};
|
||||
use clap::{Parser};
|
||||
use color_eyre::Result;
|
||||
use tokio;
|
||||
use crate::cli::Cli;
|
||||
|
||||
1
src/widgets/mod.rs
Normal file
1
src/widgets/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod textarea;
|
||||
49
src/widgets/textarea.rs
Normal file
49
src/widgets/textarea.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use crossterm::event::{Event};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::StatefulWidget;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
use tui_input::Input;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TextArea {
|
||||
input: Input,
|
||||
title: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl StatefulWidget for TextArea {
|
||||
type State = TextArea;
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
|
||||
where
|
||||
Self: Sized
|
||||
{
|
||||
let input_value = state.input.value().to_string();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(self.title.clone());
|
||||
let paragraph = Paragraph::new(Text::from(input_value))
|
||||
.block(block);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub fn new(title: &str, placeholder_text: &str) -> Self {
|
||||
Self {
|
||||
input: Input::new(placeholder_text.to_string()),
|
||||
title: title.to_string(),
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input(&mut self, event: &Event) {
|
||||
let state_result = self.input.handle_event(event);
|
||||
if state_result.is_none() {
|
||||
return;
|
||||
}
|
||||
let state = state_result.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user