From 9c23889cd686a8f3994afdc4b057f74a9f70a108 Mon Sep 17 00:00:00 2001 From: Alessandro La Conca Date: Sat, 14 Feb 2026 12:29:43 +0100 Subject: [PATCH] feat: use tui-textarea --- .gitignore | 12 +- Cargo.toml | 2 +- src/main.rs | 1 - success-core/Cargo.toml | 4 + success-core/src/app.rs | 19 ++-- success-core/src/handlers.rs | 122 ++++++++------------ success-core/src/notes.rs | 191 +++---------------------------- success-core/src/timer.rs | 19 ++-- success-core/src/types.rs | 210 ++++++++++++----------------------- success-core/src/ui.rs | 138 ++++++++++++----------- success-web/Cargo.toml | 3 +- 11 files changed, 239 insertions(+), 482 deletions(-) diff --git a/.gitignore b/.gitignore index 5b2b9e0..f146b76 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ Cargo.lock *.pdb # End of https://www.toptal.com/developers/gitignore/api/rust -.serena/ archive/ dist/ *.wasm @@ -31,4 +30,13 @@ build/ .vscode/ .idea/ ratzilla/ -.answers/ \ No newline at end of file + +# LLMs +openspec/ +.opencode/ +.github/ +.answers/ +.serena/ +.github/prompts/ +.github/skills/ +.agent/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index f4c8a08..267bfeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,4 @@ ratatui = "0.30" libc = "0.2" clap = { version = "4.4", features = ["derive"] } success-core = { path = "success-core" } -successlib = { git = "https://github.com/Calonca/success-lib", branch = "v0.5.x" } \ No newline at end of file +successlib = { git = "https://github.com/Calonca/success-lib", branch = "v0.5.x" } diff --git a/src/main.rs b/src/main.rs index 76bc81e..c7db60c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -266,7 +266,6 @@ fn open_notes_in_external_editor(state: &mut AppState, archive: &Path) -> Result })?; success_core::notes::refresh_notes_for_selection(state); - state.notes_cursor = state.notes.len(); Ok(()) } diff --git a/success-core/Cargo.toml b/success-core/Cargo.toml index 3b696c6..f88aa09 100644 --- a/success-core/Cargo.toml +++ b/success-core/Cargo.toml @@ -10,8 +10,12 @@ web = [] [dependencies] ratatui = { version = "0.30", default-features = false, features = ["all-widgets"] } +# Fork that updates ratatui from 0.29 to 0.30 to match ratzilla's ratatui version +# and resolve unicode-width version conflict. Original: tui-textarea 0.7.0 uses ratatui 0.29 +tui-textarea = { git = "https://github.com/0xferrous/tui-textarea", rev = "b6bf812d1f5edab4f311f56d405a47341e9423cf", default-features = false, features = ["ratatui"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" fuzzy-matcher = "0.3" successlib = { git = "https://github.com/Calonca/success-lib", branch = "v0.5.x" } + diff --git a/success-core/src/app.rs b/success-core/src/app.rs index 0a8117e..5850686 100644 --- a/success-core/src/app.rs +++ b/success-core/src/app.rs @@ -6,6 +6,7 @@ use crate::notes::refresh_notes_for_selection; use crate::types::*; use crate::ui::build_view_items; use successlib::{Goal, SessionView}; +use tui_textarea::TextArea; /// Central application state, generic over the storage backend. pub struct AppState { @@ -15,14 +16,13 @@ pub struct AppState { pub current_day: NaiveDate, pub selected: usize, pub mode: Mode, - pub search_input: TextInput, + pub search_input: TextArea<'static>, pub search_selected: usize, - pub duration_input: TextInput, - pub quantity_input: TextInput, + pub duration_input: TextArea<'static>, + pub quantity_input: TextArea<'static>, pub timer: Option, pub pending_session: Option, - pub notes: String, - pub notes_cursor: usize, + pub notes_textarea: TextArea<'static>, pub focused_block: FocusedBlock, pub form_state: Option, } @@ -43,14 +43,13 @@ impl AppState { current_day: today, selected: 0, mode: Mode::View, - search_input: TextInput::default(), + search_input: TextArea::default(), search_selected: 0, - duration_input: TextInput::default(), - quantity_input: TextInput::default(), + duration_input: TextArea::default(), + quantity_input: TextArea::default(), timer: None, pending_session: None, - notes: String::new(), - notes_cursor: 0, + notes_textarea: TextArea::default(), focused_block: FocusedBlock::SessionsList, form_state: None, }; diff --git a/success-core/src/handlers.rs b/success-core/src/handlers.rs index e9a7643..fe37a89 100644 --- a/success-core/src/handlers.rs +++ b/success-core/src/handlers.rs @@ -10,6 +10,7 @@ use crate::utils::{ format_duration_suggestion, parse_commands_input, parse_duration, parse_optional_u32, selected_goal_id, }; +use tui_textarea::TextArea; pub fn handle_view_key(state: &mut AppState, key: &AppKeyEvent) { match key.code { @@ -53,7 +54,7 @@ pub fn handle_view_key(state: &mut AppState, key: &AppKeyEvent) { return; } state.mode = Mode::AddSession; - state.search_input.clear(); + clear_single_line_textarea(&mut state.search_input); state.search_selected = 0; } ViewItemKind::AddReward => { @@ -61,7 +62,7 @@ pub fn handle_view_key(state: &mut AppState, key: &AppKeyEvent) { return; } state.mode = Mode::AddReward; - state.search_input.clear(); + clear_single_line_textarea(&mut state.search_input); state.search_selected = 0; } ViewItemKind::RunningTimer => {} @@ -97,28 +98,28 @@ pub fn shift_day(state: &mut AppState, delta: i64) { } pub fn handle_search_key(state: &mut AppState, key: &AppKeyEvent) { - if state.search_input.handle_key(key) { + if handle_single_line_textarea_key(&mut state.search_input, key) { state.search_selected = 0; return; } match key.code { AppKeyCode::Esc => { state.mode = Mode::View; - state.search_input.clear(); + clear_single_line_textarea(&mut state.search_input); state.search_selected = 0; } AppKeyCode::Enter => { let results = search_results(state); if let Some((_, result)) = results.get(state.search_selected) { - state.search_input.clear(); + clear_single_line_textarea(&mut state.search_input); state.search_selected = 0; match result { SearchResult::Create { name, is_reward } => { state.form_state = Some(FormState { current_field: FormField::GoalName, - goal_name: TextInput::from_string(name.clone()), - quantity_name: TextInput::default(), - commands: TextInput::default(), + goal_name: single_line_textarea_from_string(name.clone()), + quantity_name: TextArea::default(), + commands: TextArea::default(), is_reward: *is_reward, }); state.mode = Mode::GoalForm; @@ -140,7 +141,7 @@ pub fn handle_search_key(state: &mut AppState, key: &AppKeyEvent) { suggestion = Some(format_duration_suggestion(duration_mins)); } let suggestion = suggestion.unwrap_or_else(|| "25m".to_string()); - state.duration_input = TextInput::from_string(suggestion); + state.duration_input = single_line_textarea_from_string(suggestion); state.mode = Mode::DurationInput { is_reward: matches!(state.mode, Mode::AddReward), goal_name: goal.name.clone(), @@ -177,7 +178,7 @@ pub fn handle_form_key(state: &mut AppState, key: &AppKeyEvent) { FormField::Commands => &mut form.commands, }; - if field.handle_key(key) { + if handle_single_line_textarea_key(field, key) { return; } @@ -201,16 +202,20 @@ pub fn handle_form_key(state: &mut AppState, key: &AppKeyEvent) { }; } AppKeyCode::Enter => { - let name = form.goal_name.value.trim().to_string(); + let goal_name_input = single_line_textarea_value(&form.goal_name); + let name = goal_name_input.trim().to_string(); if name.is_empty() { return; } - let commands = parse_commands_input(&form.commands.value); - let quantity_name = if form.quantity_name.value.trim().is_empty() { + let commands_input = single_line_textarea_value(&form.commands); + let quantity_input = single_line_textarea_value(&form.quantity_name); + + let commands = parse_commands_input(&commands_input); + let quantity_name = if quantity_input.trim().is_empty() { None } else { - Some(form.quantity_name.value.trim().to_string()) + Some(quantity_input.trim().to_string()) }; let is_reward = form.is_reward; @@ -225,7 +230,7 @@ pub fn handle_form_key(state: &mut AppState, key: &AppKeyEvent) { state.goals.push(created.clone()); state.form_state = None; - state.duration_input = TextInput::from_string("25m".to_string()); + state.duration_input = single_line_textarea_from_string("25m".to_string()); state.mode = Mode::DurationInput { is_reward, goal_name: created.name.clone(), @@ -237,12 +242,12 @@ pub fn handle_form_key(state: &mut AppState, key: &AppKeyEvent) { } pub fn handle_duration_key(state: &mut AppState, key: &AppKeyEvent) { - if state.duration_input.handle_key(key) { + if handle_single_line_textarea_key(&mut state.duration_input, key) { return; } match key.code { AppKeyCode::Esc => { - state.duration_input.clear(); + clear_single_line_textarea(&mut state.duration_input); state.mode = Mode::View; } AppKeyCode::Enter => { @@ -254,7 +259,8 @@ pub fn handle_duration_key(state: &mut AppState, key: &AppKeyEvent) { } => (*is_reward, goal_name.clone(), *goal_id), _ => return, }; - let secs = parse_duration(&state.duration_input.value).unwrap_or(25 * 60); + let duration_value = single_line_textarea_value(&state.duration_input); + let secs = parse_duration(&duration_value).unwrap_or(25 * 60); start_timer(state, goal_name, goal_id, secs as u32, is_reward); } _ => {} @@ -266,13 +272,13 @@ pub fn handle_quantity_done_key(state: &mut AppState, key: &AppKeyEvent) { return; } - if state.quantity_input.handle_key(key) { + if handle_single_line_textarea_key(&mut state.quantity_input, key) { return; } match key.code { AppKeyCode::Esc => { - state.quantity_input.clear(); + clear_single_line_textarea(&mut state.quantity_input); if let Some(pending) = state.pending_session.take() { finalize_session(state, pending, None); } else { @@ -280,11 +286,12 @@ pub fn handle_quantity_done_key(state: &mut AppState, key: &AppKeyEvent) { } } AppKeyCode::Enter => { - let qty = parse_optional_u32(&state.quantity_input.value); + let quantity_value = single_line_textarea_value(&state.quantity_input); + let qty = parse_optional_u32(&quantity_value); if let Some(pending) = state.pending_session.take() { finalize_session(state, pending, qty); } - state.quantity_input.clear(); + clear_single_line_textarea(&mut state.quantity_input); } _ => {} } @@ -295,73 +302,36 @@ pub fn handle_timer_key(state: &mut AppState, key: &AppKeyEvent) { } pub fn handle_notes_key(state: &mut AppState, key: &AppKeyEvent) { - if key.ctrl { - match key.code { - AppKeyCode::Left => crate::notes::move_notes_cursor_word_left(state), - AppKeyCode::Right => crate::notes::move_notes_cursor_word_right(state), - _ => {} - } - return; - } - match key.code { AppKeyCode::Esc => { save_notes_for_selection(state); state.mode = Mode::View; state.focused_block = FocusedBlock::SessionsList; } - AppKeyCode::Backspace => { - if state.notes_cursor > 0 { - let prev_len = state - .notes - .get(..state.notes_cursor) - .and_then(|s| s.chars().last()) - .map(|c| c.len_utf8()) - .unwrap_or(1); - let start = state.notes_cursor - prev_len; - state.notes.replace_range(start..state.notes_cursor, ""); - state.notes_cursor = start; - save_notes_for_selection(state); - } - } - AppKeyCode::Enter => { - let insert_at = state.notes_cursor; - state.notes.insert(insert_at, '\n'); - state.notes_cursor += 1; - save_notes_for_selection(state); - } - AppKeyCode::Tab => { - let insert_at = state.notes_cursor; - state.notes.insert_str(insert_at, " "); - state.notes_cursor += 4; - save_notes_for_selection(state); - } - AppKeyCode::Char(c) => { - if !key.ctrl { - let insert_at = state.notes_cursor; - state.notes.insert(insert_at, c); - state.notes_cursor += c.len_utf8(); + _ => { + let Some(input) = app_key_to_textarea_input(key, true) else { + return; + }; + + state.notes_textarea.input(input); + + if matches!( + key.code, + AppKeyCode::Char(_) + | AppKeyCode::Backspace + | AppKeyCode::Delete + | AppKeyCode::Enter + | AppKeyCode::Tab + ) { save_notes_for_selection(state); } } - AppKeyCode::Left => { - crate::notes::move_notes_cursor_left(state); - } - AppKeyCode::Right => { - crate::notes::move_notes_cursor_right(state); - } - AppKeyCode::Up => { - crate::notes::move_notes_cursor_vert(state, -1); - } - AppKeyCode::Down => { - crate::notes::move_notes_cursor_vert(state, 1); - } - _ => {} } } pub fn search_results(state: &AppState) -> Vec<(String, SearchResult)> { - let q = state.search_input.value.trim(); + let query = single_line_textarea_value(&state.search_input); + let q = query.trim(); let is_reward = matches!(state.mode, Mode::AddReward); let goals = successlib::search_goals( diff --git a/success-core/src/notes.rs b/success-core/src/notes.rs index a669e51..01e6176 100644 --- a/success-core/src/notes.rs +++ b/success-core/src/notes.rs @@ -1,192 +1,29 @@ use crate::app::AppState; use crate::utils::selected_goal_id; +use tui_textarea::{CursorMove, TextArea}; + +fn notes_to_textarea(notes: &str) -> TextArea<'static> { + let mut textarea = TextArea::from(notes.split('\n')); + textarea.set_tab_length(4); + textarea.move_cursor(CursorMove::Bottom); + textarea.move_cursor(CursorMove::End); + textarea +} pub fn refresh_notes_for_selection(state: &mut AppState) { if let Some(goal_id) = selected_goal_id(state) { - state.notes = - successlib::get_note(state.archive_path.clone(), goal_id).unwrap_or_default(); - state.notes_cursor = state.notes.len(); + let notes = successlib::get_note(state.archive_path.clone(), goal_id).unwrap_or_default(); + state.notes_textarea = notes_to_textarea(¬es); } else { - state.notes.clear(); - state.notes_cursor = 0; + state.notes_textarea = TextArea::default(); + state.notes_textarea.set_tab_length(4); } } /// Save the notes for the currently selected goal. pub fn save_notes_for_selection(state: &mut AppState) { if let Some(goal_id) = selected_goal_id(state) { - let content = state.notes.clone(); + let content = state.notes_textarea.lines().join("\n"); let _ = successlib::edit_note(state.archive_path.clone(), goal_id, content); } } - -pub fn notes_cursor_line_col(state: &AppState) -> (usize, usize) { - let mut line = 0usize; - let mut col = 0usize; - let mut seen = 0usize; - for (idx, ch) in state.notes.char_indices() { - if idx >= state.notes_cursor { - break; - } - if ch == '\n' { - line += 1; - col = 0; - } else { - col += 1; - } - seen = idx + ch.len_utf8(); - } - if state.notes_cursor > seen { - let remaining = &state.notes[seen..state.notes_cursor]; - for ch in remaining.chars() { - if ch == '\n' { - line += 1; - col = 0; - } else { - col += 1; - } - } - } - (line, col) -} - -pub fn line_starts(notes: &str) -> Vec { - let mut starts = vec![0]; - for (idx, ch) in notes.char_indices() { - if ch == '\n' { - starts.push(idx + ch.len_utf8()); - } - } - starts -} - -pub fn move_notes_cursor_left(state: &mut AppState) { - if state.notes_cursor == 0 { - return; - } - if let Some((idx, _)) = state - .notes - .char_indices() - .take_while(|(i, _)| *i < state.notes_cursor) - .last() - { - state.notes_cursor = idx; - } else { - state.notes_cursor = 0; - } -} - -pub fn move_notes_cursor_right(state: &mut AppState) { - let len = state.notes.len(); - if state.notes_cursor >= len { - state.notes_cursor = len; - return; - } - if let Some((idx, ch)) = state - .notes - .char_indices() - .find(|(i, _)| *i >= state.notes_cursor) - { - state.notes_cursor = idx + ch.len_utf8(); - } else { - state.notes_cursor = len; - } -} - -pub fn move_notes_cursor_word_left(state: &mut AppState) { - if state.notes_cursor == 0 { - return; - } - let mut idx = state.notes_cursor; - while idx > 0 { - if let Some(ch) = state.notes[..idx].chars().next_back() { - if ch.is_whitespace() { - idx = idx.saturating_sub(ch.len_utf8()); - } else { - break; - } - } else { - break; - } - } - while idx > 0 { - if let Some(ch) = state.notes[..idx].chars().next_back() { - if !ch.is_whitespace() { - idx = idx.saturating_sub(ch.len_utf8()); - } else { - break; - } - } else { - break; - } - } - state.notes_cursor = idx; -} - -pub fn move_notes_cursor_word_right(state: &mut AppState) { - let len = state.notes.len(); - let mut idx = state.notes_cursor; - if idx >= len { - return; - } - while idx < len { - if let Some(ch) = state.notes[idx..].chars().next() { - if ch.is_whitespace() { - idx += ch.len_utf8(); - } else { - break; - } - } else { - break; - } - } - while idx < len { - if let Some(ch) = state.notes[idx..].chars().next() { - if !ch.is_whitespace() { - idx += ch.len_utf8(); - } else { - break; - } - } else { - break; - } - } - state.notes_cursor = idx; -} - -pub fn move_notes_cursor_vert(state: &mut AppState, delta: isize) { - let starts = line_starts(&state.notes); - let (line, col) = notes_cursor_line_col(state); - let new_line = line as isize + delta; - if new_line < 0 || new_line as usize >= starts.len() { - return; - } - let new_line = new_line as usize; - let line_start = starts[new_line]; - let line_end = if new_line + 1 < starts.len() { - starts[new_line + 1].saturating_sub(1) - } else { - state.notes.len() - }; - let line_len = line_end.saturating_sub(line_start); - let target_col = col.min(line_len); - let mut byte = line_start; - let mut remaining = target_col; - for (idx, ch) in state.notes[line_start..].char_indices() { - if remaining == 0 { - byte = line_start + idx; - break; - } - if idx + line_start >= line_end { - byte = line_end; - break; - } - remaining = remaining.saturating_sub(1); - byte = line_start + idx + ch.len_utf8(); - } - if remaining == 0 { - state.notes_cursor = byte; - } else { - state.notes_cursor = line_end; - } -} diff --git a/success-core/src/timer.rs b/success-core/src/timer.rs index 32bb5b1..e759517 100644 --- a/success-core/src/timer.rs +++ b/success-core/src/timer.rs @@ -45,7 +45,7 @@ pub fn finish_timer(state: &mut AppState) { if needs_quantity { state.pending_session = Some(pending); - state.quantity_input.clear(); + clear_single_line_textarea(&mut state.quantity_input); state.mode = Mode::QuantityDoneInput { goal_name: timer.label, quantity_name, @@ -71,8 +71,11 @@ pub fn start_timer( let today = Local::now().date_naive(); if state.current_day != today { state.current_day = today; - state.nodes = successlib::list_day_sessions(state.archive_path.clone(), today.format("%Y-%m-%d").to_string()) - .unwrap_or_default(); + state.nodes = successlib::list_day_sessions( + state.archive_path.clone(), + today.format("%Y-%m-%d").to_string(), + ) + .unwrap_or_default(); state.selected = build_view_items(state, 20).len().saturating_sub(1); refresh_notes_for_selection(state); } @@ -80,8 +83,7 @@ pub fn start_timer( let started_at = Utc::now(); // Append session start header to notes - let mut note = - successlib::get_note(state.archive_path.clone(), goal_id).unwrap_or_default(); + let mut note = successlib::get_note(state.archive_path.clone(), goal_id).unwrap_or_default(); let start_local = started_at.with_timezone(&Local); let start_stamp = start_local.format("%Y-%m-%d %H:%M"); note.push_str(&format!("---\n{start_stamp}\n")); @@ -123,8 +125,11 @@ pub fn finalize_session(state: &mut AppState, pending: PendingSession, quantity: .map(|dt| dt.with_timezone(&Local).date_naive()) .unwrap_or_else(|| Local::now().date_naive()); if state.current_day == timer_day { - state.nodes = successlib::list_day_sessions(state.archive_path.clone(), timer_day.format("%Y-%m-%d").to_string()) - .unwrap_or_default(); + state.nodes = successlib::list_day_sessions( + state.archive_path.clone(), + timer_day.format("%Y-%m-%d").to_string(), + ) + .unwrap_or_default(); let items = build_view_items(state, 20); state.selected = items.len().saturating_sub(1); refresh_notes_for_selection(state); diff --git a/success-core/src/types.rs b/success-core/src/types.rs index 14aeb02..e81990a 100644 --- a/success-core/src/types.rs +++ b/success-core/src/types.rs @@ -1,158 +1,86 @@ use chrono::{DateTime, Utc}; +use tui_textarea::{CursorMove, Input, Key, TextArea}; use crate::key_event::{AppKeyCode, AppKeyEvent}; use successlib::Goal; -// ── TextInput ──────────────────────────────────────────────────────────── +// ── Single-line TextArea helpers ──────────────────────────────────────── -#[derive(Debug, Clone, Default)] -pub struct TextInput { - pub value: String, - pub cursor: usize, +pub fn single_line_textarea_from_string(value: String) -> TextArea<'static> { + let mut textarea = TextArea::from([value]); + textarea.move_cursor(CursorMove::End); + textarea } -impl TextInput { - pub fn new(value: String) -> Self { - let len = value.chars().count(); - Self { value, cursor: len } - } +pub fn single_line_textarea_value(textarea: &TextArea<'_>) -> String { + textarea.lines().join("") +} - pub fn from_string(value: String) -> Self { - Self::new(value) - } +pub fn clear_single_line_textarea(textarea: &mut TextArea<'static>) { + *textarea = TextArea::default(); +} + +/// Returns true if the key was consumed. +pub fn handle_single_line_textarea_key( + textarea: &mut TextArea<'static>, + key: &AppKeyEvent, +) -> bool { + let Some(input) = app_key_to_textarea_input(key, false) else { + return false; + }; + + let consumed = textarea.input(input); + consumed + || matches!( + key.code, + AppKeyCode::Left | AppKeyCode::Right | AppKeyCode::Home | AppKeyCode::End + ) +} - /// Returns true if the key was consumed. - pub fn handle_key(&mut self, key: &AppKeyEvent) -> bool { +pub fn app_key_to_textarea_input(key: &AppKeyEvent, multiline: bool) -> Option { + if key.ctrl { match key.code { - AppKeyCode::Char(c) if !key.ctrl && !key.alt => { - self.insert_char(c); - true - } AppKeyCode::Backspace => { - self.delete_char_back(); - true + return Some(Input { + key: Key::Backspace, + ctrl: false, + alt: true, + shift: key.shift, + }); } AppKeyCode::Delete => { - self.delete_char_forward(); - true - } - AppKeyCode::Left => { - if key.ctrl { - self.move_word_left(); - } else { - self.move_left(); - } - true - } - AppKeyCode::Right => { - if key.ctrl { - self.move_word_right(); - } else { - self.move_right(); - } - true - } - AppKeyCode::Home => { - self.cursor = 0; - true - } - AppKeyCode::End => { - self.cursor = self.value.chars().count(); - true - } - _ => false, - } - } - - pub fn insert_char(&mut self, c: char) { - if self.cursor >= self.value.chars().count() { - self.value.push(c); - self.cursor += 1; - } else { - let mut result = String::new(); - for (i, ch) in self.value.chars().enumerate() { - if i == self.cursor { - result.push(c); - } - result.push(ch); - } - self.value = result; - self.cursor += 1; - } - } - - pub fn delete_char_back(&mut self) { - if self.cursor > 0 { - let mut result = String::new(); - for (i, ch) in self.value.chars().enumerate() { - if i != self.cursor - 1 { - result.push(ch); - } - } - self.value = result; - self.cursor -= 1; - } - } - - pub fn delete_char_forward(&mut self) { - if self.cursor < self.value.chars().count() { - let mut result = String::new(); - for (i, ch) in self.value.chars().enumerate() { - if i != self.cursor { - result.push(ch); - } + return Some(Input { + key: Key::Delete, + ctrl: false, + alt: true, + shift: key.shift, + }); } - self.value = result; - } - } - - pub fn move_left(&mut self) { - if self.cursor > 0 { - self.cursor -= 1; - } - } - - pub fn move_right(&mut self) { - if self.cursor < self.value.chars().count() { - self.cursor += 1; - } - } - - pub fn move_word_left(&mut self) { - if self.cursor == 0 { - return; - } - let chars: Vec = self.value.chars().collect(); - let mut idx = self.cursor; - while idx > 0 && idx <= chars.len() && chars[idx - 1].is_whitespace() { - idx -= 1; - } - while idx > 0 && idx <= chars.len() && !chars[idx - 1].is_whitespace() { - idx -= 1; - } - self.cursor = idx; - } - - pub fn move_word_right(&mut self) { - let chars: Vec = self.value.chars().collect(); - let len = chars.len(); - if self.cursor >= len { - return; - } - let mut idx = self.cursor; - while idx < len && !chars[idx].is_whitespace() { - idx += 1; + _ => {} } - while idx < len && chars[idx].is_whitespace() { - idx += 1; - } - self.cursor = idx; } - pub fn clear(&mut self) { - self.value.clear(); - self.cursor = 0; - } + let mapped = match key.code { + AppKeyCode::Char(c) => Key::Char(c), + AppKeyCode::Backspace => Key::Backspace, + AppKeyCode::Delete => Key::Delete, + AppKeyCode::Left => Key::Left, + AppKeyCode::Right => Key::Right, + AppKeyCode::Home => Key::Home, + AppKeyCode::End => Key::End, + AppKeyCode::Up if multiline => Key::Up, + AppKeyCode::Down if multiline => Key::Down, + AppKeyCode::Enter if multiline => Key::Enter, + AppKeyCode::Tab if multiline => Key::Tab, + _ => return None, + }; + + Some(Input { + key: mapped, + ctrl: key.ctrl, + alt: key.alt, + shift: key.shift, + }) } // ── Enums ──────────────────────────────────────────────────────────────── @@ -184,12 +112,12 @@ pub enum FormField { Commands, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Default)] pub struct FormState { pub current_field: FormField, - pub goal_name: TextInput, - pub quantity_name: TextInput, - pub commands: TextInput, + pub goal_name: TextArea<'static>, + pub quantity_name: TextArea<'static>, + pub commands: TextArea<'static>, pub is_reward: bool, } diff --git a/success-core/src/ui.rs b/success-core/src/ui.rs index a5f9287..72b3c97 100644 --- a/success-core/src/ui.rs +++ b/success-core/src/ui.rs @@ -1,16 +1,16 @@ use chrono::Local; -use ratatui::layout::{Constraint, Direction, Layout, Position}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph}; use crate::app::AppState; use crate::handlers::search_results; -use crate::notes::notes_cursor_line_col; use crate::style; use crate::types::*; use crate::utils::*; use successlib::{SessionKind, SessionView}; +use tui_textarea::TextArea; // ── View items ─────────────────────────────────────────────────────────── @@ -310,25 +310,15 @@ pub fn ui(f: &mut ratatui::Frame, state: &AppState, header_text: &str) { )); if selected_goal_id(state).is_some() { - let (cursor_line, cursor_col) = notes_cursor_line_col(state); - let view_height = body_chunks[1].height.max(1) as usize; - let desired_mid = view_height / 2; - let offset = cursor_line.saturating_sub(desired_mid); - let offset_u16 = offset.min(u16::MAX as usize) as u16; - let notes_para = Paragraph::new(state.notes.clone()) - .block(notes_block) - .style(dimmed) - .scroll((offset_u16, 0)); - f.render_widget(notes_para, body_chunks[1]); + let notes_inner = notes_block.inner(body_chunks[1]); + f.render_widget(notes_block, body_chunks[1]); if matches!(state.mode, Mode::NotesEdit) { - let visible_line = cursor_line - .saturating_sub(offset) - .min(view_height.saturating_sub(1)); - let cursor_y = body_chunks[1].y + visible_line as u16; - let cursor_x = body_chunks[1].x - + cursor_col.min(body_chunks[1].width.saturating_sub(1) as usize) as u16; - f.set_cursor_position(Position::new(cursor_x + 1, cursor_y + 1)); + f.render_widget(&state.notes_textarea, notes_inner); + } else { + let notes_content = state.notes_textarea.lines().join("\n"); + let notes_para = Paragraph::new(notes_content).style(dimmed); + f.render_widget(notes_para, notes_inner); } } else { let notes_para = Paragraph::new("Select a task to view notes") @@ -345,6 +335,43 @@ pub fn ui(f: &mut ratatui::Frame, state: &AppState, header_text: &str) { // ── Dialogs ────────────────────────────────────────────────────────────── +fn render_prompted_textarea_line( + f: &mut ratatui::Frame, + area: Rect, + prompt: &str, + textarea: &TextArea<'_>, +) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(prompt.len() as u16), Constraint::Min(1)]) + .split(area); + f.render_widget(Paragraph::new(prompt), chunks[0]); + f.render_widget(textarea, chunks[1]); +} + +fn render_labeled_form_field( + f: &mut ratatui::Frame, + area: Rect, + prefix: &str, + style: Style, + textarea: &TextArea<'_>, + is_active: bool, +) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(prefix.len() as u16), Constraint::Min(1)]) + .split(area); + f.render_widget(Paragraph::new(prefix).style(style), chunks[0]); + if is_active { + f.render_widget(textarea, chunks[1]); + } else { + f.render_widget( + Paragraph::new(single_line_textarea_value(textarea)).style(style), + chunks[1], + ); + } +} + fn render_goal_selector_dialog(f: &mut ratatui::Frame, state: &AppState) { if !matches!(state.mode, Mode::AddSession | Mode::AddReward) { return; @@ -376,9 +403,7 @@ fn render_goal_selector_dialog(f: &mut ratatui::Frame, state: &AppState) { ]) .split(inner); - let input_line = format!("> {}", state.search_input.value); - let input_para = Paragraph::new(input_line); - f.render_widget(input_para, dialog_chunks[0]); + render_prompted_textarea_line(f, dialog_chunks[0], "> ", &state.search_input); let results = search_results(state); let list_items: Vec = results @@ -404,13 +429,6 @@ fn render_goal_selector_dialog(f: &mut ratatui::Frame, state: &AppState) { .style(Style::default().fg(style::GRAY_DIM)), dialog_chunks[2], ); - - let cursor_x = dialog_chunks[0].x + 2 + state.search_input.cursor as u16; - let cursor_y = dialog_chunks[0].y; - f.set_cursor_position(Position::new( - cursor_x.min(dialog_chunks[0].x + dialog_chunks[0].width.saturating_sub(1)), - cursor_y, - )); } fn render_goal_form_dialog(f: &mut ratatui::Frame, state: &AppState) { @@ -476,8 +494,14 @@ fn render_goal_form_dialog(f: &mut ratatui::Frame, state: &AppState) { } else { Style::default() }; - let name_line = format!("{}{}", name_prefix, form.goal_name.value); - f.render_widget(Paragraph::new(name_line).style(name_style), layout[0]); + render_labeled_form_field( + f, + layout[0], + name_prefix, + name_style, + &form.goal_name, + form.current_field == FormField::GoalName, + ); let qty_prefix = "Quantity name (optional): "; let qty_style = if form.current_field == FormField::Quantity { @@ -485,8 +509,14 @@ fn render_goal_form_dialog(f: &mut ratatui::Frame, state: &AppState) { } else { Style::default() }; - let qty_line = format!("{}{}", qty_prefix, form.quantity_name.value); - f.render_widget(Paragraph::new(qty_line).style(qty_style), layout[1]); + render_labeled_form_field( + f, + layout[1], + qty_prefix, + qty_style, + &form.quantity_name, + form.current_field == FormField::Quantity, + ); let cmd_prefix = "Commands (optional, separated by ;): "; let cmd_style = if form.current_field == FormField::Commands { @@ -494,8 +524,14 @@ fn render_goal_form_dialog(f: &mut ratatui::Frame, state: &AppState) { } else { Style::default() }; - let cmd_line = format!("{}{}", cmd_prefix, form.commands.value); - f.render_widget(Paragraph::new(cmd_line).style(cmd_style), layout[2]); + render_labeled_form_field( + f, + layout[2], + cmd_prefix, + cmd_style, + &form.commands, + form.current_field == FormField::Commands, + ); #[cfg(feature = "web")] let help_text = @@ -507,24 +543,6 @@ fn render_goal_form_dialog(f: &mut ratatui::Frame, state: &AppState) { f.render_widget(help, layout[6]); #[cfg(not(feature = "web"))] f.render_widget(help, layout[5]); - - match form.current_field { - FormField::GoalName => { - let cursor_x = layout[0].x + name_prefix.len() as u16 + form.goal_name.cursor as u16; - let cursor_y = layout[0].y; - f.set_cursor_position(Position::new(cursor_x, cursor_y)); - } - FormField::Quantity => { - let cursor_x = layout[1].x + qty_prefix.len() as u16 + form.quantity_name.cursor as u16; - let cursor_y = layout[1].y; - f.set_cursor_position(Position::new(cursor_x, cursor_y)); - } - FormField::Commands => { - let cursor_x = layout[2].x + cmd_prefix.len() as u16 + form.commands.cursor as u16; - let cursor_y = layout[2].y; - f.set_cursor_position(Position::new(cursor_x, cursor_y)); - } - } } fn render_duration_input_dialog(f: &mut ratatui::Frame, state: &AppState) { @@ -553,17 +571,12 @@ fn render_duration_input_dialog(f: &mut ratatui::Frame, state: &AppState) { ]) .split(inner); - let input_line = format!("> {}", state.duration_input.value); - f.render_widget(Paragraph::new(input_line), layout[0]); + render_prompted_textarea_line(f, layout[0], "> ", &state.duration_input); f.render_widget( Paragraph::new("Enter: start • Esc: cancel").style(Style::default().fg(style::GRAY_DIM)), layout[1], ); - - let cursor_x = inner.x + 2 + state.duration_input.cursor as u16; - let cursor_y = inner.y; - f.set_cursor_position(Position::new(cursor_x, cursor_y)); } fn render_quantity_input_dialog(f: &mut ratatui::Frame, state: &AppState) { @@ -599,15 +612,10 @@ fn render_quantity_input_dialog(f: &mut ratatui::Frame, state: &AppState) { ]) .split(inner); - let input_line = format!("> {}", state.quantity_input.value); - f.render_widget(Paragraph::new(input_line), layout[0]); + render_prompted_textarea_line(f, layout[0], "> ", &state.quantity_input); f.render_widget( Paragraph::new("Enter: confirm • Esc: skip").style(Style::default().fg(style::GRAY_DIM)), layout[1], ); - - let cursor_x = inner.x + 2 + state.quantity_input.cursor as u16; - let cursor_y = inner.y; - f.set_cursor_position(Position::new(cursor_x, cursor_y)); } diff --git a/success-web/Cargo.toml b/success-web/Cargo.toml index 1579680..6c3a0c2 100644 --- a/success-web/Cargo.toml +++ b/success-web/Cargo.toml @@ -10,5 +10,4 @@ serde = { version = "1.0", features = ["derive"] } console_error_panic_hook = "0.1" success-core = { path = "../success-core", features = ["web"] } successlib = { git = "https://github.com/Calonca/success-lib", branch = "v0.5.x" } -# Use this patch to access dynamic font atlas support and fix redraw not triggered when only the cursor position is updated. -ratzilla = { git = "https://github.com/Calonca/ratzilla", branch = "fix/cursor-movement-invisible" } \ No newline at end of file +ratzilla = { git = "https://github.com/ratatui/ratzilla", branch = "main" }