Refactor structure

This commit is contained in:
2025-12-14 21:34:06 +08:00
parent 952f00261b
commit 27cb9fa32f
37 changed files with 712 additions and 486 deletions

43
ui/Cargo.toml Executable file
View File

@@ -0,0 +1,43 @@
[package]
name = "ui"
version = "0.1.0"
edition = "2024"
[lib]
name = "ui"
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
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"]

88
ui/src/app.rs Executable file
View File

@@ -0,0 +1,88 @@
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;
use models::dlsite::{DLSiteCategory, DLSiteGenre, DLSiteManiax};
pub(crate) struct App {
events: EventHandler,
state: AppState,
db_factory: RocksDBFactory
}
#[derive(Clone)]
pub struct AppState {
view: Option<AppView>,
}
impl App {
pub async fn create() -> Result<Self> {
let config = ApplicationConfig::get_config()?;
let mut db_factory = RocksDBFactory::default();
db_factory.register::<DLSiteManiax>();
db_factory.register::<DLSiteGenre>();
db_factory.register::<DLSiteCategory>();
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,
);
}
}
}
}

65
ui/src/cli/folder.rs Executable file
View File

@@ -0,0 +1,65 @@
use std::path::PathBuf;
use clap::{Args, Command, Parser, Subcommand};
use color_eyre::eyre::eyre;
use models::config::ApplicationConfig;
#[derive(Parser, Debug)]
pub(super) struct FolderAddCommand {
path: String,
}
#[derive(Parser, Debug)]
pub(super) enum FolderSubCommand {
Add(FolderAddCommand),
}
#[derive(Parser, Debug)]
pub(super) struct FolderCommand {
#[command(subcommand)]
pub(super) subcommand: FolderSubCommand,
}
impl Subcommand for FolderCommand {
fn augment_subcommands(cmd: Command) -> Command {
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)
}
fn has_subcommand(name: &str) -> bool {
matches!(name, "add")
}
}
impl FolderSubCommand {
pub async fn handle(&self) -> color_eyre::Result<()> {
match self {
FolderSubCommand::Add(cmd) => cmd.handle().await,
}
}
}
impl FolderAddCommand {
pub async fn handle(&self) -> color_eyre::Result<()> {
let mut config = ApplicationConfig::get_config()?;
let path = PathBuf::from(&self.path);
let abs_path = path.canonicalize()?;
if !abs_path.is_dir() {
return Err(eyre!("{:?} is not a directory", abs_path));
}
config
.path_config
.dlsite_paths
.push(abs_path.to_str().unwrap().to_string());
config.save()?;
println!("Added {:?} to path config", abs_path);
Ok(())
}
}

63
ui/src/cli/mod.rs Executable file
View File

@@ -0,0 +1,63 @@
mod folder;
mod sync;
use crate::{app, helpers};
use clap::{command, Parser};
use color_eyre::Result;
use ratatui::crossterm;
use crate::cli::folder::FolderCommand;
use crate::cli::sync::DLSiteCommand;
#[derive(Parser, Debug)]
enum CliSubCommand {
Folder(FolderCommand),
#[command(name = "dlsite")]
DLSite(DLSiteCommand),
}
#[derive(Parser, Debug)]
#[command(version, about)]
pub struct Cli {
#[command(subcommand)]
subcommand: Option<CliSubCommand>,
}
impl Cli {
pub async fn run(&self) -> Result<()> {
helpers::initialize_folders().await?;
if self.subcommand.is_none() {
return self.start_tui().await;
}
if let Some(sub_command) = &self.subcommand {
return sub_command.handle().await;
}
Ok(())
}
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
}
}
impl CliSubCommand {
pub async fn handle(&self) -> Result<()> {
match self {
CliSubCommand::Folder(cmd) => cmd.subcommand.handle().await,
CliSubCommand::DLSite(cmd) => cmd.subcommand.handle().await,
}
}
}

212
ui/src/cli/sync.rs Executable file
View File

