From a776e5518769fbb2aeb5ba4ed01b621e087f4ccd Mon Sep 17 00:00:00 2001 From: fromost Date: Wed, 15 Oct 2025 18:06:45 +0800 Subject: [PATCH] Add cursor to textarea --- Cargo.lock | 7 ++++ Cargo.toml | 1 + src/app.rs | 8 +++- src/widgets/components/mod.rs | 3 +- src/widgets/components/textarea.rs | 66 +++++++++++++++++++++++++----- src/widgets/popups/folder.rs | 6 +-- src/widgets/views/main_view.rs | 47 ++++++++++++++------- src/widgets/views/mod.rs | 13 ++---- 8 files changed, 109 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36b20ea..5a98b12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,6 +1727,12 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rat-cursor" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d7c7dfdb4219fa10891a06232b071460a66ccd15ce627e3764d63c0371eb9f5" + [[package]] name = "ratatui" version = "0.29.0" @@ -2234,6 +2240,7 @@ dependencies = [ "futures", "lazy_static", "libsqlite3-sys", + "rat-cursor", "ratatui", "reqwest", "robotstxt", diff --git a/Cargo.toml b/Cargo.toml index ce81788..fc4c8e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ lazy_static = "1.5.0" rust-ini = "0.21.3" robotstxt = "0.3.0" scraper = "0.24.0" +rat-cursor = "1.2.1" [dependencies.tui-input] version = "0.14.0" diff --git a/src/app.rs b/src/app.rs index 5d60194..faf567d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use std::any::Any; +use std::any::{Any}; use crate::event::{AppEvent, EventHandler}; use ratatui::{DefaultTerminal, Frame}; use std::time::Duration; @@ -6,10 +6,11 @@ use color_eyre::Result; use crossterm::event::{Event, KeyEvent}; use crossterm::event::Event as CrosstermEvent; use diesel::{Connection, SqliteConnection}; +use rat_cursor::HasScreenCursor; use crate::config::types::ApplicationConfig; use crate::constants::{APP_CONFIG_DIR, APP_CONIFG_FILE_PATH, APP_DATA_DIR}; use crate::widgets::views::{View}; -use crate::widgets::views::main_view::MainView; +use crate::widgets::views::MainView; pub(crate) struct App { events: EventHandler, @@ -99,6 +100,9 @@ impl App { if let Some(view) = self.state.view.as_mut() { if let Some(main_view) = view.downcast_mut::() { frame.render_stateful_widget(MainView::new(&self.app_config), frame.area(), &mut main_view.state); + if let Some(pos) = main_view.screen_cursor() { + frame.set_cursor_position(pos); + } } } } diff --git a/src/widgets/components/mod.rs b/src/widgets/components/mod.rs index 2dfb9fd..8e4a897 100644 --- a/src/widgets/components/mod.rs +++ b/src/widgets/components/mod.rs @@ -1 +1,2 @@ -pub mod textarea; \ No newline at end of file +mod textarea; +pub use textarea::*; \ No newline at end of file diff --git a/src/widgets/components/textarea.rs b/src/widgets/components/textarea.rs index 9f25667..705a1b0 100644 --- a/src/widgets/components/textarea.rs +++ b/src/widgets/components/textarea.rs @@ -1,21 +1,29 @@ use crossterm::event::{Event}; use ratatui::buffer::Buffer; -use ratatui::layout::Rect; +use ratatui::layout::{Constraint, Direction, 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; +use tui_input::backend::crossterm::{EventHandler}; +use tui_input::{Input}; use color_eyre::Result; -use ratatui::crossterm::cursor::SetCursorStyle; +use rat_cursor::HasScreenCursor; +use ratatui::style::{Color, Stylize}; #[derive(Clone)] pub struct TextArea { input: Input, title: String, + style: TextAreaStyle, + area: Option, pub active: bool, } +#[derive(Clone)] +pub enum TextAreaStyle { + Block, SingleLine +} + impl StatefulWidget for TextArea { type State = TextArea; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) @@ -23,21 +31,57 @@ impl StatefulWidget for TextArea { Self: Sized { let input_value = state.input.value().to_string(); - let block = Block::default() - .borders(Borders::ALL) - .title(state.title.clone()); - let paragraph = Paragraph::new(Text::from(input_value)) - .block(block); - paragraph.render(area, buf); + let title = self.title.clone(); + if matches!(self.style, TextAreaStyle::Block) { + let block = Block::default() + .borders(Borders::ALL) + .title(title); + let paragraph = Paragraph::new(Text::from(input_value)) + .block(block); + paragraph.render(area, buf); + } + else if matches!(self.style, TextAreaStyle::SingleLine) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(10), + Constraint::Fill(0), + ]) + .split(area); + let text = Text::from(self.title); + let line = Paragraph::new(text); + let input_text = Text::from(input_value) + .fg(Color::White); + let paragraph = Paragraph::new(input_text) + .bg(Color::Green); + state.area = Some(chunks[1]); + line.render(chunks[0], buf); + paragraph.render(chunks[1], buf); + } + } +} + +impl HasScreenCursor for TextArea { + fn screen_cursor(&self) -> Option<(u16, u16)> { + if self.area.is_none() { + return None; + } + let area = self.area.unwrap(); + let width = area.width.max(3) - 3; + let scroll = self.input.visual_scroll(width as usize); + let x = (self.input.visual_cursor().max(scroll) - scroll) as u16; + Some((area.x + x, area.y)) } } impl TextArea { - pub fn new(title: &str, placeholder_text: &str) -> Self { + pub fn new(title: &str, placeholder_text: &str, style: Option) -> Self { Self { input: Input::new(placeholder_text.to_string()), title: title.to_string(), active: false, + style: style.unwrap_or(TextAreaStyle::SingleLine), + area: None, } } diff --git a/src/widgets/popups/folder.rs b/src/widgets/popups/folder.rs index 4b61bd1..743803e 100644 --- a/src/widgets/popups/folder.rs +++ b/src/widgets/popups/folder.rs @@ -2,7 +2,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; use ratatui::prelude::{StatefulWidget, Widget}; use ratatui::widgets::{Block, Borders}; -use crate::widgets::components::textarea::TextArea; +use crate::widgets::components::TextArea; #[derive(Clone)] pub struct AddFolderPopup { @@ -11,7 +11,7 @@ pub struct AddFolderPopup { impl AddFolderPopup { pub fn new() -> Self { - let mut textarea = TextArea::new("Folder Path", ""); + let mut textarea = TextArea::new("Folder Path", "", None); textarea.active = true; Self { textarea @@ -38,7 +38,7 @@ impl StatefulWidget for AddFolderPopup { let chunks = Layout::default() .direction(Direction::Vertical) .constraints(vec![ - Constraint::Min(2) + Constraint::Length(1) ]) .split(popup_area.inner(Margin::new(1, 1))); self.textarea.render(chunks[0], buf, &mut state.textarea); diff --git a/src/widgets/views/main_view.rs b/src/widgets/views/main_view.rs index 695f3a5..b343486 100644 --- a/src/widgets/views/main_view.rs +++ b/src/widgets/views/main_view.rs @@ -1,14 +1,16 @@ use std::any::Any; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::event::KeyCode::Char; +use rat_cursor::HasScreenCursor; use ratatui::buffer::Buffer; +use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::prelude::{Color, Line, Span, Style, Text, Widget}; use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidget}; use crate::config::types::ApplicationConfig; use crate::constants::APP_CONIFG_FILE_PATH; use crate::widgets::popups::folder::AddFolderPopup; -use crate::widgets::views::{AppStatus, View}; +use crate::widgets::views::{View}; pub struct MainView { app_config: ApplicationConfig, @@ -18,7 +20,14 @@ pub struct MainView { #[derive(Debug)] pub struct MainViewState { popup: Option>, - status: AppStatus, + status: Status, +} + +#[derive(Debug, Clone, Copy)] +enum Status { + Running, + Exiting, + Popup } impl MainView { @@ -26,28 +35,25 @@ impl MainView { Self { state: MainViewState { popup: None, - status: AppStatus::Running + status: Status::Running }, - app_config: app_conf.clone(), + app_config: app_conf.clone() } } fn quit(&mut self) -> color_eyre::Result<()> { if self.state.popup.is_none() { - self.state.status = AppStatus::Exiting; + self.state.status = Status::Exiting; self.app_config .clone() .write_to_file(&APP_CONIFG_FILE_PATH.to_path_buf())?; } - else { - self.state.popup = None; - } Ok(()) } fn folder_popup(&mut self) { self.state.popup = Some(Box::new(AddFolderPopup::new())); - self.state.status = AppStatus::Input; + self.state.status = Status::Popup; } } @@ -61,10 +67,11 @@ impl View for MainView { } fn handle_key_input(&mut self, key: &KeyEvent) -> color_eyre::Result<()> { - if matches!(self.state.status, AppStatus::Input) && matches!(key.code, KeyCode::Esc) { - self.state.status = AppStatus::Running; + if matches!(self.state.status, Status::Popup) && matches!(key.code, KeyCode::Esc) { + self.state.status = Status::Running; + self.state.popup = None; } - if matches!(key.kind, KeyEventKind::Press) && !matches!(self.state.status, AppStatus::Input) { + if !matches!(self.state.status, Status::Popup) && matches!(key.kind, KeyEventKind::Press) { match key.code { Char('q') => self.quit()?, Char('a') => self.folder_popup(), @@ -75,7 +82,7 @@ impl View for MainView { } fn is_running(&self) -> bool { - !matches!(self.state.status, AppStatus::Exiting) + !matches!(self.state.status, Status::Exiting) } } @@ -105,6 +112,16 @@ impl StatefulWidget for MainView { } } +impl HasScreenCursor for MainView { + fn screen_cursor(&self) -> Option<(u16, u16)> { + if let Some(popup) = &self.state.popup && + let Some(add_folder) = popup.downcast_ref::() { + return add_folder.textarea.screen_cursor() + } + None + } +} + impl MainView { fn render_game_list(state: &mut MainViewState, area: Rect, buf: &mut Buffer) { let game_list = Block::new() @@ -128,8 +145,8 @@ impl MainView { let mut navigation_text = vec![ Span::styled("(q) quit / (a) add folders", Style::default().fg(Color::Green)), ]; - if matches!(state.status, AppStatus::Input) { - navigation_text[0] = Span::styled("Input Mode", 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); diff --git a/src/widgets/views/mod.rs b/src/widgets/views/mod.rs index 5a9038f..4446cdb 100644 --- a/src/widgets/views/mod.rs +++ b/src/widgets/views/mod.rs @@ -1,14 +1,7 @@ -use std::any::Any; +mod main_view; + use crossterm::event::{Event, KeyEvent}; - -pub mod main_view; - -#[derive(Debug, Clone, Copy)] -pub enum AppStatus { - Running, - Exiting, - Input -} +pub use main_view::MainView; pub trait View { fn handle_input(&mut self, event: &Event) -> color_eyre::Result<()>;