Refactor structure
This commit is contained in:
2
ui/src/widgets/components/mod.rs
Executable file
2
ui/src/widgets/components/mod.rs
Executable file
@@ -0,0 +1,2 @@
|
||||
mod textarea;
|
||||
pub use textarea::*;
|
||||
159
ui/src/widgets/components/textarea.rs
Executable file
159
ui/src/widgets/components/textarea.rs
Executable 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
3
ui/src/widgets/mod.rs
Executable file
@@ -0,0 +1,3 @@
|
||||
pub mod components;
|
||||
pub mod popups;
|
||||
pub mod views;
|
||||
61
ui/src/widgets/popups/folder.rs
Executable file
61
ui/src/widgets/popups/folder.rs
Executable 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
6
ui/src/widgets/popups/mod.rs
Executable 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
249
ui/src/widgets/views/main_view.rs
Executable 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
24
ui/src/widgets/views/mod.rs
Executable 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user