diff --git a/Cargo.toml b/Cargo.toml index 7d111d6..6b7697f 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,13 +29,20 @@ lazy_static = "1.5.0" color-eyre = { version = "0.6.5" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" +rmp-serde = "1.3.1" + log = "0.4.29" -ratatui = { version = "0.29.0", features = ["all-widgets"] } -dashmap = { version = "6.1.0", features = ["serde"] } +chrono = { version = "0.4.42", features = ["serde"] } [dependencies] color-eyre = "0.6.5" jemallocator = "0.5.4" tokio = { version = "1.48.0", features = ["macros"] } clap_builder = "4.5.53" +fern = { version = "0.7.1", features = ["chrono", "colored"] } + +chrono.workspace = true +log.workspace = true + ui = { path = "./ui" } +models = { path = "./models" } \ No newline at end of file diff --git a/crawler/Cargo.toml b/crawler/Cargo.toml index 6c85eaf..dffc9ba 100755 --- a/crawler/Cargo.toml +++ b/crawler/Cargo.toml @@ -12,10 +12,11 @@ robotstxt = "0.3.0" models = { path = "../models" } tokio.workspace = true -serde.workspace = true color-eyre.workspace = true lazy_static.workspace = true +serde.workspace = true serde_json.workspace = true +log.workspace = true futures = "0.3.31" itertools = "0.14.0" diff --git a/crawler/src/dlsite.rs b/crawler/src/dlsite.rs index 8c3c435..c1b4e2c 100755 --- a/crawler/src/dlsite.rs +++ b/crawler/src/dlsite.rs @@ -16,7 +16,6 @@ use models::dlsite::{matches_primary_language, PrimaryLanguage, JP_LOCALE}; use super::Crawler; use models::dlsite::crawler::*; -//TODO: override locale with user one const DLSITE_URL: &str = "https://www.dlsite.com/"; const DLSITE_PRODUCT_API_ENDPOINT: &str = "/maniax/product/info/ajax"; const DLSITE_FS_ENDPOINT: &str = "/maniax/fs/=/api_access/1/"; @@ -42,7 +41,14 @@ impl DLSiteCrawler { Ok(crawler) } - pub async fn get_game_infos(&self, rj_nums: Vec, locale: &LanguageTag) -> Result, Report>>>> + pub async fn get_game_infos(&self, rj_nums: Vec, locale: &LanguageTag) + -> Result< + FuturesUnordered< + impl Future< + Output = Result<(DLSiteManiax, bool)> + > + > + > { let invalid_nums = rj_nums.iter() .filter(|&n| !is_valid_rj_number(n)) @@ -65,8 +71,9 @@ impl DLSiteCrawler { // try to catch '[]' empty result from the api let value_downcast_result: Result, _> = serde_json::from_value(value); let maniax_result = value_downcast_result.unwrap_or(HashMap::new()); - - Self::verify_all_works_exists(&maniax_result, rj_nums); + if let Err(e) = Self::verify_all_works_exists(&maniax_result, rj_nums) { + println!("{}", e); + } let tasks = FuturesUnordered::new(); for (rj_num, mut info) in maniax_result { @@ -78,19 +85,18 @@ impl DLSiteCrawler { self.crawler.get_html(&html_path, Some(&query)) ); let (html, status) = html_result?; + info.rj_num = rj_num; if StatusCode::NOT_FOUND == status { - println!("{} is no longer available", rj_num); - return Ok(None); + return Ok((info, false)); } info.genre_ids = self.get_work_genres(&html, locale.try_into()?).await?; - info.rj_num = rj_num; - Ok::, Report>(Some(info)) + Ok::<(DLSiteManiax, bool), Report>((info, true)) }) } Ok(tasks) } - fn verify_all_works_exists(maniax_result: &HashMap, rj_nums: Vec) { + fn verify_all_works_exists(maniax_result: &HashMap, rj_nums: Vec) -> Result<()> { let keys = maniax_result.keys() .map(|k| k.to_string()) .collect::>(); @@ -100,8 +106,9 @@ impl DLSiteCrawler { .map(|n| n.to_string()) .collect::>(); if !nums_diff.is_empty() { - println!("Restricted/Removed Works: {}", nums_diff.join(", ").red()); + return Err(eyre!("Restricted/Removed Works: {}", nums_diff.join(", ").red())); } + Ok(()) } async fn save_main_image(&self, info: &DLSiteManiax, rj_num: &str) -> Result<()> { @@ -133,9 +140,6 @@ impl DLSiteCrawler { return Err(eyre!("Genre url is empty")); }; let genre_url = Url::parse(genre_href)?; - let Some(path_segments) = genre_url.path_segments() else { - return Err(eyre!("Genre url has no segment: {}", genre_href)); - }; let Some(genre_id) = genre_url.path_segments().unwrap() .into_iter() .skip(4) diff --git a/db/Cargo.toml b/db/Cargo.toml index a5574af..6e6cc4b 100755 --- a/db/Cargo.toml +++ b/db/Cargo.toml @@ -9,7 +9,9 @@ num_cpus = "1.17.0" lazy_static.workspace = true color-eyre.workspace = true serde.workspace = true +rmp-serde.workspace = true serde_json.workspace = true directories.workspace = true +log.workspace = true models = { path = "../models" } diff --git a/db/src/lib.rs b/db/src/lib.rs index c2383da..6b79d17 100755 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -107,7 +107,7 @@ impl RocksDB { if query_res.is_none() { return Ok(None); } - let mut value: TColumn = serde_json::from_slice(&query_res.unwrap())?; + let mut value: TColumn = rmp_serde::from_slice(&query_res.unwrap())?; value.set_id(id.clone()); Ok(Some(value)) } @@ -116,7 +116,7 @@ impl RocksDB { where TColumn: RocksColumn + Serialize { let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap(); - self.db.put_cf(&cf, serde_json::to_string(&value.get_id())?, serde_json::to_string(value)?)?; + self.db.put_cf(&cf, serde_json::to_string(&value.get_id())?, rmp_serde::to_vec(&value)?)?; Ok(()) } @@ -129,7 +129,7 @@ impl RocksDB { for id in ids { let query_res = transaction.get_cf(&cf, serde_json::to_string(id)?)?; if let Some(res) = query_res { - let mut value: TColumn = serde_json::from_slice(&res)?; + let mut value: TColumn = rmp_serde::from_slice(&res)?; value.set_id(id.clone()); values.push(value); } @@ -163,7 +163,7 @@ impl RocksDB { .filter_map(Result::ok) .map(|(k, v)| { let id = serde_json::from_slice::(&k).unwrap(); - let mut value = serde_json::from_slice::(&v).unwrap(); + let mut value = rmp_serde::from_slice::(&v).unwrap(); value.set_id(id); value }) @@ -177,7 +177,7 @@ impl RocksDB { let transaction = self.db.transaction(); let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap(); for value in values { - transaction.put_cf(&cf, serde_json::to_string(&value.get_id())?, serde_json::to_string(value)?)?; + transaction.put_cf(&cf, serde_json::to_string(&value.get_id())?, rmp_serde::to_vec(value)?)?; } transaction.commit()?; Ok(()) diff --git a/models/Cargo.toml b/models/Cargo.toml index 6c6d2e6..2fa69e5 100755 --- a/models/Cargo.toml +++ b/models/Cargo.toml @@ -7,9 +7,9 @@ edition = "2024" directories.workspace = true color-eyre.workspace = true serde.workspace = true -lazy_static.workspace = true -ratatui.workspace = true serde_json.workspace = true -dashmap.workspace = true +lazy_static.workspace = true + +dashmap = { version = "6.1.0", features = ["serde"] } language-tags = { version = "0.3.2", features = ["serde"] } sys-locale = "0.3.2" diff --git a/models/src/dlsite/maniax.rs b/models/src/dlsite/maniax.rs index e0d34dc..b0cd42e 100755 --- a/models/src/dlsite/maniax.rs +++ b/models/src/dlsite/maniax.rs @@ -1,5 +1,4 @@ use std::path::PathBuf; -use ratatui::text::Text; use serde::{Deserialize, Serialize}; use crate::db::{RocksColumn, RocksReferences}; use super::genre::DLSiteGenre; @@ -50,16 +49,4 @@ impl RocksReferences for DLSiteManiax { fn get_reference_ids(&self) -> Vec<::Id> { self.genre_ids.clone() } -} - -impl Into> for &DLSiteManiax { - fn into(self) -> Text<'static> { - Text::from(self.rj_num.to_string()) - } -} - -impl Into> for DLSiteManiax { - fn into(self) -> Text<'static> { - Text::from(self.rj_num.to_string()) - } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index a956bd1..750d26d 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,36 @@ use clap_builder::Parser; +use fern::colors::{Color, ColoredLevelConfig}; + #[tokio::main] async fn main() -> color_eyre::Result<()> { color_eyre::install()?; let cli = ui::Cli::parse(); + let colors = ColoredLevelConfig::new() + .error(Color::Red) + .warn(Color::Yellow) + .info(Color::BrightBlue) + .debug(Color::Blue) + .trace(Color::Cyan); + let mut dispatcher = fern::Dispatch::new() + .format(move |out, message, record| { + out.finish( + format_args!( + "[{} {} {}] {}", + chrono::Local::now().format("%H:%M:%S"), + colors.color(record.level()), + record.target(), + message + ) + ) + }) + .level(log::LevelFilter::Info) + .level_for("ui", log::LevelFilter::Debug) + .chain(fern::log_file(models::APP_CACHE_PATH.join("debug.log"))?); + if cli.is_gui_mode() { + dispatcher = dispatcher.chain(std::io::stdout()); + } + dispatcher.apply()?; + log::info!("Logger initialized"); cli.run().await?; Ok(()) } \ No newline at end of file diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 4946de1..cb2227b 100755 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -9,35 +9,21 @@ path = "src/lib.rs" [dependencies] futures = "0.3.28" -rat-cursor = "1.2.1" itertools = "0.14.0" color-eyre.workspace = true serde.workspace = true tokio.workspace = true -ratatui.workspace = true +log.workspace = true models = { path = "../models" } db = { path = "../db" } crawler = { path = "../crawler" } -[dependencies.ratatui-image] -version = "8.0.2" -features = ["tokio", "serde"] - [dependencies.indicatif] version = "0.18.1" features = ["futures", "tokio"] -[dependencies.tui-input] -version = "0.14.0" -features = ["crossterm"] -default-features = false - -[dependencies.crossterm] -version = "0.29.0" -features = ["event-stream"] - [dependencies.clap] version = "4.5.48" features = ["derive", "cargo"] diff --git a/ui/src/app.rs b/ui/src/app.rs deleted file mode 100755 index 87e0ef1..0000000 --- a/ui/src/app.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::event::{AppEvent, EventHandler}; -use crate::widgets::views::{AppView, MainView}; -use color_eyre::Result; -use crossterm::event::{Event}; -use ratatui::{DefaultTerminal, Frame}; -use std::time::Duration; -use color_eyre::eyre::eyre; -use db::{RocksDBFactory}; -use models::config::ApplicationConfig; - -pub(crate) struct App { - events: EventHandler, - state: AppState, - db_factory: RocksDBFactory -} - -#[derive(Clone)] -pub struct AppState { - view: Option, -} - -impl App { - pub async fn create() -> Result { - let config = ApplicationConfig::get_config()?; - let db_factory = RocksDBFactory::default(); - let state = AppState { - view: Some(AppView::Main(MainView::new(db_factory.clone())?)), - }; - let app = Self { - events: EventHandler::new(Duration::from_millis(config.basic_config.tick_rate)), - state, - db_factory - }; - Ok(app) - } - - pub async fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> { - loop { - let event = self.events.next().await?; - self.update(event)?; - let Some(current_view) = self.state.view.as_mut() else { - continue; - }; - let view = current_view.get_view(); - if !view.is_running() { break Ok(()) } - terminal.draw(|frame| self.draw(frame))?; - } - } - - fn update(&mut self, event: AppEvent) -> Result<()> { - if let AppEvent::Raw(cross_event) = event { - self.handle_event(&cross_event)?; - } - Ok(()) - } - - fn handle_event(&mut self, key: &Event) -> Result<()> { - let Some(current_view) = self.state.view.as_mut() else { - return Err(eyre!("there is no view")); - }; - let view = current_view.get_view(); - view.handle_input(key)?; - Ok(()) - } - - fn draw(&mut self, frame: &mut Frame) { - let Some(current_view) = self.state.view.as_mut() else { - return; - }; - let view = current_view.get_view(); - if let Some(pos) = view.screen_cursor() { - frame.set_cursor_position(pos); - } - match current_view { - AppView::Main(main_view) => { - frame.render_stateful_widget( - main_view.clone(), - frame.area(), - &mut main_view.state, - ); - } - } - } -} diff --git a/ui/src/cli/mod.rs b/ui/src/cli/mod.rs index a37ab98..5e26efd 100755 --- a/ui/src/cli/mod.rs +++ b/ui/src/cli/mod.rs @@ -1,10 +1,9 @@ mod folder; mod sync; -use crate::{app, helpers}; +use crate::{helpers}; use clap::{command, Parser}; use color_eyre::Result; -use ratatui::crossterm; use crate::cli::folder::FolderCommand; use crate::cli::sync::DLSiteCommand; @@ -33,23 +32,13 @@ impl Cli { } Ok(()) } + + pub fn is_gui_mode(&self) -> bool { + self.subcommand.is_none() + } async fn start_tui(&self) -> Result<()> { - crossterm::terminal::enable_raw_mode()?; - let mut terminal = ratatui::init(); - struct TuiCleanup; - impl Drop for TuiCleanup { - fn drop(&mut self) { - ratatui::restore(); - let _ = crossterm::terminal::disable_raw_mode(); - } - } - let _cleanup = TuiCleanup; - let app = app::App::create().await?; - let result = app.run(&mut terminal).await; - ratatui::restore(); - crossterm::terminal::disable_raw_mode()?; - result + todo!() } } diff --git a/ui/src/cli/sync.rs b/ui/src/cli/sync.rs index 8987135..3058b85 100755 --- a/ui/src/cli/sync.rs +++ b/ui/src/cli/sync.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use clap::{Parser}; use color_eyre::eyre::{Result}; -use crossterm::style::{style, Stylize}; +use color_eyre::owo_colors::OwoColorize; use futures::StreamExt; use indicatif::{ProgressBar, ProgressStyle}; use itertools::Itertools; @@ -54,22 +54,24 @@ impl DLSiteSyncCommand { Self::sync_genres(db_factory.clone(), &app_conf, &crawler).await?; println!( "{} {} Done in {:.2?}", - style("Genres").cyan(), - style("Syncing").green(), + "Genres".cyan(), + "Syncing".green(), genre_now.elapsed() ); + log::debug!("Finished genres syncing in {:.2?}", genre_now.elapsed()); } if self.do_sync_work { let work_now = Instant::now(); self.sync_works(&app_conf, db_factory.clone(), &crawler).await?; println!( "{} {} Done in {:.2?}", - style("Works").cyan(), - style("Syncing").green(), + "Works".cyan(), + "Syncing".green(), work_now.elapsed() ); + log::debug!("Finished works syncing in {:.2?}", work_now.elapsed()); } - println!("{} Done in {:.2?}", style("Syncing").green(), now.elapsed()); + println!("{} Done in {:.2?}", "Syncing".green(), now.elapsed()); Ok(()) } @@ -110,7 +112,6 @@ impl DLSiteSyncCommand { } } db.set_values(&modified_genres)?; - Ok(()) } @@ -123,14 +124,20 @@ impl DLSiteSyncCommand { let mut game_infos = crawler.get_game_infos(rj_nums, &app_conf.basic_config.locale).await?; let existing_game_infos = db.get_all_values::()?; - let mut modified_maniaxes: Vec = Vec::new(); + let mut modified_maniaxes = Vec::new(); let progress = ProgressBar::new(game_infos.len() as u64) - .with_style(ProgressStyle::default_bar()); + .with_style(ProgressStyle::with_template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")?) + .with_message("Retrieving game infos"); + let mut non_exists_nums = Vec::new(); while let Some(info) = game_infos.next().await { - let Some(maniax) = info? else { + let (maniax, exists) = info?; + progress.set_message(format!("Retrieved {}", maniax.rj_num)); + if !exists { + non_exists_nums.push(maniax.rj_num); + progress.inc(1); continue; - }; + } let existing_maniax = existing_game_infos.iter() .find(|v| v.rj_num == maniax.rj_num); if let Some(existing_maniax) = existing_maniax { @@ -138,6 +145,7 @@ impl DLSiteSyncCommand { let DLSiteTranslations(existing_translations) = existing_maniax.name.clone(); if existing_translations.contains(&name) { modified_maniaxes.push(existing_maniax.clone()); + progress.inc(1); continue; } let mut modified_maniax = existing_maniax.clone(); @@ -153,6 +161,10 @@ impl DLSiteSyncCommand { } progress.inc(1); } + progress.set_message("Finished"); + for rj_num in non_exists_nums.clone() { + println!("{} is no longer available", rj_num); + } db.set_values(&modified_maniaxes)?; Ok(()) } @@ -175,8 +187,8 @@ impl DLSiteSyncCommand { if !dir_path.is_dir() { println!( "{} {}", - style(dir_path.to_str().unwrap()).blue(), - style("is not a directory").red() + dir_path.to_str().unwrap().blue(), + "is not a directory".red() ); continue; } @@ -188,8 +200,8 @@ impl DLSiteSyncCommand { if !is_valid_rj_number(&dir_name) && !existing_folders.contains(&dir_path_str) { println!( "{} {}", - style(dir_path.to_str().unwrap()).blue(), - style("is not a valid rj number, please add it manually").red() + dir_path.to_str().unwrap().blue(), + "is not a valid rj number, please add it manually".red() ); continue; } diff --git a/ui/src/event.rs b/ui/src/event.rs deleted file mode 100755 index 8eaa989..0000000 --- a/ui/src/event.rs +++ /dev/null @@ -1,52 +0,0 @@ -use color_eyre::eyre::{eyre, Result}; -use crossterm::event::EventStream; -use futures::FutureExt; -use futures::StreamExt; -use std::time::Duration; -use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; - -#[derive(Clone)] -pub(crate) enum AppEvent { - Error(String), - Tick, - Raw(crossterm::event::Event), -} - -pub(crate) struct EventHandler { - _tx: UnboundedSender, - rx: UnboundedReceiver, -} - -impl EventHandler { - pub fn new(tick_rate: Duration) -> Self { - let mut interval = tokio::time::interval(tick_rate); - let mut event_reader = EventStream::new(); - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - - let _tx = tx.clone(); - - let task = tokio::spawn(async move { - loop { - let delay = interval.tick(); - let crossterm_event = event_reader.next().fuse(); - tokio::select! { - maybe_event = crossterm_event => { - if let Some(Err(e)) = maybe_event { - tx.send(AppEvent::Error(e.to_string())).unwrap() - } else if let Some(Ok(event)) = maybe_event { - tx.send(AppEvent::Raw(event)).unwrap(); - } - } - _ = delay => { - tx.send(AppEvent::Tick).unwrap() - } - } - } - }); - Self { _tx, rx } - } - - pub(crate) async fn next(&mut self) -> Result { - self.rx.recv().await.ok_or(eyre!("Unable to get event")) - } -} diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 4d45880..3c397cb 100755 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -1,7 +1,4 @@ -mod app; mod cli; -mod event; mod helpers; -mod widgets; pub use cli::Cli; \ No newline at end of file diff --git a/ui/src/widgets/components/game_info_box.rs b/ui/src/widgets/components/game_info_box.rs deleted file mode 100755 index e3ff953..0000000 --- a/ui/src/widgets/components/game_info_box.rs +++ /dev/null @@ -1,82 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::Line; -use ratatui::style::Style; -use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}; -use db::RocksDBFactory; -use models::config::ApplicationConfig; -use models::db::RocksReferences; -use models::dlsite::{DLSiteGenre, DLSiteManiax}; -use crate::widgets::components::Component; - -#[derive(Clone)] -pub struct GameInfoBox { - pub state: GameInfoBoxState -} - -#[derive(Clone)] -pub struct GameInfoBoxState { - game_title: String, - genres: Vec, - active: bool -} - -impl Component for GameInfoBoxState { - fn set_active(&mut self, active: bool) { - self.active = active; - } -} - -impl TryFrom for GameInfoBoxState { - type Error = color_eyre::Report; - - fn try_from(value: DLSiteManiax) -> Result { - let db = RocksDBFactory::default().get_current_context()?; - let locale = ApplicationConfig::get_config()?.basic_config.locale; - let game_title = value.name.get_translation(locale.clone())?; - let game_genres = - db.get_reference_values::(&value.get_reference_ids())? - .iter() - .map(|v| v.name.get_translation(locale.clone())) - .filter_map(Result::ok) - .collect::>(); - Ok(Self { game_title, genres: game_genres, active: false }) - } -} - -impl GameInfoBox { - pub fn new(maniax: DLSiteManiax) -> color_eyre::Result { - Ok( - Self { - state: GameInfoBoxState::try_from(maniax)? - } - ) - } - - pub fn set_info(&mut self, maniax: &DLSiteManiax) -> color_eyre::Result<()> { - self.state = GameInfoBoxState::try_from(maniax.clone())?; - Ok(()) - } -} - -impl StatefulWidget for GameInfoBox { - type State = GameInfoBoxState; - - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let block = Block::new() - .title(Line::raw("Info")) - .borders(Borders::ALL) - .style(Style::default()); - let title_text = Line::styled( - state.game_title.clone(), - Style::default().fg(ratatui::style::Color::Yellow) - ); - let text = Paragraph::new( - vec![ - title_text, - Line::raw(state.genres.join(", ")) - ] - ).block(block.clone()); - text.render(area, buf); - } -} \ No newline at end of file diff --git a/ui/src/widgets/components/game_list.rs b/ui/src/widgets/components/game_list.rs deleted file mode 100755 index 473d9d2..0000000 --- a/ui/src/widgets/components/game_list.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::marker::PhantomData; -use color_eyre::Result; -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::palette::tailwind::SLATE; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line}; -use ratatui::widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState, StatefulWidget}; -use serde::de::DeserializeOwned; -use models::db::RocksColumn; -use crate::widgets::components::Component; - -const SELECTED_STYLE: Style = Style::new() - .bg(SLATE.c800) - .add_modifier(Modifier::BOLD); - -#[derive(Debug, Clone)] -pub struct GameList<'a, T> where T: Into> + DeserializeOwned + RocksColumn + Clone { - pub state: GameListState<'a, T> -} - -#[derive(Debug, Clone)] -pub struct GameListState<'a, T> where T: Into> + DeserializeOwned + RocksColumn + Clone { - games: Vec, - list_state: ListState, - list_page_size: usize, - active: bool, - _phantom: PhantomData<&'a ()> -} - -impl<'a, T> Component for GameList<'a, T> where T: Into> + DeserializeOwned + RocksColumn + Clone { - fn set_active(&mut self, active: bool) { - self.state.active = active; - } -} - -impl<'a, T> Default for GameList<'a, T> where T: Into> + DeserializeOwned + RocksColumn + Clone { - fn default() -> Self { - Self::new(vec![]) - } -} - -impl<'a, T> StatefulWidget for GameList<'a, T> -where - T: Into> + DeserializeOwned + RocksColumn + Clone -{ - type State = GameListState<'a, T>; - - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let list_block = Block::new() - .title(Line::raw("Games")) - .borders(Borders::ALL); - let list_block = - if state.active { list_block.style(Style::default().fg(Color::Yellow)) } - else { list_block.style(Style::default()) }; - let game_list = List::new(state.games.clone()) - .block(list_block) - .style(Style::default().fg(Color::White)) - .highlight_style(SELECTED_STYLE) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::WhenSelected); - state.list_page_size = (area.height - 2) as usize; - game_list.render(area, buf, &mut state.list_state); - } -} - -impl<'a, T> GameList<'a, T> -where - T: Into> + DeserializeOwned + RocksColumn + Clone -{ - pub fn new(games: Vec) -> Self { - let mut state = ListState::default(); - state.select_first(); - let game_list = GameList { - state: GameListState:: { - games, - list_state: state, - _phantom: PhantomData, - list_page_size: 0, - active: false - }, - }; - game_list - } - - pub fn handle_game_list_key(&mut self, event: &KeyEvent) -> Result<()> { - let mut game_list_state = self.state.list_state.clone(); - match event.code { - KeyCode::Down => game_list_state.select_next(), - KeyCode::Up => game_list_state.select_previous(), - KeyCode::PageUp =>game_list_state.scroll_up_by(self.state.list_page_size as u16), - KeyCode::PageDown => game_list_state.scroll_down_by(self.state.list_page_size as u16), - KeyCode::Home => game_list_state.select_first(), - KeyCode::End => game_list_state.select_last(), - _ => {} - } - self.state.list_state = game_list_state; - Ok(()) - } - - pub fn get_selected_value(&self) -> Option<&T> { - let Some(index) = self.state.list_state.selected() else { - return None; - }; - self.state.games.get(index.clamp(0, self.state.games.len() - 1)) - } -} \ No newline at end of file diff --git a/ui/src/widgets/components/mod.rs b/ui/src/widgets/components/mod.rs deleted file mode 100755 index cd50edd..0000000 --- a/ui/src/widgets/components/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod textarea; -mod game_list; -mod game_info_box; - -pub use textarea::*; -pub use game_list::*; -pub use game_info_box::*; - -pub trait Component { - fn set_active(&mut self, active: bool); -} diff --git a/ui/src/widgets/components/textarea.rs b/ui/src/widgets/components/textarea.rs deleted file mode 100755 index 924c149..0000000 --- a/ui/src/widgets/components/textarea.rs +++ /dev/null @@ -1,159 +0,0 @@ -use std::sync::Arc; -use color_eyre::Result; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use rat_cursor::HasScreenCursor; -use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::prelude::StatefulWidget; -use ratatui::style::{Color, Stylize}; -use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, Borders, Paragraph, Widget}; -use tui_input::backend::crossterm::EventHandler; -use tui_input::Input; - -#[derive(Clone)] -pub struct TextArea { - title: String, - style: TextAreaStyle, - auto_scroll: bool, - validate_fn: Arc bool>, - pub state: TextAreaState, -} - -#[derive(Clone)] -pub struct TextAreaState { - pub is_active: bool, - input_area: Option, - scroll_offset: u16, - input: Input, - is_valid: bool -} - -#[derive(Clone)] -pub enum TextAreaStyle { - Block, - SingleLine, -} - -impl StatefulWidget for TextArea { - type State = TextAreaState; - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) - where - Self: Sized, - { - let input_value = state.input.value().to_string(); - let title = self.title.clone(); - match self.style { - TextAreaStyle::Block => { - let block = Block::default().borders(Borders::ALL).title(title); - let paragraph = Paragraph::new(Text::from(input_value)).block(block); - state.input_area = Some(area); - paragraph.render(area, buf); - } - TextAreaStyle::SingleLine => { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Max((self.title.len() + 1) as u16), Constraint::Fill(0)]) - .split(area); - let label_text = Text::from(self.title); - let label = Paragraph::new(label_text); - - let input_text = Span::from(input_value.clone()).fg(Color::White); - let input_line = Line::from(input_text); - let paragraph = Paragraph::new(input_line) - .bg(if state.is_valid { Color::Green } else { Color::Red }) - .scroll((0, state.scroll_offset)); - - if state.input_area.is_none() { - state.input_area = Some(chunks[1]); - } - else if let Some(area) = self.state.input_area && area != chunks[1] { - state.input_area = Some(chunks[1]); - } - - label.render(chunks[0], buf); - paragraph.render(chunks[1], buf); - } - } - } -} - -impl HasScreenCursor for TextArea { - fn screen_cursor(&self) -> Option<(u16, u16)> { - if self.state.input_area.is_none() { - return None; - } - let area = self.state.input_area.unwrap(); - let scroll = self.state.input.visual_scroll(1); - let x = self.state.input.visual_cursor().max(scroll) as u16 - self.state.scroll_offset; - Some((area.x + x, area.y)) - } -} - -impl TextArea { - pub fn new(title: &str, - placeholder_text: &str, - validate_fn: fn(&str) -> bool, - ) -> Self { - let func = Arc::new(validate_fn); - Self { - title: title.to_string(), - style: TextAreaStyle::SingleLine, - auto_scroll: true, - validate_fn: func, - state: TextAreaState { - input: Input::new(placeholder_text.to_string()), - is_active: false, - input_area: None, - scroll_offset: 0, - is_valid: false - } - } - } - - pub fn display_style(mut self, style: TextAreaStyle) -> Self { - self.style = style; - self - } - - pub fn set_auto_scroll(mut self, auto_scroll: bool) -> Self { - self.auto_scroll = auto_scroll; - self - } - - pub fn handle_input(&mut self, event: &Event) -> Result<()> { - let _ = self.state.input.handle_event(event); - self.state.is_valid = (self.validate_fn)(self.state.input.value()); - if let Event::Key(key) = event && - !matches!(key.kind, KeyEventKind::Release) && - let Some(area) = self.state.input_area - { - let scroll_offset = self.state.scroll_offset; - let cursor_pos = self.state.input.cursor() as u16; - if scroll_offset > cursor_pos { - self.state.scroll_offset = cursor_pos; - } else if cursor_pos >= area.width + scroll_offset { - self.state.scroll_offset = cursor_pos - area.width; - } else if self.auto_scroll && scroll_offset > 0 && key.code.is_delete() { - self.state.scroll_offset -= 1; - // HACK: with_cursor function requires to be owned so use handle event - let key_event = Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())); - let _ = self.state.input.handle_event(&key_event); - } - } - - Ok(()) - } - - pub fn get_value(&self) -> Option { - if self.state.is_valid { - return Some(self.state.input.value().to_string()); - } - None - } - - pub fn reset_value(&mut self) -> Result<()> { - self.state.is_valid = false; - Ok(self.state.input.reset()) - } -} diff --git a/ui/src/widgets/mod.rs b/ui/src/widgets/mod.rs deleted file mode 100755 index e2f6435..0000000 --- a/ui/src/widgets/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod components; -pub mod popups; -pub mod views; diff --git a/ui/src/widgets/popups/folder.rs b/ui/src/widgets/popups/folder.rs deleted file mode 100755 index 33fd5d2..0000000 --- a/ui/src/widgets/popups/folder.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::path::Path; -use crate::widgets::components::TextArea; -use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; -use ratatui::prelude::{StatefulWidget, Widget}; -use ratatui::widgets::{Block, Borders}; - -#[derive(Clone)] -pub struct AddFolderPopup { - pub textarea: TextArea, -} - -impl AddFolderPopup { - pub fn new() -> Self { - let mut textarea = TextArea::new( - "Folder Path", - "", - |v| { - let path = Path::new(v); - path.exists() && path.is_dir() - } - ); - textarea.state.is_active = true; - Self { textarea } - } - - pub fn get_folder_value(&mut self) -> Option { - let value = self.textarea.get_value(); - if value.is_none() { - return None; - } - if let Some(path) = value && !path.is_empty() { - return Some(path); - } - None - } -} - -impl StatefulWidget for AddFolderPopup { - type State = AddFolderPopup; - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) - where - Self: Sized, - { - let popup_area = Rect { - x: area.width / 4, - y: area.height / 3, - width: area.width / 2, - height: area.height / 3, - }; - let block = Block::default() - .title("Add New Folder") - .borders(Borders::ALL); - block.render(popup_area, buf); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![Constraint::Length(1)]) - .split(popup_area.inner(Margin::new(1, 1))); - self.textarea.render(chunks[0], buf, &mut state.textarea.state); - } -} diff --git a/ui/src/widgets/popups/mod.rs b/ui/src/widgets/popups/mod.rs deleted file mode 100755 index f0c8f9c..0000000 --- a/ui/src/widgets/popups/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod folder; - -#[derive(Clone)] -pub enum AppPopup { - AddFolder(folder::AddFolderPopup) -} \ No newline at end of file diff --git a/ui/src/widgets/views/main_view.rs b/ui/src/widgets/views/main_view.rs deleted file mode 100755 index 51f6935..0000000 --- a/ui/src/widgets/views/main_view.rs +++ /dev/null @@ -1,219 +0,0 @@ -use crate::widgets::popups::folder::AddFolderPopup; -use crossterm::event::KeyCode::Char; -use crossterm::event::{Event, KeyCode, KeyEventKind}; -use rat_cursor::HasScreenCursor; -use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::prelude::{Color, Line, Span, Style, Text, Widget}; -use ratatui::widgets::{Paragraph, StatefulWidget}; -use db::{RocksDBFactory}; -use models::config::ApplicationConfig; -use models::dlsite::{DLSiteManiax}; -use crate::widgets::components::{Component, GameInfoBox, GameList}; -use crate::widgets::popups::AppPopup; -use crate::widgets::views::View; - -#[derive(Clone)] -pub struct MainView { - pub state: MainViewState, - db_factory: RocksDBFactory -} - -#[derive(Clone)] -pub struct MainViewState { - status: Status, - dl_game_list: GameList<'static, DLSiteManiax>, - game_info_box: GameInfoBox -} - -#[derive(Clone)] -enum Status { - Running, - Exiting, - Popup(AppPopup), -} - -impl MainView { - pub fn new(mut db_factory: RocksDBFactory) -> color_eyre::Result { - let mut games = { - let db = db_factory.get_current_context()?; - let values = db.get_all_values::()?; - values - }; - games.sort_by(|a, b| { - let left = a.rj_num - .chars().skip(2) - .collect::() - .parse::() - .unwrap(); - let right = b.rj_num - .chars().skip(2) - .collect::() - .parse::() - .unwrap(); - left.cmp(&right) - }); - let first_game = games[0].clone(); - let mut dl_game_list = GameList::new(games); - dl_game_list.set_active(true); - let view = Self { - state: MainViewState { - status: Status::Running, - dl_game_list, - game_info_box: GameInfoBox::new(first_game)? - }, - db_factory - }; - Ok(view) - } -} - -impl MainViewState { - fn quit(&mut self) -> color_eyre::Result<()> { - if matches!(self.status, Status::Running) { - self.status = Status::Exiting; - ApplicationConfig::get_config()?.save()?; - } - Ok(()) - } - - fn folder_popup(&mut self) { - self.status = Status::Popup(AppPopup::AddFolder(AddFolderPopup::new())); - } - - fn handle_popup(&mut self, event: &Event) -> color_eyre::Result<()> { - let Status::Popup(popup) = &mut self.status else { - return Ok(()); - }; - match popup { - AppPopup::AddFolder(folder_popup) => { - folder_popup.textarea.handle_input(event)?; - if let Event::Key(key) = event && - key.code.is_enter() && - let Some(value) = folder_popup.get_folder_value() - { - let mut config = ApplicationConfig::get_config()?; - config.path_config.dlsite_paths.push(value); - - folder_popup.textarea.reset_value()?; - config.save()?; - } - } - } - Ok(()) - } -} - -impl View for MainView { - fn handle_input(&mut self, event: &Event) -> color_eyre::Result<()> { - let state = &mut self.state; - state.handle_popup(event)?; - - if let Event::Key(key_event) = event { - if let Status::Popup(_) = &state.status && - matches!(key_event.code, KeyCode::Esc) - { - state.status = Status::Running; - } - if matches!(state.status, Status::Running) && - matches!(key_event.kind, KeyEventKind::Press) - { - match key_event.code { - Char('q') => state.quit()?, - Char('a') => state.folder_popup(), - _ => {} - } - state.dl_game_list.handle_game_list_key(key_event)?; - match state.dl_game_list.get_selected_value() { - Some(value) => { - state.game_info_box.set_info(value)?; - } - None => println!("No game selected") - } - } - } - Ok(()) - } - - fn is_running(&self) -> bool { - !matches!(self.state.status, Status::Exiting) - } -} - -impl StatefulWidget for MainView { - type State = MainViewState; - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) - where - Self: Sized, - { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ]) - .split(area); - - Self::render_header(chunks[0], buf); - Self::render_game_info(chunks[1], buf, state); - Self::render_footer(state, chunks[2], buf); - - let Status::Popup(popup) = &mut state.status else { - return; - }; - match popup { - AppPopup::AddFolder(popup) => { - popup.clone().render(area, buf, popup); - } - } - } -} - -impl HasScreenCursor for MainView { - fn screen_cursor(&self) -> Option<(u16, u16)> { - let Status::Popup(popup) = &self.state.status else { - return None; - }; - match popup { - AppPopup::AddFolder(popup) => { - popup.textarea.screen_cursor() - } - } - } -} - -impl MainView { - fn render_game_info(area: Rect, buf: &mut Buffer, state: &mut MainViewState) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(14), - Constraint::Fill(0), - ]) - .split(area); - state.dl_game_list.clone().render(chunks[0], buf, &mut state.dl_game_list.state); - state.game_info_box.clone().render(chunks[1], buf, &mut state.game_info_box.state); - } - - fn render_header(area: Rect, buf: &mut Buffer) { - let title = Paragraph::new(Text::styled( - "SuS Manager", - Style::default().fg(Color::Green), - )); - title.render(area, buf); - } - - fn render_footer(state: &mut MainViewState, area: Rect, buf: &mut Buffer) { - let mut navigation_text = vec![Span::styled( - "(q) quit / (a) add folders", - Style::default().fg(Color::Green), - )]; - if let Status::Popup(_) = state.status { - navigation_text[0] = Span::styled("(Esc) close", Style::default().fg(Color::Green)); - } - let line = Line::from(navigation_text); - let footer = Paragraph::new(line); - footer.render(area, buf); - } -} diff --git a/ui/src/widgets/views/mod.rs b/ui/src/widgets/views/mod.rs deleted file mode 100755 index 2a7d0d4..0000000 --- a/ui/src/widgets/views/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -mod main_view; - -use crossterm::event::{Event}; -use rat_cursor::HasScreenCursor; -pub use main_view::MainView; - -pub trait View: HasScreenCursor { - fn handle_input(&mut self, event: &Event) -> color_eyre::Result<()>; - fn is_running(&self) -> bool; -} - -#[derive(Clone)] -pub enum AppView { - Main(MainView), -} - -impl AppView { - pub fn get_view(&mut self) -> &mut dyn View - { - match self { - AppView::Main(main_view) => main_view - } - } -} \ No newline at end of file