@@ -0,0 +1,212 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use clap::{Parser};
use color_eyre::eyre::{Result};
use crossterm::style::{style, Stylize};
use futures::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use itertools::Itertools;
use tokio::sync::Mutex;
use tokio::time::Instant;
use crawler::DLSiteCrawler;
use db::{RocksDBFactory};
use models::config::ApplicationConfig;
use models::dlsite::{DLSiteCategory, DLSiteGenre, DLSiteManiax, DLSiteTranslation};
use crate::helpers;
#[derive(Parser, Debug)]
pub(super) struct DLSiteCommand {
#[command(subcommand)]
pub(super) subcommand: DLSiteSubCommand,
}
#[derive(Parser, Debug)]
pub(super) enum DLSiteSubCommand {
#[command(name = "sync")]
Sync(DLSiteSyncCommand)
}
#[derive(Parser, Debug)]
pub(super) struct DLSiteSyncCommand {
#[clap(long, short, action)]
missing: bool,
#[clap(long = "genre", default_value = "false")]
do_sync_genre: bool,
#[clap(long = "work", default_value = "true")]
do_sync_work: bool
}
impl DLSiteSubCommand {
pub async fn handle(&self) -> Result<()> {
match self {
Self::Sync(cmd) => cmd.handle().await,
}
}
}
impl DLSiteSyncCommand {
pub async fn handle(&self) -> Result<()> {
let now = Instant::now();
let app_conf = ApplicationConfig::get_config()?;
let db_factory = RocksDBFactory::default();
let crawler = DLSiteCrawler::new()?;
if self.do_sync_genre {
let genre_now = Instant::now();
Self::sync_genres(db_factory.clone(), &app_conf, &crawler).await?;
println!(
"{} {} Done in {:.2?}",
style("Genres").cyan(),
style("Syncing").green(),
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(),
work_now.elapsed()
);
}
println!("{} Done in {:.2?}", style("Syncing").green(), now.elapsed());
Ok(())
}
async fn sync_genres(mut db_factory: RocksDBFactory, app_conf: &ApplicationConfig, crawler: &DLSiteCrawler) -> Result<()> {
let mut db = db_factory.get_current_context()?;
let requested_categories = crawler.get_all_genres(&app_conf.basic_config.locale).await?;
let categories: Vec<DLSiteCategory> = requested_categories.iter()
.map(|g| g.clone().try_into())
.filter_map(Result::ok)
.collect();
db.set_values(&categories)?;
let genres = requested_categories.into_iter()
.flat_map(|v| v.values)
.collect_vec();
let existing_genres = db.get_all_values::<DLSiteGenre>()?;
let mut modified_genres: Vec<DLSiteGenre> = Vec::new();
for genre in genres {
let id = genre.value.parse::<u16>()?;
let existing_genre =
existing_genres.iter().find(|v| v.id == id);
if let Some(existing_genre) = existing_genre {
let name = DLSiteTranslation::try_from(genre.name)?;
if existing_genre.name.contains(&name) {
modified_genres.push(existing_genre.clone());
continue;
}
let mut modified_genre = existing_genre.clone();
modified_genre.name.push(name);
modified_genres.push(modified_genre);
}
else {
modified_genres.push(DLSiteGenre {
id, name: vec![DLSiteTranslation::try_from(genre.name)?]
});
}
}
db.set_values(&modified_genres)?;
Ok(())
}
async fn sync_works(&self, app_conf: &ApplicationConfig, mut db_factory: RocksDBFactory, crawler: &DLSiteCrawler) -> Result<()> {
let mut db = db_factory.get_current_context()?;
let existing_works = db.get_all_values::<DLSiteManiax>()?;
let work_list = self.get_work_list(&app_conf, &existing_works).await?;
let rj_nums = work_list.clone().into_keys().collect::<Vec<_>>();
let mut game_infos = crawler.get_game_infos(rj_nums, &app_conf.basic_config.locale).await?;
let existing_game_infos = db.get_all_values::<DLSiteManiax>()?;
let mut modified_maniaxes: Vec<DLSiteManiax> = Vec::new();
let progress = ProgressBar::new(game_infos.len() as u64)
.with_style(ProgressStyle::default_bar());
let shared_progress = Mutex::new(progress);
while let Some(info) = game_infos.next().await {
let maniax = info?;
let existing_maniax = existing_game_infos.iter()
.find(|v| v.rj_num == maniax.rj_num);
if let Some(existing_maniax) = existing_maniax {
let name = DLSiteTranslation::try_from(maniax.title)?;
if existing_maniax.name.contains(&name) {
modified_maniaxes.push(existing_maniax.clone());
continue;
}
let mut modified_maniax = existing_maniax.clone();
modified_maniax.name.push(name);
modified_maniaxes.push(modified_maniax);
} else {
let mut value: DLSiteManiax = maniax.into();
let maniax_folder = work_list.get(&value.rj_num).unwrap().to_owned();
value.folder_path = maniax_folder;
modified_maniaxes.push(value);
}
let progress = shared_progress.lock().await;
progress.inc(1);
}
db.set_values(&modified_maniaxes)?;
Ok(())
}
async fn get_work_list(&self, app_conf: &ApplicationConfig, existing_works: &[DLSiteManiax])
-> Result<HashMap<String, PathBuf>>
{
let existing_nums = existing_works.iter()
.map(|x| x.rj_num.clone())
.collect::<Vec<_>>();
let existing_folders = existing_works.iter()
.map(|x| x.folder_path.to_str().unwrap().to_string())
.collect::<Vec<_>>();
let mut works_list: HashMap<String, PathBuf> = HashMap::new();
let config_paths = app_conf.path_config.dlsite_paths.iter()
.map(|path| Path::new(path))
.collect::<Vec<_>>();
let dir_paths = helpers::get_all_folders(&config_paths).await?;
for dir_path in dir_paths {
if !dir_path.is_dir() {
println!(
"{} {}",
style(dir_path.to_str().unwrap()).blue(),
style("is not a directory").red()
);
continue;
}
let dir_path_str = dir_path.to_str().unwrap().to_string();
let dir_name = dir_path
.file_name().unwrap()
.to_str().unwrap()
.to_string();
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()
);
continue;
}
if self.missing && existing_nums.contains(&dir_name) {
continue;
}
works_list.insert(dir_name, dir_path);
}
Ok(works_list)
}
}
fn is_valid_rj_number(rj_num: &str) -> bool {
let len = rj_num.len();
if len != 8 && len != 10 {
return false;
}
if !rj_num.starts_with("RJ") {
return false;
}
if !rj_num.chars().skip(2).all(|c| c.is_numeric()) {
return false;
}
true
}

52
ui/src/event.rs Executable file
View File

@@ -0,0 +1,52 @@
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<AppEvent>,
rx: UnboundedReceiver<AppEvent>,
}
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<AppEvent> {
self.rx.recv().await.ok_or(eyre!("Unable to get event"))
}
}

35
ui/src/helpers/mod.rs Executable file
View File

@@ -0,0 +1,35 @@
use std::path::{Path, PathBuf};
use color_eyre::eyre::eyre;
use color_eyre::owo_colors::OwoColorize;
use tokio::fs;
use crawler::DLSITE_IMG_FOLDER;
use models::{APP_CONFIG_DIR, APP_DATA_DIR};
pub async fn initialize_folders() -> color_eyre::Result<()> {
if !APP_CONFIG_DIR.exists() {
fs::create_dir_all(APP_CONFIG_DIR.as_path()).await?;
}
if !APP_DATA_DIR.exists() {
fs::create_dir_all(APP_DATA_DIR.as_path()).await?;
}
if !DLSITE_IMG_FOLDER.exists() {
fs::create_dir_all(DLSITE_IMG_FOLDER.as_path()).await?;
}
Ok(())
}
pub async fn get_all_folders(paths: &[&Path]) -> color_eyre::Result<Vec<PathBuf>> {
let mut folders: Vec<PathBuf> = Vec::new();
for path in paths {
let path = path.to_path_buf();
if !path.exists() {
return Err(eyre!("{:?} {}", path.blue(), "does not exist".red()));
}
let mut dirs = fs::read_dir(path).await?;
while let Some(dir) = dirs.next_entry().await? {
folders.push(dir.path());
}
}
Ok(folders)
}

8
ui/src/lib.rs Executable file
View File

@@ -0,0 +1,8 @@
mod app;
mod cli;
mod event;
mod helpers;
mod models;
mod widgets;
pub use cli::Cli;

33
ui/src/models/game_list.rs Executable file
View File

@@ -0,0 +1,33 @@
use color_eyre::Result;
use ratatui::widgets::ListState;
use serde::de::DeserializeOwned;
use db::types::RocksColumn;
#[derive(Debug, Clone)]
pub(crate) struct GameList<T> {
pub games: Vec<T>,
pub state: ListState,
}
impl<T> Default for GameList<T> {
fn default() -> Self {
Self {
games: Vec::new(),
state: ListState::default(),
}
}
}
impl<T> GameList<T>
where T: DeserializeOwned + RocksColumn
{
pub fn new(games: Vec<T>) -> Result<Self> {
let mut state = ListState::default();
state.select_first();
let game_list = GameList {
games,
state
};
Ok(game_list)
}
}

2
ui/src/models/mod.rs Executable file
View File

@@ -0,0 +1,2 @@
mod game_list;
pub use game_list::*;

View File

@@ -0,0 +1,2 @@
mod textarea;
pub use textarea::*;

View File

@@ -0,0 +1,159 @@
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<dyn Fn(&str) -> bool>,
pub state: TextAreaState,
}
#[derive(Clone)]
pub struct TextAreaState {
pub is_active: bool,
input_area: Option<Rect>,
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<String> {
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())
}
}

3
ui/src/widgets/mod.rs Executable file
View File

@@ -0,0 +1,3 @@
pub mod components;
pub mod popups;
pub mod views;

61
ui/src/widgets/popups/folder.rs Executable file
View File

@@ -0,0 +1,61 @@
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",
"",
|x| {
let path = Path::new(x);
path.exists() && path.is_dir()
}
);
textarea.state.is_active = true;
Self { textarea }
}
pub fn get_folder_value(&mut self) -> Option<String> {
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);
}
}

6
ui/src/widgets/popups/mod.rs Executable file
View File

@@ -0,0 +1,6 @@
pub mod folder;
#[derive(Clone)]
pub enum AppPopup {
AddFolder(folder::AddFolderPopup)
}

249
ui/src/widgets/views/main_view.rs Executable file
View File

@@ -0,0 +1,249 @@
use crate::widgets::popups::folder::AddFolderPopup;
use crossterm::event::KeyCode::Char;
use crossterm::event::{Event, KeyCode, KeyEvent, 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::style::Modifier;
use ratatui::style::palette::tailwind::SLATE;
use ratatui::widgets::{Block, Borders, HighlightSpacing, List, Paragraph, StatefulWidget};
use db::RocksDBFactory;
use models::config::ApplicationConfig;
use models::dlsite::DLSiteManiax;
use crate::models::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 {
popup: Option<AppPopup>,
status: Status,
dl_game_list: GameList<DLSiteManiax>,
list_page_size: usize,
}
#[derive(Debug, Clone, Copy)]
enum Status {
Running,
Exiting,
Popup,
}
impl MainView {
pub fn new(mut db_factory: RocksDBFactory) -> color_eyre::Result<Self> {
let db = db_factory.get_current_context()?;
let games = db.get_all_values::<DLSiteManiax>()?;
let dl_game_list = GameList::new(games)?;
let view = Self {
state: MainViewState {
popup: None,
status: Status::Running,
list_page_size: 0,
dl_game_list,
},
db_factory
};
Ok(view)
}
}
impl MainViewState {
fn quit(&mut self) -> color_eyre::Result<()> {
if self.popup.is_none() {
self.status = Status::Exiting;
ApplicationConfig::get_config()?.save()?;
}
Ok(())
}
fn folder_popup(&mut self) {
self.popup = Some(AppPopup::AddFolder(AddFolderPopup::new()));
self.status = Status::Popup;
}
fn handle_popup(&mut self, event: &Event) -> color_eyre::Result<()> {
let Some(current_popup) = self.popup.as_mut() else {
return Ok(());
};
match current_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(())
}
fn handle_game_list_key(&mut self, event: &KeyEvent) -> color_eyre::Result<()> {
let game_list_state = &mut self.dl_game_list.state;
let game_list_len = self.dl_game_list.games.len();
let selected_value = game_list_state.selected().unwrap_or(0);
match event.code {
KeyCode::Down => game_list_state.select_next(),
KeyCode::Up => game_list_state.select_previous(),
KeyCode::PageUp => {
let selected_index =
if selected_value < self.list_page_size { 0 }
else { selected_value - self.list_page_size };
game_list_state.select(Some(selected_index))
},
KeyCode::PageDown => {
game_list_state.select(Some((selected_value + self.list_page_size).clamp(0, game_list_len)))
},
KeyCode::Home => game_list_state.select_first(),
KeyCode::End => game_list_state.select_last(),
_ => {}
}
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 matches!(state.status, Status::Popup) &&
matches!(key_event.code, KeyCode::Esc)
{
state.status = Status::Running;
state.popup = None;
}
if !matches!(state.status, Status::Popup) &&
matches!(key_event.kind, KeyEventKind::Press)
{
match key_event.code {
Char('q') => state.quit()?,
Char('a') => state.folder_popup(),
_ => {}
}
state.handle_game_list_key(key_event)?;
}
}
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);
state.list_page_size = chunks[1].height as usize;
Self::render_header(chunks[0], buf);
Self::render_game_info(chunks[1], buf, state);
Self::render_footer(state, chunks[2], buf);
let Some(popup) = state.popup.as_mut() 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 Some(popup) = &self.state.popup else {
return None;
};
match popup {
AppPopup::AddFolder(popup) => {
popup.textarea.screen_cursor()
}
}
}
}
impl MainView {
const SELECTED_STYLE: Style = Style::new()
.bg(SLATE.c800)
.add_modifier(Modifier::BOLD);
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);
Self::render_game_list(chunks[0], buf, state);
Self::render_game_box(chunks[1], buf, state);
}
fn render_game_list(area: Rect, buf: &mut Buffer, state: &mut MainViewState) {
let list_block = Block::new()
.title(Line::raw("Games"))
.borders(Borders::ALL)
.style(Style::default());
let game_list = List::new(&state.dl_game_list.games)
.block(list_block)
.highlight_style(Self::SELECTED_STYLE)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::WhenSelected);
StatefulWidget::render(game_list, area, buf, &mut state.dl_game_list.state);
}
fn render_game_box(area: Rect, buf: &mut Buffer, state: &mut MainViewState) {
let block = Block::new()
.title(Line::raw("Info"))
.borders(Borders::ALL)
.style(Style::default());
block.render(area, buf);
}
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 matches!(state.status, Status::Popup) {
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);
}
}

24
ui/src/widgets/views/mod.rs Executable file
View File

@@ -0,0 +1,24 @@
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
}
}
}