From cd1921001c5360c86a57eb564ac6f092e19bde85 Mon Sep 17 00:00:00 2001 From: Alessandro La Conca Date: Sun, 8 Feb 2026 21:43:51 +0100 Subject: [PATCH] feat: add web version --- .github/workflows/pages.yml | 60 + .gitignore | 14 +- Cargo.toml | 12 +- README.md | 114 ++ src/main.rs | 2408 +++++---------------------------- success-core/Cargo.toml | 17 + success-core/src/app.rs | 84 ++ success-core/src/handlers.rs | 404 ++++++ success-core/src/key_event.rs | 33 + success-core/src/lib.rs | 11 + success-core/src/notes.rs | 192 +++ success-core/src/style.rs | 5 + success-core/src/timer.rs | 132 ++ success-core/src/types.rs | 228 ++++ success-core/src/ui.rs | 613 +++++++++ success-core/src/utils.rs | 238 ++++ success-web/Cargo.toml | 14 + success-web/index.html | 39 + success-web/src/main.rs | 230 ++++ 19 files changed, 2755 insertions(+), 2093 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 README.md create mode 100644 success-core/Cargo.toml create mode 100644 success-core/src/app.rs create mode 100644 success-core/src/handlers.rs create mode 100644 success-core/src/key_event.rs create mode 100644 success-core/src/lib.rs create mode 100644 success-core/src/notes.rs create mode 100644 success-core/src/style.rs create mode 100644 success-core/src/timer.rs create mode 100644 success-core/src/types.rs create mode 100644 success-core/src/ui.rs create mode 100644 success-core/src/utils.rs create mode 100644 success-web/Cargo.toml create mode 100644 success-web/index.html create mode 100644 success-web/src/main.rs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..122247c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,60 @@ +name: Pages + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + name: Build and deploy + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + + - name: Install Trunk + run: cargo install trunk --locked + + - name: Build web app + working-directory: success-web + run: trunk build --release --public-url ./ + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: success-web/dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 9912418..5b2b9e0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,16 @@ Cargo.lock # End of https://www.toptal.com/developers/gitignore/api/rust .serena/ -archive/ \ No newline at end of file +archive/ +dist/ +*.wasm +*.js.map +build/ +.DS_Store +*.swp +*.swo +*~ +.vscode/ +.idea/ +ratzilla/ +.answers/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index a3b8348..2897870 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "success-cli" -version = "0.3.0" +version = "0.3.1" edition = "2021" description = "CLI for achieving goals" @@ -9,11 +9,9 @@ anyhow = "1.0" chrono = { version = "0.4", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -uuid = { version = "1.4", features = ["v4"] } -crossterm = "0.27" -ratatui = "0.26" -serde_yaml = "0.9" -fuzzy-matcher = "0.3" +crossterm = "0.28" +ratatui = "0.30" libc = "0.2" clap = { version = "4.4", features = ["derive"] } -successlib = { git = "https://github.com/Calonca/success-lib", branch = "main" } +success-core = { path = "success-core" } +successlib = { git = "https://github.com/Calonca/success-lib", branch = "v0.5.x" } \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3016edb --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Success CLI + +A terminal-based goal tracking and productivity application built with Rust. + +## Project Structure + +- **`success-cli`** - CLI application (Ratatui-based TUI) +- **`success-core`** - Core business logic and data structures +- **`success-web`** - Web version (Ratzilla/WebAssembly)repository) + +## Getting Started + +### Running the CLI Version + +```bash +# Build and run the CLI application +cargo run --release + +# Or run with a custom archive path +cargo run --release -- --archive /path/to/archive +``` + +The first time you run the CLI, it will prompt you to set an archive location where all your goals and sessions will be stored. + +### Running the Web Version + +The web version is built with Ratzilla (Rust + WebAssembly). + +Web demo (GitHub Pages): https://calonca.github.io/success-cli/ + +**Recommended: Using Trunk** (all-in-one build tool for WASM) + +```bash +# Install trunk +cargo install trunk + +# Build and serve with hot reload (development) +cd success-web +trunk serve + +# Build for production +trunk build --release +``` + +Then open `http://localhost:8080` in your web browser. + +Then open the appropriate URL in your web browser (default is `http://localhost:8080` for trunk, `http://localhost:8000` for others). + +### Running Ratzilla Web Examples + +Ratzilla allows building terminal-themed web applications with Rust and WebAssembly. + +```bash +# Change to the ratzilla directory +cd ratzilla + +# Build and run an example (e.g., demo) +cargo run --example demo --target wasm32-unknown-unknown + +# Other available examples +cargo run --example minimal +cargo run --example demo2 +``` + +For more details on Ratzilla, see [ratzilla/README.md](ratzilla/README.md). + +## Features + +### CLI Application +- **Goal Management**: Create, track, and manage goals +- **Session Tracking**: Log work sessions and rewards +- **Progress Visualization**: View progress with visual progress bars +- **Notes**: Add and edit notes for each goal +- **External Editor**: Edit notes in your preferred text editor (press `E`) +- **Archive Management**: Open archive folder in file manager (press `o`) + +### Key Bindings (CLI) +- `↑↓` - Navigate items +- `←→` - Change day +- `Enter` - Add session/confirm +- `e` - Edit notes (in-app) +- `E` - Edit notes (external editor) +- `o` - Open archive in file manager +- `Esc` - Cancel/exit + +## Building + +```bash +# Build debug version +cargo build + +# Build release version +cargo build --release + +# Run tests +cargo test + +# Format code +cargo fmt + +# Lint code +cargo clippy +``` + +## Configuration + +The CLI stores its configuration at `~/.config/success-cli/config.json` which includes the path to your archive folder. + +## Development + +- Uses `Ratatui` for terminal UI +- Uses `Crossterm` for terminal handling +- Uses `Chrono` for date/time operations +- Modular architecture with core logic separated from UI diff --git a/src/main.rs b/src/main.rs index 90ecc95..76bc81e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,2021 +9,62 @@ use std::process::{Child, Command, Stdio}; use std::time::Duration; use anyhow::{bail, Context, Result}; -use chrono::Duration as ChronoDuration; -use chrono::{DateTime, Local, NaiveDate, Utc}; use clap::Parser; -use crossterm::cursor::{MoveTo, SetCursorStyle}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; -use crossterm::{execute, terminal}; +use crossterm::cursor::SetCursorStyle; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{self, disable_raw_mode, enable_raw_mode}; use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Direction, Layout}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; - -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; -use ratatui::Terminal; -use serde::{Deserialize, Serialize}; -use successlib::{ - add_goal, add_session, edit_note, edit_note_api, get_formatted_session_time_range, get_note, - list_day_sessions, list_goals, list_sessions_between_dates, search_goals, Goal, Session, - SessionKind, -}; -#[cfg(target_os = "macos")] -const FILE_MANAGER_COMMAND: &str = "open"; -#[cfg(target_os = "windows")] -const FILE_MANAGER_COMMAND: &str = "explorer"; -#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -const FILE_MANAGER_COMMAND: &str = "xdg-open"; - -const DEFAULT_EDITOR: &str = "nvim"; - -fn render_goal_form_dialog(f: &mut ratatui::Frame, state: &AppState) { - if let Some(form) = &state.form_state { - let area = centered_rect(80, 70, f.size()); - f.render_widget(ratatui::widgets::Clear, area); - - let title = if form.is_reward { - "Create new reward" - } else { - "Create new goal" - }; - - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(Color::Blue)); - - let inner = block.inner(area); - f.render_widget(block, area); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Goal Name - Constraint::Length(1), // Quantity - Constraint::Length(1), // Commands - Constraint::Length(1), // Spacer - Constraint::Min(1), // Filler - Constraint::Length(1), // Help text - ]) - .split(inner); - - let name_prefix = "Name: "; - let name_style = if form.current_field == FormField::GoalName { - Style::default().fg(Color::Blue) - } 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]); - - let qty_prefix = "Quantity name (optional): "; - let qty_style = if form.current_field == FormField::Quantity { - Style::default().fg(Color::Blue) - } 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]); - - let cmd_prefix = "Commands (optional, separated by ;): "; - let cmd_style = if form.current_field == FormField::Commands { - Style::default().fg(Color::Blue) - } else { - Style::default() - }; - let cmd_line = format!("{}{}", cmd_prefix, form.commands.value); - f.render_widget(Paragraph::new(cmd_line).style(cmd_style), layout[2]); - - let help = Paragraph::new("↑↓/Tab: navigate • Enter: create • Esc: cancel") - .style(Style::default().fg(Color::DarkGray)); - 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(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(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(cursor_x, cursor_y); - } - } - } -} - -fn render_duration_input_dialog(f: &mut ratatui::Frame, state: &AppState) { - if let Mode::DurationInput { ref goal_name, .. } = state.mode { - // Height 4: Border(1) + Input(1) + Help(1) + Border(1) - let area = centered_rect_fixed_height(60, 4, f.size()); - f.render_widget(ratatui::widgets::Clear, area); - - let title = format!("Duration for {} (e.g., 30m, 1h)", goal_name); - - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(Color::Blue)); - - let inner = block.inner(area); - f.render_widget(block, area); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Input - Constraint::Min(1), // Help - ]) - .split(inner); - - let input_line = format!("> {}", state.duration_input.value); - f.render_widget(Paragraph::new(input_line.clone()), layout[0]); - - // Help hint in last line of block - f.render_widget( - Paragraph::new("Enter: start • Esc: cancel") - .style(Style::default().fg(Color::DarkGray)), - layout[1], - ); - - let cursor_x = inner.x + 2 + state.duration_input.cursor as u16; - let cursor_y = inner.y; - f.set_cursor(cursor_x, cursor_y); - } -} - -fn render_quantity_input_dialog(f: &mut ratatui::Frame, state: &AppState) { - if let Mode::QuantityDoneInput { - ref goal_name, - ref quantity_name, - } = state.mode - { - // Height 4 for consistent look - let area = centered_rect_fixed_height(60, 4, f.size()); - f.render_widget(ratatui::widgets::Clear, area); - - let title = if let Some(name) = quantity_name { - format!("{} done for {} (blank to skip)", name, goal_name) - } else { - format!("Quantity done for {} (blank to skip)", goal_name) - }; - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(Color::Blue)); - - let inner = block.inner(area); - f.render_widget(block, area); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Input - Constraint::Min(1), // Help - ]) - .split(inner); - - let input_line = format!("> {}", state.quantity_input.value); - f.render_widget(Paragraph::new(input_line.clone()), layout[0]); - - f.render_widget( - Paragraph::new("Enter: confirm • Esc: skip") - .style(Style::default().fg(Color::DarkGray)), - layout[1], - ); - - let cursor_x = inner.x + 2 + state.quantity_input.cursor as u16; - let cursor_y = inner.y; - f.set_cursor(cursor_x, cursor_y); - } -} - -fn kind_label(kind: SessionKind) -> &'static str { - match kind { - SessionKind::Goal => "session", - SessionKind::Reward => "reward", - } -} - -/// Returns a centered rect of the given percentage size within the parent rect -fn centered_rect( - percent_x: u16, - percent_y: u16, - r: ratatui::layout::Rect, -) -> ratatui::layout::Rect { - use ratatui::layout::{Constraint, Direction, Layout}; - - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] -} - -fn centered_rect_fixed_height( - percent_x: u16, - height: u16, - r: ratatui::layout::Rect, -) -> ratatui::layout::Rect { - use ratatui::layout::{Constraint, Direction, Layout}; - - let vertical_pad = r.height.saturating_sub(height) / 2; - - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(vertical_pad), - Constraint::Length(height), - Constraint::Length(vertical_pad), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] -} - -/// Returns a centered rect of fixed size (width, height) within the parent rect -#[allow(dead_code)] -fn centered_rect_fixed(width: u16, height: u16, r: ratatui::layout::Rect) -> ratatui::layout::Rect { - use ratatui::layout::{Constraint, Direction, Layout}; - - let vertical_padding = r.height.saturating_sub(height) / 2; - let horizontal_padding = r.width.saturating_sub(width) / 2; - - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(vertical_padding), - Constraint::Length(height), - Constraint::Length(vertical_padding), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(horizontal_padding), - Constraint::Length(width), - Constraint::Length(horizontal_padding), - ]) - .split(popup_layout[1])[1] -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct TextInput { - value: String, - cursor: usize, -} - -impl TextInput { - fn new(value: String) -> Self { - let len = value.chars().count(); - Self { value, cursor: len } - } - - fn from_string(value: String) -> Self { - Self::new(value) - } - - fn handle_key(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Char(c) - if !key.modifiers.contains(KeyModifiers::CONTROL) - && !key.modifiers.contains(KeyModifiers::ALT) => - { - self.insert_char(c); - true - } - KeyCode::Backspace => { - self.delete_char_back(); - true - } - KeyCode::Delete => { - self.delete_char_forward(); - true - } - KeyCode::Left => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - self.move_word_left(); - } else { - self.move_left(); - } - true - } - KeyCode::Right => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - self.move_word_right(); - } else { - self.move_right(); - } - true - } - KeyCode::Home => { - self.cursor = 0; - true - } - KeyCode::End => { - self.cursor = self.value.chars().count(); - true - } - _ => false, - } - } - - 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; - } - } - - 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; - } - } - - 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); - } - } - self.value = result; - } - } - - fn move_left(&mut self) { - if self.cursor > 0 { - self.cursor -= 1; - } - } - - fn move_right(&mut self) { - if self.cursor < self.value.chars().count() { - self.cursor += 1; - } - } - - fn move_word_left(&mut self) { - if self.cursor == 0 { - return; - } - let chars: Vec = self.value.chars().collect(); - let mut idx = self.cursor; - // Skip current non-separators if we are at the end of a word? - // Simple logic: skip spaces backwards, then skip non-spaces backwards - 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; - } - - 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; - // Skip current non-separators - while idx < len && !chars[idx].is_whitespace() { - idx += 1; - } - // Skip spaces - while idx < len && chars[idx].is_whitespace() { - idx += 1; - } - self.cursor = idx; - } - - fn clear(&mut self) { - self.value.clear(); - self.cursor = 0; - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CliConfig { - archive: Option, -} - -#[derive(Debug)] -enum Mode { - View, - AddSession, - AddReward, - GoalForm, - QuantityDoneInput { - goal_name: String, - quantity_name: Option, - }, - DurationInput { - is_reward: bool, - goal_name: String, - goal_id: u64, - }, - Timer, - NotesEdit, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum FormField { - #[default] - GoalName, - Quantity, - Commands, -} - -#[derive(Debug, Clone, Default)] -struct FormState { - current_field: FormField, - goal_name: TextInput, - quantity_name: TextInput, - commands: TextInput, - is_reward: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum FocusedBlock { - #[default] - SessionsList, - Notes, -} - -fn is_dialog_open(mode: &Mode) -> bool { - matches!( - mode, - Mode::AddSession - | Mode::AddReward - | Mode::GoalForm - | Mode::QuantityDoneInput { .. } - | Mode::DurationInput { .. } - ) -} - -fn get_block_style(current: FocusedBlock, target: FocusedBlock, mode: &Mode) -> Style { - if !is_dialog_open(mode) && current == target { - Style::default().fg(Color::Blue) - } else { - Style::default() - } -} - -fn get_dimmed_style(mode: &Mode) -> Style { - if is_dialog_open(mode) { - Style::default().fg(Color::DarkGray) - } else { - Style::default() - } -} - -fn get_cursor_style(mode: &Mode) -> SetCursorStyle { - match mode { - Mode::NotesEdit - | Mode::AddSession - | Mode::AddReward - | Mode::GoalForm - | Mode::QuantityDoneInput { .. } - | Mode::DurationInput { .. } => SetCursorStyle::SteadyBlock, - Mode::View | Mode::Timer => SetCursorStyle::SteadyBlock, - } -} - -fn format_mode(mode: &Mode) -> &'static str { - match mode { - Mode::View => "view", - Mode::AddSession => "add-session", - Mode::AddReward => "add-reward", - Mode::GoalForm => "goal-form", - Mode::QuantityDoneInput { .. } => "quantity-done", - Mode::DurationInput { .. } => "duration", - Mode::Timer => "timer", - Mode::NotesEdit => "notes-edit", - } -} - -#[derive(Debug)] -struct AppState { - archive: PathBuf, - goals: Vec, - nodes: Vec, - current_day: NaiveDate, - selected: usize, - mode: Mode, - search_input: TextInput, - search_selected: usize, - duration_input: TextInput, - quantity_input: TextInput, - timer: Option, - pending_session: Option, - notes: String, - notes_cursor: usize, - status: Option, - needs_full_redraw: bool, - focused_block: FocusedBlock, - form_state: Option, -} - -#[derive(Debug)] -struct TimerState { - label: String, - goal_id: u64, - remaining: u64, - total: u64, - is_reward: bool, - spawned: Vec, - started_at: DateTime, -} - -#[derive(Debug, Clone)] -struct PendingSession { - label: String, - goal_id: u64, - total: u64, - is_reward: bool, - started_at: DateTime, -} - -#[derive(Debug)] -struct SpawnedCommand { - command: String, - child: Option, - pgid: i32, -} - -#[derive(Parser, Debug)] -#[command(name = "success-cli")] -#[command(about = "CLI for achieving goals", long_about = None)] -struct Args { - /// Custom archive path (useful for testing) - #[arg(short, long)] - archive: Option, -} - -fn main() -> Result<()> { - let args = Args::parse(); - let archive = resolve_archive_interactive(args.archive.clone())?; - let today = Local::now().date_naive(); - let mut state = AppState { - archive: archive.clone(), - goals: list_goals(&archive, None)?, - nodes: list_day_sessions(&archive, today)?, - current_day: today, - selected: 0, - mode: Mode::View, - search_input: TextInput::default(), - search_selected: 0, - duration_input: TextInput::default(), - quantity_input: TextInput::default(), - timer: None, - - pending_session: None, - notes: String::new(), - notes_cursor: 0, - status: None, - needs_full_redraw: false, - focused_block: FocusedBlock::SessionsList, - form_state: None, - }; - - // Start focused on the most recent item (last in list) if any exist. - state.selected = build_view_items(&state, 20).len().saturating_sub(1); - - refresh_notes_for_selection(&mut state)?; - - // Only persist config if archive wasn't provided via CLI argument - if args.archive.is_none() { - persist_config(&archive).ok(); - } - - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!( - stdout, - terminal::EnterAlternateScreen, - event::EnableMouseCapture, - SetCursorStyle::SteadyBlock - )?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let res = run_app(&mut terminal, &mut state); - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - terminal::LeaveAlternateScreen, - event::DisableMouseCapture, - SetCursorStyle::DefaultUserShape - )?; - terminal.show_cursor()?; - - if let Err(err) = res { - eprintln!("Error: {err}"); - } - Ok(()) -} - -fn run_app( - terminal: &mut Terminal, - state: &mut AppState, -) -> Result<()> { - loop { - if state.needs_full_redraw { - terminal.clear()?; - state.needs_full_redraw = false; - } - if state.timer.is_some() { - tick_timer(state); - } - - terminal.draw(|f| ui(f, state))?; - execute!(terminal.backend_mut(), get_cursor_style(&state.mode))?; - - if event::poll(Duration::from_millis(200))? { - match event::read()? { - Event::Key(key) => { - if handle_key(state, key)? { - break; - } - } - Event::Resize(_, _) => {} - _ => {} - } - } - } - if let Some(timer) = state.timer.as_mut() { - kill_spawned(&mut timer.spawned); - } - Ok(()) -} - -fn render_goal_selector_dialog(f: &mut ratatui::Frame, state: &AppState) { - if !matches!(state.mode, Mode::AddSession | Mode::AddReward) { - return; - } - - let popup_area = centered_rect(80, 70, f.size()); - f.render_widget(ratatui::widgets::Clear, popup_area); - - let prompt = if matches!(state.mode, Mode::AddReward) { - "Choose reward" - } else { - "Choose goal" - }; - - let popup_block = Block::default() - .borders(Borders::ALL) - .title(prompt) - .border_style(Style::default().fg(Color::Blue)); - - let inner = popup_block.inner(popup_area); - f.render_widget(popup_block, popup_area); - - let dialog_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Input - Constraint::Min(1), // List - Constraint::Length(1), // Help - ]) - .split(inner); - - let input_line = format!("> {}", state.search_input.value); - let input_para = Paragraph::new(input_line.clone()); - f.render_widget(input_para, dialog_chunks[0]); - - let results = search_results(state); - let list_items: Vec = results - .iter() - .map(|(label, _)| ListItem::new(Line::from(label.clone()))) - .collect(); - - let mut list_state = ListState::default(); - if !results.is_empty() { - list_state.select(Some(state.search_selected.min(results.len() - 1))); - } - - let list = List::new(list_items).highlight_style( - Style::default() - .fg(Color::Blue) - .add_modifier(Modifier::BOLD), - ); - - f.render_stateful_widget(list, dialog_chunks[1], &mut list_state); - - f.render_widget( - Paragraph::new("Type to search • ↑↓ select • Enter pick • Esc cancel") - .style(Style::default().fg(Color::DarkGray)), - 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( - cursor_x.min(dialog_chunks[0].x + dialog_chunks[0].width.saturating_sub(1)), - cursor_y, - ); -} - -fn ui(f: &mut ratatui::Frame, state: &AppState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(5), // Body - ]) - .split(f.size()); - - let body_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) - .split(chunks[1]); - - let mut header_line = format!( - "Archive: {} (open with 'o') | Mode: {} ", - state.archive.display(), - format_mode(&state.mode), - ); - if let Some(timer) = &state.timer { - header_line.push_str(&format!( - " | Timer: {} ({}s left)", - timer.label, timer.remaining - )); - } - if let Some(status) = &state.status { - header_line.push_str(&format!(" | {status}")); - } - - let dimmed = get_dimmed_style(&state.mode); - - let header = Paragraph::new(header_line) - .block( - Block::default() - .borders(Borders::ALL) - .title("Success CLI") - .style(dimmed), - ) - .style(dimmed); - - f.render_widget(header, chunks[0]); - - let list_width = body_chunks[0].width.saturating_sub(4) as usize; - - let items = build_view_items(state, list_width); - - let is_any_timer_selected = items - .get(state.selected) - .map(|it| it.kind == ViewItemKind::RunningTimer) - .unwrap_or(false); - - let list_items: Vec = items - .iter() - .enumerate() - .map(|(i, item)| { - let is_selected = i == state.selected; - let is_dialog_open = is_dialog_open(&state.mode); - - // Highlight selected item even if dialog is open (as requested), - // but the list itself might be dimmed. Explicit style overrides list style. - // Also highlight ALL timer items if ANY timer item is selected. - // Also highlight the [Insert] line if we are in QuantityDoneInput mode. - let is_quantity_input = matches!(state.mode, Mode::QuantityDoneInput { .. }); - let is_insert_item = matches!( - item.kind, - ViewItemKind::AddSession | ViewItemKind::AddReward - ); - - let should_highlight = is_selected - || (is_any_timer_selected && item.kind == ViewItemKind::RunningTimer) - || (is_quantity_input && is_insert_item); - - let label_style = if should_highlight { - Style::default() - .fg(Color::Blue) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - - let mut spans = vec![Span::styled(item.label.clone(), label_style)]; - - if is_selected && !is_dialog_open { - match item.kind { - ViewItemKind::AddSession => { - spans.push(Span::styled( - " (Enter: add session)", - Style::default().fg(Color::DarkGray), - )); - } - ViewItemKind::AddReward => { - spans.push(Span::styled( - " (Enter: receive reward)", - Style::default().fg(Color::DarkGray), - )); - } - ViewItemKind::Existing(_, _) => { - if state.focused_block == FocusedBlock::SessionsList { - spans.push(Span::styled( - " (e: edit, E: external edit)", - Style::default().fg(Color::DarkGray), - )); - } - } - - _ => {} - } - } - ListItem::new(Line::from(spans)) - }) - .collect(); - let title = format!( - "Sessions of {} (←→ day • ↑↓ move)", - format_day_label(state.current_day) - ); - - let sessions_block = Block::default() - .borders(Borders::ALL) - .title(title) - .style(dimmed) - .border_style(get_block_style( - state.focused_block, - FocusedBlock::SessionsList, - &state.mode, - )); - - let list = List::new(list_items).block(sessions_block).style(dimmed); - - let mut stateful = ratatui::widgets::ListState::default(); - if !items.is_empty() { - stateful.select(Some(state.selected.min(items.len() - 1))); - } - f.render_stateful_widget(list, body_chunks[0], &mut stateful); - - let notes_title = if matches!(state.mode, Mode::NotesEdit) { - "Notes (Esc to stop editing)" - } else { - "Notes" - }; - - let notes_block = Block::default() - .borders(Borders::ALL) - .title(notes_title) - .style(dimmed) - .border_style(get_block_style( - state.focused_block, - FocusedBlock::Notes, - &state.mode, - )); - - 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]); - - 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(cursor_x + 1, cursor_y + 1); - } - } else { - let notes_para = Paragraph::new("Select a task to view notes") - .block(notes_block) - .style(dimmed); - f.render_widget(notes_para, body_chunks[1]); - } - - render_goal_selector_dialog(f, state); - render_goal_form_dialog(f, state); - render_duration_input_dialog(f, state); - render_quantity_input_dialog(f, state); -} - -#[derive(Debug, Clone)] -struct ViewItem { - label: String, - kind: ViewItemKind, -} - -#[derive(Debug, Clone)] -enum SearchResult { - Existing(Goal), - Create { name: String, is_reward: bool }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ViewItemKind { - RunningTimer, - Existing(SessionKind, usize), - AddSession, - AddReward, -} - -fn build_timer_view_items(timer: &TimerState, width: usize) -> Vec { - let started_local = timer.started_at.with_timezone(&Local).format("%H:%M"); - let info_item = ViewItem { - label: format!( - "[*] Running: {} ({}s left) [started {}]", - timer.label, timer.remaining, started_local - ), - kind: ViewItemKind::RunningTimer, - }; - - let pct = if timer.total == 0 { - 0.0 - } else { - 1.0 - (timer.remaining as f32 / timer.total as f32) - }; - let ratio = pct.clamp(0.0, 1.0); - - // Ensure width is at least something reasonable to avoid panic or weirdness - let bar_width = width.max(1); - let filled = (ratio * bar_width as f32) as usize; - let empty = bar_width.saturating_sub(filled); - let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty)); - - let bar_item = ViewItem { - label: bar, - kind: ViewItemKind::RunningTimer, - }; - - vec![info_item, bar_item] -} - -fn build_view_items(state: &AppState, width: usize) -> Vec { - let mut items = Vec::new(); - for (idx, n) in state.nodes.iter().enumerate() { - let prefix = match n.kind { - SessionKind::Goal => "[S]", - SessionKind::Reward => "[R]", - }; - let duration = (n.end_at - n.start_at).num_minutes(); - let times = get_formatted_session_time_range(n); - let unit = goal_quantity_name(state, n.goal_id) - .map(|u| format!(" {u}")) - .unwrap_or_default(); - let qty_label = n - .quantity - .map(|q| format!("{q}{unit} in ")) - .unwrap_or_default(); - items.push(ViewItem { - label: format!("{prefix} {} ({qty_label}{duration}m) [{times}]", n.name), - kind: ViewItemKind::Existing(n.kind, idx), - }); - } - // Only show the running timer if we're viewing today. - if let Some(timer) = &state.timer { - if state.current_day == Local::now().date_naive() { - items.extend(build_timer_view_items(timer, width)); - } - } - - // Only offer add rows when no timer is running AND we are viewing today. - if state.timer.is_none() && state.current_day == Local::now().date_naive() { - if let Mode::QuantityDoneInput { - ref goal_name, - ref quantity_name, - } = state.mode - { - let quantity_name = quantity_name.as_deref().unwrap_or("quantity"); - items.push(ViewItem { - label: format!("[+] Insert {quantity_name} for {goal_name}"), - kind: ViewItemKind::AddSession, - }); - } else if state - .nodes - .last() - .map(|n| n.kind == SessionKind::Goal) - .unwrap_or(false) - { - items.push(ViewItem { - label: "[+] Receive reward".to_string(), - kind: ViewItemKind::AddReward, - }); - } else { - items.push(ViewItem { - label: "[+] Work on new goal".to_string(), - kind: ViewItemKind::AddSession, - }); - } - } - items -} - -fn handle_key(state: &mut AppState, key: KeyEvent) -> Result { - if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) { - return Ok(true); - } - match state.mode { - Mode::View => handle_view_key(state, key), - Mode::AddSession | Mode::AddReward => handle_search_key(state, key), - Mode::GoalForm => handle_form_key(state, key), - Mode::QuantityDoneInput { .. } => handle_quantity_done_key(state, key), - Mode::DurationInput { .. } => handle_duration_key(state, key), - Mode::Timer => handle_timer_key(state, key), - Mode::NotesEdit => handle_notes_key(state, key), - } -} - -fn handle_form_key(state: &mut AppState, key: KeyEvent) -> Result { - let Some(form) = state.form_state.as_mut() else { - state.mode = Mode::View; - return Ok(false); - }; - - let field = match form.current_field { - FormField::GoalName => &mut form.goal_name, - FormField::Quantity => &mut form.quantity_name, - FormField::Commands => &mut form.commands, - }; - - if field.handle_key(key) { - return Ok(false); - } - - match key.code { - KeyCode::Esc => { - state.form_state = None; - state.mode = Mode::View; - } - KeyCode::Up | KeyCode::BackTab => { - form.current_field = match form.current_field { - FormField::GoalName => FormField::Commands, - FormField::Quantity => FormField::GoalName, - FormField::Commands => FormField::Quantity, - }; - } - KeyCode::Down | KeyCode::Tab => { - form.current_field = match form.current_field { - FormField::GoalName => FormField::Quantity, - FormField::Quantity => FormField::Commands, - FormField::Commands => FormField::GoalName, - }; - } - KeyCode::Enter => { - let name = form.goal_name.value.trim(); - if name.is_empty() { - state.status = Some("Goal name cannot be empty".to_string()); - return Ok(false); - } - - let commands = parse_commands_input(&form.commands.value); - let quantity_name = if form.quantity_name.value.trim().is_empty() { - None - } else { - Some(form.quantity_name.value.trim().to_string()) - }; - let is_reward = form.is_reward; - let goal_name = name.to_string(); - - let created = add_goal( - &state.archive, - &goal_name, - is_reward, - commands, - quantity_name, - )?; - state.goals.push(created.clone()); - - state.form_state = None; - state.duration_input = TextInput::from_string("25m".to_string()); - state.mode = Mode::DurationInput { - is_reward, - goal_name: created.name.clone(), - goal_id: created.id, - }; - } - _ => {} - } - Ok(false) -} - -fn handle_view_key(state: &mut AppState, key: KeyEvent) -> Result { - match key.code { - KeyCode::Char('q') => return Ok(true), - KeyCode::Up | KeyCode::Char('k') => { - let prev = state.selected; - state.selected = state.selected.saturating_sub(1); - if state.selected != prev { - refresh_notes_for_selection(state)?; - } - } - KeyCode::Left | KeyCode::Char('h') => { - shift_day(state, -1)?; - } - KeyCode::Right | KeyCode::Char('l') => { - shift_day(state, 1)?; - } - KeyCode::Down | KeyCode::Char('j') => { - let max_idx = build_view_items(state, 20).len().saturating_sub(1); - let prev = state.selected; - state.selected = state.selected.min(max_idx).saturating_add(1).min(max_idx); - if state.selected != prev { - refresh_notes_for_selection(state)?; - } - } - KeyCode::Char('e') => { - if selected_goal_id(state).is_some() { - refresh_notes_for_selection(state)?; - state.mode = Mode::NotesEdit; - state.focused_block = FocusedBlock::Notes; - } - } - KeyCode::Char('E') => { - if selected_goal_id(state).is_some() { - match open_notes_in_external_editor(state) { - Ok(_) => { - state.status = Some("Notes updated via external editor".to_string()); - } - Err(err) => { - state.status = Some(format!("Failed to open editor: {err}")); - } - } - } else { - state.status = Some("Select a goal before editing notes".to_string()); - } - } - KeyCode::Char('o') => match open_archive_in_file_manager(state) { - Ok(_) => { - state.status = Some("Opening archive in file manager".to_string()); - } - Err(err) => { - state.status = Some(format!("Failed to open archive: {err}")); - } - }, - KeyCode::Enter => { - let items = build_view_items(state, 20); - let Some(item) = items.get(state.selected) else { - return Ok(false); - }; - match item.kind { - ViewItemKind::AddSession => { - if state.timer.is_some() { - state.status = - Some("Finish the running session before starting another".to_string()); - return Ok(false); - } - state.mode = Mode::AddSession; - state.search_input.clear(); - state.search_selected = 0; - } - ViewItemKind::AddReward => { - if state.timer.is_some() { - state.status = - Some("Finish the running session before starting another".to_string()); - return Ok(false); - } - state.mode = Mode::AddReward; - state.search_input.clear(); - state.search_selected = 0; - } - - ViewItemKind::RunningTimer => {} - ViewItemKind::Existing(_, _) => {} - } - } - _ => {} - } - Ok(false) -} - -fn shift_day(state: &mut AppState, delta: i64) -> Result<()> { - if delta == 0 { - return Ok(()); - } - let today = Local::now().date_naive(); - let Some(new_day) = state - .current_day - .checked_add_signed(ChronoDuration::days(delta)) - else { - state.status = Some("Day change out of range".to_string()); - return Ok(()); - }; - if new_day > today { - state.status = Some("Cannot view future days".to_string()); - return Ok(()); - } - - state.current_day = new_day; - state.nodes = list_day_sessions(&state.archive, new_day)?; - state.selected = build_view_items(state, 20).len().saturating_sub(1); - refresh_notes_for_selection(state)?; - state.status = Some(format!("Showing {}", format_day_label(new_day))); - Ok(()) -} - -fn handle_search_key(state: &mut AppState, key: KeyEvent) -> Result { - if state.search_input.handle_key(key) { - state.search_selected = 0; - return Ok(false); - } - match key.code { - KeyCode::Esc => { - state.mode = Mode::View; - state.search_input.clear(); - state.search_selected = 0; - } - KeyCode::Enter => { - let results = search_results(state); - if let Some((_, result)) = results.get(state.search_selected) { - state.search_input.clear(); - 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(), - is_reward: *is_reward, - }); - state.mode = Mode::GoalForm; - } - SearchResult::Existing(goal) => { - let mut suggestion = None; - if let Ok(recent) = list_sessions_between_dates(&state.archive, None, None) - { - if let Some(last) = recent - .iter() - .filter(|s| s.goal_id == goal.id) - .max_by_key(|s| s.start_at) - { - let duration_mins = (last.end_at - last.start_at).num_minutes(); - suggestion = Some(format_duration_suggestion(duration_mins)); - } - } - let suggestion = suggestion.unwrap_or_else(|| "25m".to_string()); - state.duration_input = TextInput::from_string(suggestion); - state.mode = Mode::DurationInput { - is_reward: matches!(state.mode, Mode::AddReward), - goal_name: goal.name.clone(), - goal_id: goal.id, - }; - } - } - } - } - KeyCode::Up => { - if state.search_selected > 0 { - state.search_selected -= 1; - } - } - KeyCode::Down => { - let len = search_results(state).len(); - if len > 0 { - state.search_selected = (state.search_selected + 1).min(len - 1); - } - } - _ => {} - } - Ok(false) -} - -fn handle_quantity_done_key(state: &mut AppState, key: KeyEvent) -> Result { - let Mode::QuantityDoneInput { .. } = state.mode else { - return Ok(false); - }; - - if state.quantity_input.handle_key(key) { - return Ok(false); - } - - match key.code { - KeyCode::Esc => { - state.quantity_input.clear(); - if let Some(pending) = state.pending_session.take() { - finalize_session(state, pending, None); - } else { - state.mode = Mode::View; - } - } - KeyCode::Enter => { - let qty = parse_optional_u32(&state.quantity_input.value); - - if let Some(pending) = state.pending_session.take() { - finalize_session(state, pending, qty); - } - state.quantity_input.clear(); - } - _ => {} - } - - Ok(false) -} - -fn handle_duration_key(state: &mut AppState, key: KeyEvent) -> Result { - if state.duration_input.handle_key(key) { - return Ok(false); - } - match key.code { - KeyCode::Esc => { - state.duration_input.clear(); - state.mode = Mode::View; - } - KeyCode::Enter => { - let Mode::DurationInput { - is_reward, - ref goal_name, - goal_id, - } = state.mode - else { - return Ok(false); - }; - let secs = parse_duration(&state.duration_input.value).unwrap_or(25 * 60); - start_timer(state, goal_name.clone(), goal_id, secs as u32, is_reward)?; - } - _ => {} - } - Ok(false) -} - -fn handle_timer_key(state: &mut AppState, key: KeyEvent) -> Result { - // Allow all navigation and editing actions during timer, just not starting new sessions - handle_view_key(state, key) -} - -fn handle_notes_key(state: &mut AppState, key: KeyEvent) -> Result { - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Left => move_notes_cursor_word_left(state), - KeyCode::Right => move_notes_cursor_word_right(state), - _ => {} - } - return Ok(false); - } - - match key.code { - KeyCode::Esc => { - save_notes_for_selection(state)?; - state.mode = Mode::View; - state.focused_block = FocusedBlock::SessionsList; - } - KeyCode::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)?; - } - } - KeyCode::Enter => { - let insert_at = state.notes_cursor; - state.notes.insert(insert_at, '\n'); - state.notes_cursor += 1; - save_notes_for_selection(state)?; - } - KeyCode::Tab => { - let insert_at = state.notes_cursor; - state.notes.insert_str(insert_at, " "); - state.notes_cursor += 4; - save_notes_for_selection(state)?; - } - KeyCode::Char(c) => { - if key.modifiers != KeyModifiers::CONTROL { - let insert_at = state.notes_cursor; - state.notes.insert(insert_at, c); - state.notes_cursor += c.len_utf8(); - save_notes_for_selection(state)?; - } - } - KeyCode::Left => move_notes_cursor_left(state), - KeyCode::Right => move_notes_cursor_right(state), - KeyCode::Up => move_notes_cursor_vert(state, -1), - KeyCode::Down => move_notes_cursor_vert(state, 1), - _ => {} - } - Ok(false) -} - -fn tick_timer(state: &mut AppState) { - if let Some(timer) = state.timer.as_mut() { - // Calculate elapsed time based on real-world clock time (DateTime) - // instead of process time (Instant) so it works correctly when - // the PC is suspended or the terminal is backgrounded - let now_utc = Utc::now(); - let elapsed_seconds = (now_utc - timer.started_at).num_seconds(); - - if elapsed_seconds >= 0 { - let elapsed = elapsed_seconds as u64; - if elapsed >= timer.total { - timer.remaining = 0; - } else { - timer.remaining = timer.total - elapsed; - } - - if timer.remaining == 0 { - finish_timer(state); - } - } - } -} - -fn finish_timer(state: &mut AppState) { - if let Some(mut timer) = state.timer.take() { - // Only kill spawned apps when finishing a reward, not a regular session - if timer.is_reward { - kill_spawned(&mut timer.spawned); - } - - let pending = PendingSession { - label: timer.label.clone(), - goal_id: timer.goal_id, - total: timer.total, - is_reward: timer.is_reward, - started_at: timer.started_at, - }; - - let quantity_name = goal_quantity_name(state, timer.goal_id); - let needs_quantity = quantity_name.is_some(); - - if needs_quantity { - state.pending_session = Some(pending); - state.quantity_input.clear(); - state.mode = Mode::QuantityDoneInput { - goal_name: timer.label, - quantity_name, - }; - if let Some(unit) = goal_quantity_name(state, timer.goal_id) { - state.status = Some(format!("Enter quantity done ({unit})")); - } - } else { - finalize_session(state, pending, None); - } - } -} - -fn start_timer( - state: &mut AppState, - goal_name: String, - goal_id: u64, - seconds: u32, - is_reward: bool, -) -> Result<()> { - if state.timer.is_some() { - state.status = Some("Finish the running session before starting another".to_string()); - return Ok(()); - } - - let today = Local::now().date_naive(); - if state.current_day != today { - state.current_day = today; - state.nodes = list_day_sessions(&state.archive, today)?; - state.selected = build_view_items(state, 20).len().saturating_sub(1); - refresh_notes_for_selection(state)?; - } - - let started_at = Utc::now(); - let commands = commands_for_goal(state, goal_id); - let spawned = spawn_commands(&commands); - state.timer = Some(TimerState { - label: goal_name, - goal_id, - remaining: seconds as u64, - total: seconds as u64, - is_reward, - spawned, - started_at, - }); - state.selected = build_view_items(state, 20).len().saturating_sub(1); - if let Err(e) = append_session_start_header(&state.archive, goal_id, started_at) { - state.status = Some(format!("Failed to prepare notes: {e}")); - } - refresh_notes_for_selection(state)?; - state.mode = Mode::Timer; - state.status = Some(format!( - "Running {}", - if is_reward { "reward" } else { "session" } - )); - Ok(()) -} - -fn finalize_session(state: &mut AppState, pending: PendingSession, quantity: Option) { - state.mode = Mode::View; - let duration_secs = pending.total.min(u32::MAX as u64) as u32; - let created = match add_session( - &state.archive, - pending.goal_id, - &pending.label, - pending.started_at, - duration_secs, - pending.is_reward, - quantity, - ) { - Ok(session) => session, - Err(e) => { - state.status = Some(format!("Failed to record session: {e}")); - return; - } - }; - - let timer_day = created.start_at.with_timezone(&Local).date_naive(); - if state.current_day == timer_day { - match list_day_sessions(&state.archive, timer_day) { - Ok(nodes) => { - state.nodes = nodes; - let items = build_view_items(state, 20); - state.selected = items.len().saturating_sub(1); - if let Err(e) = refresh_notes_for_selection(state) { - eprintln!("Failed to load notes: {e}"); - } - } - Err(e) => eprintln!("Failed to load day graph: {e}"), - } - } - let kind = if pending.is_reward { - SessionKind::Reward - } else { - SessionKind::Goal - }; - state.status = Some(format!("Finished {}", kind_label(kind))); -} - -fn parse_optional_u32(input: &str) -> Option { - let trimmed = input.trim(); - if trimmed.is_empty() { - None - } else { - trimmed.parse::().ok() - } -} - -fn goal_quantity_name(state: &AppState, goal_id: u64) -> Option { - state - .goals - .iter() - .find(|g| g.id == goal_id) - .and_then(|g| g.quantity_name.clone()) -} - -fn search_results(state: &AppState) -> Vec<(String, SearchResult)> { - let q = state.search_input.value.trim(); - - let is_reward = matches!(state.mode, Mode::AddReward); - - // Use the library function which handles fuzzy matching and sorting by recent - let goals = search_goals(&state.archive, q, Some(is_reward), None, true).unwrap_or_default(); - - let mut results: Vec<(String, SearchResult)> = goals - .into_iter() - .map(|g| { - ( - format!("{} (id {})", g.name, g.id), - SearchResult::Existing(g), - ) - }) - .collect(); - - let create_label = format!("Create: {q}"); - results.push(( - create_label, - SearchResult::Create { - name: if q.is_empty() { - if is_reward { - "New reward".to_string() - } else { - "New goal".to_string() - } - } else { - q.to_string() - }, - is_reward, - }, - )); - - results -} - -fn selected_goal_id(state: &AppState) -> Option { - let items = build_view_items(state, 20); - match items.get(state.selected).map(|v| v.kind) { - Some(ViewItemKind::RunningTimer) => state.timer.as_ref().map(|t| t.goal_id), - Some(ViewItemKind::Existing(_, idx)) => state.nodes.get(idx).map(|n| n.goal_id), - _ => state.timer.as_ref().map(|t| t.goal_id), - } -} - -fn append_session_start_header( - archive: &Path, - goal_id: u64, - start_at: DateTime, -) -> Result<()> { - let mut note = get_note(archive, goal_id).unwrap_or_default(); - - let start_local = start_at.with_timezone(&Local); - let start_stamp = start_local.format("%Y-%m-%d %H:%M"); - note.push_str(&format!("---\n{start_stamp}\n")); - - edit_note_api(archive.to_string_lossy().to_string(), goal_id, note) - .context("Failed to append session header")?; - - Ok(()) -} - -fn refresh_notes_for_selection(state: &mut AppState) -> Result<()> { - if let Some(goal_id) = selected_goal_id(state) { - state.notes = get_note(&state.archive, goal_id)?; - state.notes_cursor = state.notes.len(); - } else { - state.notes.clear(); - state.notes_cursor = 0; - } - Ok(()) -} - -fn save_notes_for_selection(state: &AppState) -> Result<()> { - if let Some(goal_id) = selected_goal_id(state) { - edit_note(&state.archive, goal_id, &state.notes)?; - } - Ok(()) -} - -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 cursor is at end and notes end with newline, cursor is start of next line - if state.notes_cursor > seen { - // beyond last processed char, adjust based on trailing segment - 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) -} - -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 -} - -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; - } -} - -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; - } -} - -fn move_notes_cursor_word_left(state: &mut AppState) { - if state.notes_cursor == 0 { - return; - } - let mut idx = state.notes_cursor; - // Skip trailing whitespace to the left - 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; - } - } - // Skip the word - 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; -} - -fn move_notes_cursor_word_right(state: &mut AppState) { - let len = state.notes.len(); - let mut idx = state.notes_cursor; - if idx >= len { - return; - } - // Skip whitespace to the right - while idx < len { - if let Some(ch) = state.notes[idx..].chars().next() { - if ch.is_whitespace() { - idx += ch.len_utf8(); - } else { - break; - } - } else { - break; - } - } - // Skip the word - 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; -} - -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() { - // exclude the newline char - 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); - // Walk from line_start to target_col chars to find byte offset - 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; - } -} - -fn open_archive_in_file_manager(state: &AppState) -> Result<()> { - let path = state.archive.clone(); - if !path.exists() { - fs::create_dir_all(&path)?; - } - - Command::new(FILE_MANAGER_COMMAND) - .arg(&path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .with_context(|| format!("Failed to start {FILE_MANAGER_COMMAND}"))?; - - Ok(()) -} - -fn open_notes_in_external_editor(state: &mut AppState) -> Result<()> { - let goal_id = selected_goal_id(state).context("No goal selected for note editing")?; - refresh_notes_for_selection(state)?; - save_notes_for_selection(state)?; - - // successlib saves notes to archive/notes/goal_.md - let note_path = state - .archive - .join("notes") - .join(format!("goal_{goal_id}.md")); - - let editor_value = std::env::var("EDITOR").unwrap_or_else(|_| DEFAULT_EDITOR.to_string()); - let mut editor_parts = parse_editor_command(editor_value.trim()); - if editor_parts.is_empty() { - editor_parts.push(DEFAULT_EDITOR.to_string()); - } - let editor_bin = editor_parts.remove(0); - let editor_args = editor_parts; - - let note_for_run = note_path.clone(); - with_terminal_suspended(move || { - println!("Opening notes with {} (goal {})...", editor_bin, goal_id); - io::stdout().flush().ok(); - - let mut command = Command::new(&editor_bin); - for arg in &editor_args { - command.arg(arg); - } - command.arg(¬e_for_run); - command.stdin(Stdio::inherit()); - command.stdout(Stdio::inherit()); - command.stderr(Stdio::inherit()); - let status = command - .status() - .with_context(|| format!("Failed to run {editor_bin}"))?; - if !status.success() { - bail!("Editor exited with status {status}"); - } - Ok(()) - })?; - - // Reload notes in case the editor changed the file on disk. - refresh_notes_for_selection(state)?; - state.notes_cursor = state.notes.len(); - state.needs_full_redraw = true; - Ok(()) -} - -fn with_terminal_suspended(action: F) -> Result<()> -where - F: FnOnce() -> Result<()>, -{ - disable_raw_mode()?; - { - let mut stdout = io::stdout(); - execute!( - stdout, - terminal::LeaveAlternateScreen, - event::DisableMouseCapture - )?; - } - - let result = action(); - - { - let mut stdout = io::stdout(); - execute!( - stdout, - terminal::EnterAlternateScreen, - event::EnableMouseCapture, - SetCursorStyle::SteadyBlock, - Clear(ClearType::All), - MoveTo(0, 0) - )?; - } - enable_raw_mode()?; - - result -} - -fn parse_editor_command(input: &str) -> Vec { - let mut args = Vec::new(); - let mut current = String::new(); - let mut chars = input.chars().peekable(); - let mut in_single = false; - let mut in_double = false; - - while let Some(ch) = chars.next() { - match ch { - '\'' if !in_double => { - in_single = !in_single; - } - '"' if !in_single => { - in_double = !in_double; - } - '\\' if !in_single => { - if let Some(next) = chars.next() { - current.push(next); - } - } - c if c.is_whitespace() && !in_single && !in_double => { - if !current.is_empty() { - args.push(std::mem::take(&mut current)); - } - } - _ => current.push(ch), - } - } +use ratatui::Terminal; +use serde::{Deserialize, Serialize}; - if !current.is_empty() { - args.push(current); - } +use success_core::app::AppState; +use success_core::key_event::{AppKeyCode, AppKeyEvent}; +use success_core::types::Mode; +use success_core::ui; - args +#[cfg(target_os = "macos")] +const FILE_MANAGER_COMMAND: &str = "open"; +#[cfg(target_os = "windows")] +const FILE_MANAGER_COMMAND: &str = "explorer"; +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +const FILE_MANAGER_COMMAND: &str = "xdg-open"; + +const DEFAULT_EDITOR: &str = "nvim"; + +// ── Key event conversion ───────────────────────────────────────────────── + +fn convert_key(key: crossterm::event::KeyEvent) -> AppKeyEvent { + let code = match key.code { + KeyCode::Char(c) => AppKeyCode::Char(c), + KeyCode::Backspace => AppKeyCode::Backspace, + KeyCode::Enter => AppKeyCode::Enter, + KeyCode::Left => AppKeyCode::Left, + KeyCode::Right => AppKeyCode::Right, + KeyCode::Up => AppKeyCode::Up, + KeyCode::Down => AppKeyCode::Down, + KeyCode::Tab => AppKeyCode::Tab, + KeyCode::BackTab => AppKeyCode::BackTab, + KeyCode::Delete => AppKeyCode::Delete, + KeyCode::Home => AppKeyCode::Home, + KeyCode::End => AppKeyCode::End, + KeyCode::Esc => AppKeyCode::Esc, + _ => AppKeyCode::Other, + }; + AppKeyEvent { + code, + ctrl: key.modifiers.contains(KeyModifiers::CONTROL), + alt: key.modifiers.contains(KeyModifiers::ALT), + shift: key.modifiers.contains(KeyModifiers::SHIFT), + } } -fn parse_commands_input(input: &str) -> Vec { - input - .split([';', '\n']) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() +// ── CLI-specific: spawned commands ─────────────────────────────────────── + +struct SpawnedCommand { + command: String, + child: Option, + pgid: i32, } fn commands_for_goal(state: &AppState, goal_id: u64) -> Vec { @@ -2048,21 +89,17 @@ fn spawn_commands(commands: &[String]) -> Vec { .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - - // Isolate the spawned command in its own process group so we can kill everything it starts. unsafe { command.pre_exec(|| { setsid(); Ok(()) }); } - command.spawn() }; - match child { Ok(child) => { - let pgid = child.id() as i32; // after setsid(), pgid == pid + let pgid = child.id() as i32; SpawnedCommand { command: cmd.clone(), child: Some(child), @@ -2088,8 +125,7 @@ fn spawn_commands(commands: &[String]) -> Vec { .iter() .map(|cmd| { let child = if cfg!(target_os = "windows") { - let mut command = Command::new("cmd"); - command + Command::new("cmd") .arg("/C") .arg(cmd) .stdin(Stdio::null()) @@ -2097,8 +133,7 @@ fn spawn_commands(commands: &[String]) -> Vec { .stderr(Stdio::null()) .spawn() } else { - let mut command = Command::new("sh"); - command + Command::new("sh") .arg("-c") .arg(format!("exec {cmd}")) .stdin(Stdio::null()) @@ -2106,7 +141,6 @@ fn spawn_commands(commands: &[String]) -> Vec { .stderr(Stdio::null()) .spawn() }; - match child { Ok(child) => SpawnedCommand { command: cmd.clone(), @@ -2131,7 +165,6 @@ fn kill_spawned(spawned: &mut [SpawnedCommand]) { for sc in spawned.iter_mut() { if let Some(child) = sc.child.as_mut() { let pid = child.id(); - // Best-effort: kill the whole process group first so children die too. unsafe { if sc.pgid != 0 { kill(-sc.pgid, SIGTERM); @@ -2139,7 +172,6 @@ fn kill_spawned(spawned: &mut [SpawnedCommand]) { kill(-(pid as i32), SIGTERM); } } - // Give the process a brief moment to exit before escalating. std::thread::sleep(Duration::from_millis(150)); unsafe { if sc.pgid != 0 { @@ -2154,8 +186,6 @@ fn kill_spawned(spawned: &mut [SpawnedCommand]) { } } let _ = child.wait(); - - // Fallback for apps that reparent or respawn outside the pgid (e.g., GUI apps). let _ = Command::new("pkill") .arg("-f") .arg(&sc.command) @@ -2181,81 +211,138 @@ fn kill_spawned(spawned: &mut [SpawnedCommand]) { } } -fn parse_duration(input: &str) -> Option { - let input = input.trim(); - if input.is_empty() { - return None; +// ── CLI-specific: external editor, file manager ────────────────────────── + +fn open_archive_in_file_manager(archive: &Path) -> Result<()> { + if !archive.exists() { + fs::create_dir_all(archive)?; } + Command::new(FILE_MANAGER_COMMAND) + .arg(archive) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("Failed to start {FILE_MANAGER_COMMAND}"))?; + Ok(()) +} - // Accumulate total seconds - let mut total_seconds = 0; - let mut current_digits = String::new(); +fn open_notes_in_external_editor(state: &mut AppState, archive: &Path) -> Result<()> { + let goal_id = success_core::utils::selected_goal_id(state) + .context("No goal selected for note editing")?; + success_core::notes::refresh_notes_for_selection(state); + success_core::notes::save_notes_for_selection(state); - // Custom parser for formats like "1h30m", "90m", "1h", "90" - for c in input.chars() { - if c.is_ascii_digit() { - current_digits.push(c); - } else if c.is_alphabetic() { - if current_digits.is_empty() { - continue; // Ignore unit without number? or error? - } - let val = current_digits.parse::().ok()?; - current_digits.clear(); - match c.to_ascii_lowercase() { - 'h' => total_seconds += val * 3600, - 'm' => total_seconds += val * 60, - 's' => total_seconds += val, - _ => return None, // Unknown unit - } - } else if c.is_whitespace() { - // Ignore whitespace - } else { - // Invalid character - return None; - } - } + let note_path = archive.join("notes").join(format!("goal_{goal_id}.md")); - if !current_digits.is_empty() { - // Trailing number without unit. Assume minutes. - let val = current_digits.parse::().ok()?; - total_seconds += val * 60; + let editor_value = std::env::var("EDITOR").unwrap_or_else(|_| DEFAULT_EDITOR.to_string()); + let mut editor_parts = parse_editor_command(editor_value.trim()); + if editor_parts.is_empty() { + editor_parts.push(DEFAULT_EDITOR.to_string()); } + let editor_bin = editor_parts.remove(0); + let editor_args = editor_parts; - if total_seconds == 0 { - None - } else { - Some(total_seconds) - } + let note_for_run = note_path.clone(); + with_terminal_suspended(move || { + println!("Opening notes with {} (goal {})...", editor_bin, goal_id); + io::stdout().flush().ok(); + + let mut command = Command::new(&editor_bin); + for arg in &editor_args { + command.arg(arg); + } + command.arg(¬e_for_run); + command.stdin(Stdio::inherit()); + command.stdout(Stdio::inherit()); + command.stderr(Stdio::inherit()); + let status = command + .status() + .with_context(|| format!("Failed to run {editor_bin}"))?; + if !status.success() { + bail!("Editor exited with status {status}"); + } + Ok(()) + })?; + + success_core::notes::refresh_notes_for_selection(state); + state.notes_cursor = state.notes.len(); + Ok(()) } -fn format_day_label(day: NaiveDate) -> String { - let today = Local::now().date_naive(); - let base = day.format("%Y-%m-%d").to_string(); - let diff = (today - day).num_days(); - if diff == 0 { - format!("{base}, today") - } else { - format!("{base}, -{diff}d") +fn with_terminal_suspended(action: F) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + disable_raw_mode()?; + { + let mut stdout = io::stdout(); + execute!( + stdout, + terminal::LeaveAlternateScreen, + event::DisableMouseCapture + )?; } -} -fn format_duration_suggestion(duration_mins: i64) -> String { - if duration_mins == 0 { - return "1s".to_string(); + let result = action(); + + { + let mut stdout = io::stdout(); + execute!( + stdout, + terminal::EnterAlternateScreen, + event::EnableMouseCapture, + SetCursorStyle::SteadyBlock, + crossterm::terminal::Clear(crossterm::terminal::ClearType::All), + crossterm::cursor::MoveTo(0, 0) + )?; } - let mins = duration_mins.max(0); - if mins == 0 { - return "1s".to_string(); + enable_raw_mode()?; + + result +} + +fn parse_editor_command(input: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + let mut in_single = false; + let mut in_double = false; + + while let Some(ch) = chars.next() { + match ch { + '\'' if !in_double => { + in_single = !in_single; + } + '"' if !in_single => { + in_double = !in_double; + } + '\\' if !in_single => { + if let Some(next) = chars.next() { + current.push(next); + } + } + c if c.is_whitespace() && !in_single && !in_double => { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } } - let h = mins / 60; - let m = mins % 60; - if h > 0 && m > 0 { - format!("{}h {}m", h, m) - } else if h > 0 { - format!("{}h", h) - } else { - format!("{}m", m) + + if !current.is_empty() { + args.push(current); } + + args +} + +// ── Config ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CliConfig { + archive: Option, } fn persist_config(archive: &Path) -> Result<()> { @@ -2301,3 +388,154 @@ fn config_path() -> Result { let home = std::env::var("HOME").context("HOME not set; please set HOME")?; Ok(Path::new(&home).join(".config/success-cli/config.json")) } + +// ── Main ───────────────────────────────────────────────────────────────── + +#[derive(Parser, Debug)] +#[command(name = "success-cli")] +#[command(about = "CLI for achieving goals", long_about = None)] +struct Args { + /// Custom archive path (useful for testing) + #[arg(short, long)] + archive: Option, +} + +/// CLI-extended state: wraps core state + CLI-only fields +struct CliState { + app: AppState, + archive: PathBuf, + spawned: Vec, + needs_full_redraw: bool, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let archive = resolve_archive_interactive(args.archive.clone())?; + + let app = AppState::new(archive.to_string_lossy().to_string()); + + let mut cli = CliState { + app, + archive: archive.clone(), + spawned: Vec::new(), + needs_full_redraw: false, + }; + + if args.archive.is_none() { + persist_config(&archive).ok(); + } + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!( + stdout, + terminal::EnterAlternateScreen, + event::EnableMouseCapture, + SetCursorStyle::SteadyBlock + )?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let res = run_app(&mut terminal, &mut cli); + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + terminal::LeaveAlternateScreen, + event::DisableMouseCapture, + SetCursorStyle::DefaultUserShape + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + eprintln!("Error: {err}"); + } + Ok(()) +} + +fn run_app + std::io::Write>( + terminal: &mut Terminal, + cli: &mut CliState, +) -> Result<()> { + loop { + if cli.needs_full_redraw { + terminal.clear()?; + cli.needs_full_redraw = false; + } + cli.app.tick(); + + let header = format!("Archive: {} (open with 'o')", cli.archive.display()); + terminal.draw(|f| ui::ui(f, &cli.app, &header))?; + execute!(terminal.backend_mut(), get_cursor_style(&cli.app.mode))?; + + if event::poll(Duration::from_millis(200))? { + match event::read()? { + Event::Key(key) => { + // Handle CLI-specific keys first + if handle_cli_key(cli, key)? { + break; + } + } + Event::Resize(_, _) => {} + _ => {} + } + } + } + kill_spawned(&mut cli.spawned); + Ok(()) +} + +/// Handle CLI-specific key actions, then delegate to core. +/// Returns true if the app should quit. +fn handle_cli_key(cli: &mut CliState, key: crossterm::event::KeyEvent) -> Result { + // Handle CLI-only keys in View mode + if matches!(cli.app.mode, Mode::View) { + match key.code { + KeyCode::Char('E') => { + if success_core::utils::selected_goal_id(&cli.app).is_some() { + open_notes_in_external_editor(&mut cli.app, &cli.archive)?; + cli.needs_full_redraw = true; + } + return Ok(false); + } + KeyCode::Char('o') => { + let _ = open_archive_in_file_manager(&cli.archive); + return Ok(false); + } + _ => {} + } + } + + // Delegate to core + let app_key = convert_key(key); + let quit = cli.app.handle_key(app_key); + + // After core handles a timer start, spawn commands + if matches!(cli.app.mode, Mode::Timer) { + if let Some(timer) = &cli.app.timer { + if cli.spawned.is_empty() { + let cmds = commands_for_goal(&cli.app, timer.goal_id); + cli.spawned = spawn_commands(&cmds); + } + } + } + + // When timer finishes and was a reward, kill spawned apps + if !matches!(cli.app.mode, Mode::Timer) && !cli.spawned.is_empty() { + kill_spawned(&mut cli.spawned); + } + + Ok(quit) +} + +fn get_cursor_style(mode: &Mode) -> SetCursorStyle { + match mode { + Mode::NotesEdit + | Mode::AddSession + | Mode::AddReward + | Mode::GoalForm + | Mode::QuantityDoneInput { .. } + | Mode::DurationInput { .. } => SetCursorStyle::SteadyBlock, + Mode::View | Mode::Timer => SetCursorStyle::SteadyBlock, + } +} diff --git a/success-core/Cargo.toml b/success-core/Cargo.toml new file mode 100644 index 0000000..3b696c6 --- /dev/null +++ b/success-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "success-core" +version = "0.1.0" +edition = "2021" +description = "Shared core for success-cli and success-web" + +[features] +default = [] +web = [] + +[dependencies] +ratatui = { version = "0.30", default-features = false, features = ["all-widgets"] } +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 new file mode 100644 index 0000000..0a8117e --- /dev/null +++ b/success-core/src/app.rs @@ -0,0 +1,84 @@ +use chrono::{Local, NaiveDate}; + +use crate::handlers::*; +use crate::key_event::AppKeyEvent; +use crate::notes::refresh_notes_for_selection; +use crate::types::*; +use crate::ui::build_view_items; +use successlib::{Goal, SessionView}; + +/// Central application state, generic over the storage backend. +pub struct AppState { + pub archive_path: String, + pub goals: Vec, + pub nodes: Vec, + pub current_day: NaiveDate, + pub selected: usize, + pub mode: Mode, + pub search_input: TextInput, + pub search_selected: usize, + pub duration_input: TextInput, + pub quantity_input: TextInput, + pub timer: Option, + pub pending_session: Option, + pub notes: String, + pub notes_cursor: usize, + pub focused_block: FocusedBlock, + pub form_state: Option, +} + +impl AppState { + pub fn new(archive_path: String) -> Self { + let today = Local::now().date_naive(); + let goals = successlib::list_goals(archive_path.clone(), None).unwrap_or_default(); + let nodes = successlib::list_day_sessions( + archive_path.clone(), + today.format("%Y-%m-%d").to_string(), + ) + .unwrap_or_default(); + let mut state = Self { + archive_path, + goals, + nodes, + current_day: today, + selected: 0, + mode: Mode::View, + search_input: TextInput::default(), + search_selected: 0, + duration_input: TextInput::default(), + quantity_input: TextInput::default(), + timer: None, + pending_session: None, + notes: String::new(), + notes_cursor: 0, + focused_block: FocusedBlock::SessionsList, + form_state: None, + }; + state.selected = build_view_items(&state, 20).len().saturating_sub(1); + refresh_notes_for_selection(&mut state); + state + } + + /// Dispatch a key event to the appropriate handler. + /// Returns true if the app should quit (only for CLI). + pub fn handle_key(&mut self, key: AppKeyEvent) -> bool { + if key.is_ctrl_c() { + return true; + } + match self.mode { + Mode::View => handle_view_key(self, &key), + Mode::AddSession | Mode::AddReward => handle_search_key(self, &key), + Mode::GoalForm => handle_form_key(self, &key), + Mode::QuantityDoneInput { .. } => handle_quantity_done_key(self, &key), + Mode::DurationInput { .. } => handle_duration_key(self, &key), + Mode::Timer => handle_timer_key(self, &key), + Mode::NotesEdit => handle_notes_key(self, &key), + } + false + } + + /// Tick the timer (call on every frame / poll cycle). + pub fn tick(&mut self) { + crate::timer::tick_timer(self); + } +} diff --git a/success-core/src/handlers.rs b/success-core/src/handlers.rs new file mode 100644 index 0000000..e9a7643 --- /dev/null +++ b/success-core/src/handlers.rs @@ -0,0 +1,404 @@ +use chrono::{Duration as ChronoDuration, Local}; + +use crate::app::AppState; +use crate::key_event::{AppKeyCode, AppKeyEvent}; +use crate::notes::{refresh_notes_for_selection, save_notes_for_selection}; +use crate::timer::{finalize_session, start_timer}; +use crate::types::*; +use crate::ui::{build_view_items, ViewItemKind}; +use crate::utils::{ + format_duration_suggestion, parse_commands_input, parse_duration, parse_optional_u32, + selected_goal_id, +}; + +pub fn handle_view_key(state: &mut AppState, key: &AppKeyEvent) { + match key.code { + AppKeyCode::Char('q') => {} // Quit is handled by the caller + AppKeyCode::Up | AppKeyCode::Char('k') => { + let prev = state.selected; + state.selected = state.selected.saturating_sub(1); + if state.selected != prev { + refresh_notes_for_selection(state); + } + } + AppKeyCode::Left | AppKeyCode::Char('h') => { + shift_day(state, -1); + } + AppKeyCode::Right | AppKeyCode::Char('l') => { + shift_day(state, 1); + } + AppKeyCode::Down | AppKeyCode::Char('j') => { + let max_idx = build_view_items(state, 20).len().saturating_sub(1); + let prev = state.selected; + state.selected = state.selected.min(max_idx).saturating_add(1).min(max_idx); + if state.selected != prev { + refresh_notes_for_selection(state); + } + } + AppKeyCode::Char('e') => { + if selected_goal_id(state).is_some() { + refresh_notes_for_selection(state); + state.mode = Mode::NotesEdit; + state.focused_block = FocusedBlock::Notes; + } + } + AppKeyCode::Enter => { + let items = build_view_items(state, 20); + let Some(item) = items.get(state.selected) else { + return; + }; + match item.kind { + ViewItemKind::AddSession => { + if state.timer.is_some() { + return; + } + state.mode = Mode::AddSession; + state.search_input.clear(); + state.search_selected = 0; + } + ViewItemKind::AddReward => { + if state.timer.is_some() { + return; + } + state.mode = Mode::AddReward; + state.search_input.clear(); + state.search_selected = 0; + } + ViewItemKind::RunningTimer => {} + ViewItemKind::Existing(_, _) => {} + } + } + _ => {} + } +} + +pub fn shift_day(state: &mut AppState, delta: i64) { + if delta == 0 { + return; + } + let today = Local::now().date_naive(); + let Some(new_day) = state + .current_day + .checked_add_signed(ChronoDuration::days(delta)) + else { + return; + }; + if new_day > today { + return; + } + state.current_day = new_day; + state.nodes = successlib::list_day_sessions( + state.archive_path.clone(), + new_day.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); +} + +pub fn handle_search_key(state: &mut AppState, key: &AppKeyEvent) { + if state.search_input.handle_key(key) { + state.search_selected = 0; + return; + } + match key.code { + AppKeyCode::Esc => { + state.mode = Mode::View; + state.search_input.clear(); + state.search_selected = 0; + } + AppKeyCode::Enter => { + let results = search_results(state); + if let Some((_, result)) = results.get(state.search_selected) { + state.search_input.clear(); + 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(), + is_reward: *is_reward, + }); + state.mode = Mode::GoalForm; + } + SearchResult::Existing(goal) => { + let mut suggestion = None; + let recent = successlib::list_sessions_between_dates( + state.archive_path.clone(), + None, + None, + ) + .unwrap_or_default(); + if let Some(last) = recent + .iter() + .filter(|s| s.goal_id == goal.id) + .max_by_key(|s| s.start_at) + { + let duration_mins = (last.end_at - last.start_at) / 60; + suggestion = Some(format_duration_suggestion(duration_mins)); + } + let suggestion = suggestion.unwrap_or_else(|| "25m".to_string()); + state.duration_input = TextInput::from_string(suggestion); + state.mode = Mode::DurationInput { + is_reward: matches!(state.mode, Mode::AddReward), + goal_name: goal.name.clone(), + goal_id: goal.id, + }; + } + } + } + } + AppKeyCode::Up => { + if state.search_selected > 0 { + state.search_selected -= 1; + } + } + AppKeyCode::Down => { + let len = search_results(state).len(); + if len > 0 { + state.search_selected = (state.search_selected + 1).min(len - 1); + } + } + _ => {} + } +} + +pub fn handle_form_key(state: &mut AppState, key: &AppKeyEvent) { + let Some(form) = state.form_state.as_mut() else { + state.mode = Mode::View; + return; + }; + + let field = match form.current_field { + FormField::GoalName => &mut form.goal_name, + FormField::Quantity => &mut form.quantity_name, + FormField::Commands => &mut form.commands, + }; + + if field.handle_key(key) { + return; + } + + match key.code { + AppKeyCode::Esc => { + state.form_state = None; + state.mode = Mode::View; + } + AppKeyCode::Up | AppKeyCode::BackTab => { + form.current_field = match form.current_field { + FormField::GoalName => FormField::Commands, + FormField::Quantity => FormField::GoalName, + FormField::Commands => FormField::Quantity, + }; + } + AppKeyCode::Down | AppKeyCode::Tab => { + form.current_field = match form.current_field { + FormField::GoalName => FormField::Quantity, + FormField::Quantity => FormField::Commands, + FormField::Commands => FormField::GoalName, + }; + } + AppKeyCode::Enter => { + let name = form.goal_name.value.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() { + None + } else { + Some(form.quantity_name.value.trim().to_string()) + }; + let is_reward = form.is_reward; + + let created = successlib::add_goal( + state.archive_path.clone(), + name.clone(), + is_reward, + commands, + quantity_name, + ) + .expect("Failed to add goal"); + state.goals.push(created.clone()); + + state.form_state = None; + state.duration_input = TextInput::from_string("25m".to_string()); + state.mode = Mode::DurationInput { + is_reward, + goal_name: created.name.clone(), + goal_id: created.id, + }; + } + _ => {} + } +} + +pub fn handle_duration_key(state: &mut AppState, key: &AppKeyEvent) { + if state.duration_input.handle_key(key) { + return; + } + match key.code { + AppKeyCode::Esc => { + state.duration_input.clear(); + state.mode = Mode::View; + } + AppKeyCode::Enter => { + let (is_reward, goal_name, goal_id) = match &state.mode { + Mode::DurationInput { + is_reward, + goal_name, + goal_id, + } => (*is_reward, goal_name.clone(), *goal_id), + _ => return, + }; + let secs = parse_duration(&state.duration_input.value).unwrap_or(25 * 60); + start_timer(state, goal_name, goal_id, secs as u32, is_reward); + } + _ => {} + } +} + +pub fn handle_quantity_done_key(state: &mut AppState, key: &AppKeyEvent) { + if !matches!(state.mode, Mode::QuantityDoneInput { .. }) { + return; + } + + if state.quantity_input.handle_key(key) { + return; + } + + match key.code { + AppKeyCode::Esc => { + state.quantity_input.clear(); + if let Some(pending) = state.pending_session.take() { + finalize_session(state, pending, None); + } else { + state.mode = Mode::View; + } + } + AppKeyCode::Enter => { + let qty = parse_optional_u32(&state.quantity_input.value); + if let Some(pending) = state.pending_session.take() { + finalize_session(state, pending, qty); + } + state.quantity_input.clear(); + } + _ => {} + } +} + +pub fn handle_timer_key(state: &mut AppState, key: &AppKeyEvent) { + handle_view_key(state, key); +} + +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(); + 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 is_reward = matches!(state.mode, Mode::AddReward); + + let goals = successlib::search_goals( + state.archive_path.clone(), + q.to_string(), + Some(is_reward), + None, + Some(true), + ) + .unwrap_or_default(); + + let mut results: Vec<(String, SearchResult)> = goals + .into_iter() + .map(|g| { + ( + format!("{} (id {})", g.name, g.id), + SearchResult::Existing(g), + ) + }) + .collect(); + + let create_label = format!("Create: {q}"); + results.push(( + create_label, + SearchResult::Create { + name: if q.is_empty() { + if is_reward { + "New reward".to_string() + } else { + "New goal".to_string() + } + } else { + q.to_string() + }, + is_reward, + }, + )); + + results +} diff --git a/success-core/src/key_event.rs b/success-core/src/key_event.rs new file mode 100644 index 0000000..0c55938 --- /dev/null +++ b/success-core/src/key_event.rs @@ -0,0 +1,33 @@ +/// Abstract key event that both crossterm and ratzilla events can convert to. +#[derive(Debug, Clone)] +pub struct AppKeyEvent { + pub code: AppKeyCode, + pub ctrl: bool, + pub alt: bool, + pub shift: bool, +} + +/// Abstract key code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppKeyCode { + Char(char), + Backspace, + Enter, + Left, + Right, + Up, + Down, + Tab, + BackTab, + Delete, + Home, + End, + Esc, + Other, +} + +impl AppKeyEvent { + pub fn is_ctrl_c(&self) -> bool { + self.ctrl && self.code == AppKeyCode::Char('c') + } +} diff --git a/success-core/src/lib.rs b/success-core/src/lib.rs new file mode 100644 index 0000000..90e1eeb --- /dev/null +++ b/success-core/src/lib.rs @@ -0,0 +1,11 @@ +pub mod app; +pub mod handlers; +pub mod key_event; +pub mod notes; +// Re-export success-lib domain types to avoid duplicates. +pub use successlib::{Goal, Session, SessionKind, SessionView}; +pub mod style; +pub mod timer; +pub mod types; +pub mod ui; +pub mod utils; diff --git a/success-core/src/notes.rs b/success-core/src/notes.rs new file mode 100644 index 0000000..a669e51 --- /dev/null +++ b/success-core/src/notes.rs @@ -0,0 +1,192 @@ +use crate::app::AppState; +use crate::utils::selected_goal_id; + +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(); + } else { + state.notes.clear(); + state.notes_cursor = 0; + } +} + +/// 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 _ = 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/style.rs b/success-core/src/style.rs new file mode 100644 index 0000000..2b7f202 --- /dev/null +++ b/success-core/src/style.rs @@ -0,0 +1,5 @@ +use ratatui::style::Color; + +pub const BLUE: Color = Color::Rgb(0x89, 0xB4, 0xFA); +pub const GRAY_DIM: Color = Color::DarkGray; +pub const YELLOW: Color = Color::Rgb(0xF9, 0xE2, 0x79); diff --git a/success-core/src/timer.rs b/success-core/src/timer.rs new file mode 100644 index 0000000..32bb5b1 --- /dev/null +++ b/success-core/src/timer.rs @@ -0,0 +1,132 @@ +use chrono::{Local, Utc}; + +use crate::app::AppState; +use crate::notes::{refresh_notes_for_selection, save_notes_for_selection}; +use crate::types::*; +use crate::ui::build_view_items; +use crate::utils::goal_quantity_name; + +pub fn tick_timer(state: &mut AppState) { + if let Some(timer) = state.timer.as_mut() { + let now_utc = Utc::now(); + let elapsed_seconds = (now_utc - timer.started_at).num_seconds(); + + if elapsed_seconds >= 0 { + let elapsed = elapsed_seconds as u64; + if elapsed >= timer.total { + timer.remaining = 0; + } else { + timer.remaining = timer.total - elapsed; + } + + if timer.remaining == 0 { + finish_timer(state); + } + } + } +} + +pub fn finish_timer(state: &mut AppState) { + if let Some(timer) = state.timer.take() { + if matches!(state.mode, Mode::NotesEdit) { + save_notes_for_selection(state); + } + + let pending = PendingSession { + label: timer.label.clone(), + goal_id: timer.goal_id, + total: timer.total, + is_reward: timer.is_reward, + started_at: timer.started_at, + }; + + let quantity_name = goal_quantity_name(state, timer.goal_id); + let needs_quantity = quantity_name.is_some(); + + if needs_quantity { + state.pending_session = Some(pending); + state.quantity_input.clear(); + state.mode = Mode::QuantityDoneInput { + goal_name: timer.label, + quantity_name, + }; + state.focused_block = FocusedBlock::SessionsList; + } else { + finalize_session(state, pending, None); + } + } +} + +pub fn start_timer( + state: &mut AppState, + goal_name: String, + goal_id: u64, + seconds: u32, + is_reward: bool, +) { + if state.timer.is_some() { + return; + } + + 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.selected = build_view_items(state, 20).len().saturating_sub(1); + refresh_notes_for_selection(state); + } + + 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 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")); + let _ = successlib::edit_note(state.archive_path.clone(), goal_id, note); + + state.timer = Some(TimerState { + label: goal_name, + goal_id, + remaining: seconds as u64, + total: seconds as u64, + is_reward, + started_at, + }); + state.selected = build_view_items(state, 20).len().saturating_sub(1); + refresh_notes_for_selection(state); + state.mode = Mode::Timer; +} + +pub fn finalize_session(state: &mut AppState, pending: PendingSession, quantity: Option) { + if matches!(state.mode, Mode::NotesEdit) { + save_notes_for_selection(state); + } + + state.mode = Mode::View; + state.focused_block = FocusedBlock::SessionsList; + let duration_secs = pending.total.min(u32::MAX as u64) as u32; + let created = successlib::add_session( + state.archive_path.clone(), + pending.goal_id, + pending.label.clone(), + pending.started_at.timestamp(), + duration_secs, + pending.is_reward, + quantity, + ) + .expect("Failed to add session"); + + let timer_day = chrono::DateTime::from_timestamp(created.start_at, 0) + .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(); + 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 new file mode 100644 index 0000000..14aeb02 --- /dev/null +++ b/success-core/src/types.rs @@ -0,0 +1,228 @@ +use chrono::{DateTime, Utc}; + +use crate::key_event::{AppKeyCode, AppKeyEvent}; +use successlib::Goal; + +// ── TextInput ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct TextInput { + pub value: String, + pub cursor: usize, +} + +impl TextInput { + pub fn new(value: String) -> Self { + let len = value.chars().count(); + Self { value, cursor: len } + } + + pub fn from_string(value: String) -> Self { + Self::new(value) + } + + /// Returns true if the key was consumed. + pub fn handle_key(&mut self, key: &AppKeyEvent) -> bool { + match key.code { + AppKeyCode::Char(c) if !key.ctrl && !key.alt => { + self.insert_char(c); + true + } + AppKeyCode::Backspace => { + self.delete_char_back(); + true + } + 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); + } + } + 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; + } +} + +// ── Enums ──────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum Mode { + View, + AddSession, + AddReward, + GoalForm, + QuantityDoneInput { + goal_name: String, + quantity_name: Option, + }, + DurationInput { + is_reward: bool, + goal_name: String, + goal_id: u64, + }, + Timer, + NotesEdit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FormField { + #[default] + GoalName, + Quantity, + Commands, +} + +#[derive(Debug, Clone, Default)] +pub struct FormState { + pub current_field: FormField, + pub goal_name: TextInput, + pub quantity_name: TextInput, + pub commands: TextInput, + pub is_reward: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FocusedBlock { + #[default] + SessionsList, + Notes, +} + +#[derive(Debug, Clone)] +pub enum SearchResult { + Existing(Goal), + Create { name: String, is_reward: bool }, +} + +// ── Timer / Pending ────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct TimerState { + pub label: String, + pub goal_id: u64, + pub remaining: u64, + pub total: u64, + pub is_reward: bool, + pub started_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct PendingSession { + pub label: String, + pub goal_id: u64, + pub total: u64, + pub is_reward: bool, + pub started_at: DateTime, +} diff --git a/success-core/src/ui.rs b/success-core/src/ui.rs new file mode 100644 index 0000000..a5f9287 --- /dev/null +++ b/success-core/src/ui.rs @@ -0,0 +1,613 @@ +use chrono::Local; +use ratatui::layout::{Constraint, Direction, Layout, Position}; +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}; + +// ── View items ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct ViewItem { + pub label: String, + pub kind: ViewItemKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewItemKind { + RunningTimer, + Existing(SessionKind, usize), + AddSession, + AddReward, +} + +fn build_timer_view_items(timer: &TimerState, _width: usize) -> Vec { + let started_local = timer.started_at.with_timezone(&Local).format("%H:%M"); + let info_line = format!( + "[*] {} ({}s left) [started {}]", + timer.label, timer.remaining, started_local + ); + + vec![ViewItem { + label: info_line, + kind: ViewItemKind::RunningTimer, + }] +} + +fn get_formatted_session_time_range(n: &SessionView) -> String { + let start = chrono::DateTime::from_timestamp(n.start_at, 0) + .map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string()) + .unwrap_or_else(|| "??:??".to_string()); + let end = chrono::DateTime::from_timestamp(n.end_at, 0) + .map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string()) + .unwrap_or_else(|| "??:??".to_string()); + format!("{start}-{end}") +} + +pub fn build_view_items(state: &AppState, width: usize) -> Vec { + let mut items = Vec::new(); + for (idx, n) in state.nodes.iter().enumerate() { + let prefix = match n.kind { + SessionKind::Goal => "[S]", + SessionKind::Reward => "[R]", + }; + let duration = (n.end_at - n.start_at) / 60; + let times = get_formatted_session_time_range(n); + let unit = goal_quantity_name(state, n.goal_id) + .map(|u| format!(" {u}")) + .unwrap_or_default(); + let qty_label = n + .quantity + .map(|q| format!("{q}{unit} in ")) + .unwrap_or_default(); + items.push(ViewItem { + label: format!("{prefix} {} ({qty_label}{duration}m) [{times}]", n.name), + kind: ViewItemKind::Existing(n.kind, idx), + }); + } + + if let Some(timer) = &state.timer { + if state.current_day == Local::now().date_naive() { + items.extend(build_timer_view_items(timer, width)); + } + } + + if state.timer.is_none() && state.current_day == Local::now().date_naive() { + if let Mode::QuantityDoneInput { + ref goal_name, + ref quantity_name, + } = state.mode + { + let quantity_name = quantity_name.as_deref().unwrap_or("quantity"); + items.push(ViewItem { + label: format!("[+] Insert {quantity_name} for {goal_name}"), + kind: ViewItemKind::AddSession, + }); + } else if state + .nodes + .last() + .map(|n| n.kind == SessionKind::Goal) + .unwrap_or(false) + { + items.push(ViewItem { + label: "[+] Receive reward".to_string(), + kind: ViewItemKind::AddReward, + }); + } else { + items.push(ViewItem { + label: "[+] Work on new goal".to_string(), + kind: ViewItemKind::AddSession, + }); + } + } + items +} + +// ── Main UI ────────────────────────────────────────────────────────────── + +/// Render the entire UI. +/// +/// `header_text` is the text shown in the header bar (e.g. "Archive: /path (open with 'o')"). +pub fn ui(f: &mut ratatui::Frame, state: &AppState, header_text: &str) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(5), // Body + ]) + .split(f.area()); + + let body_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(chunks[1]); + + let dimmed = get_dimmed_style(&state.mode); + + let header = Paragraph::new(Line::from(header_text.to_string())) + .block( + Block::default() + .borders(Borders::ALL) + .title("Success CLI") + .style(dimmed), + ) + .style(dimmed); + + f.render_widget(header, chunks[0]); + + let (list_area, gauge_area) = if state.timer.is_some() + && state.current_day == Local::now().date_naive() + && body_chunks[0].height > 4 + { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(body_chunks[0]); + (areas[0], Some(areas[1])) + } else { + (body_chunks[0], None) + }; + + let list_width = list_area.width.saturating_sub(4) as usize; + + let items = build_view_items(state, list_width); + + let list_items: Vec = items + .iter() + .enumerate() + .map(|(i, item)| { + let is_selected = i == state.selected; + let is_dlg_open = is_dialog_open(&state.mode); + + let is_quantity_input = matches!(state.mode, Mode::QuantityDoneInput { .. }); + let is_insert_item = matches!( + item.kind, + ViewItemKind::AddSession | ViewItemKind::AddReward + ); + + let should_highlight = is_selected || (is_quantity_input && is_insert_item); + + let label_style = if should_highlight { + Style::default() + .fg(style::BLUE) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let label_lines: Vec<&str> = item.label.lines().collect(); + let mut lines: Vec = label_lines + .iter() + .flat_map(|line_text| { + wrap_text(line_text, list_width) + .into_iter() + .enumerate() + .map(|(wrap_idx, wrapped)| { + let text = if wrap_idx == 0 { + wrapped + } else { + format!(" {}", wrapped) + }; + Line::from(vec![Span::styled(text, label_style)]) + }) + .collect::>() + }) + .collect(); + + if is_selected && !is_dlg_open { + let target_line_idx = if lines.is_empty() { 0 } else { lines.len() - 1 }; + + let hint_spans = match item.kind { + ViewItemKind::AddSession => { + vec![Span::styled( + " (Enter: add session)", + Style::default().fg(style::GRAY_DIM), + )] + } + ViewItemKind::AddReward => { + vec![Span::styled( + " (Enter: receive reward)", + Style::default().fg(style::GRAY_DIM), + )] + } + ViewItemKind::Existing(_, _) | ViewItemKind::RunningTimer => { + if state.focused_block == FocusedBlock::SessionsList { + vec![Span::styled( + " (e: edit)", + Style::default().fg(style::GRAY_DIM), + )] + } else { + vec![] + } + } + }; + + if !hint_spans.is_empty() { + if let Some(target_line) = lines.get_mut(target_line_idx) { + target_line.spans.extend(hint_spans); + } + } + } + + ListItem::new(lines) + }) + .collect(); + + let title = format!( + "Sessions of {} (←→ day • ↑↓ move)", + format_day_label(state.current_day) + ); + + let sessions_block = Block::default() + .borders(Borders::ALL) + .title(title) + .style(dimmed) + .border_style(get_block_style( + state.focused_block, + FocusedBlock::SessionsList, + &state.mode, + )); + + let list = List::new(list_items).block(sessions_block).style(dimmed); + + let mut stateful = ListState::default(); + if !items.is_empty() { + stateful.select(Some(state.selected.min(items.len() - 1))); + } + f.render_stateful_widget(list, list_area, &mut stateful); + + if let (Some(timer), Some(gauge_area)) = (&state.timer, gauge_area) { + let pct = if timer.total == 0 { + 0.0 + } else { + 1.0 - (timer.remaining as f64 / timer.total as f64) + }; + let ratio = pct.clamp(0.0, 1.0); + let label = format!("{:.0}%", ratio * 100.0); + + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .title("Timer Progress") + .style(dimmed) + .border_style(get_block_style( + state.focused_block, + FocusedBlock::SessionsList, + &state.mode, + )), + ) + .gauge_style(Style::default().fg(style::BLUE)) + .ratio(ratio) + .label(label) + .use_unicode(true); + + f.render_widget(gauge, gauge_area); + } + + // ── Notes panel ── + let notes_title = if matches!(state.mode, Mode::NotesEdit) { + "Notes (Esc to stop editing)" + } else { + "Notes" + }; + + let notes_block = Block::default() + .borders(Borders::ALL) + .title(notes_title) + .style(dimmed) + .border_style(get_block_style( + state.focused_block, + FocusedBlock::Notes, + &state.mode, + )); + + 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]); + + 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)); + } + } else { + let notes_para = Paragraph::new("Select a task to view notes") + .block(notes_block) + .style(dimmed); + f.render_widget(notes_para, body_chunks[1]); + } + + render_goal_selector_dialog(f, state); + render_goal_form_dialog(f, state); + render_duration_input_dialog(f, state); + render_quantity_input_dialog(f, state); +} + +// ── Dialogs ────────────────────────────────────────────────────────────── + +fn render_goal_selector_dialog(f: &mut ratatui::Frame, state: &AppState) { + if !matches!(state.mode, Mode::AddSession | Mode::AddReward) { + return; + } + + let popup_area = centered_rect(80, 70, f.area()); + f.render_widget(ratatui::widgets::Clear, popup_area); + + let prompt = if matches!(state.mode, Mode::AddReward) { + "Choose reward" + } else { + "Choose goal" + }; + + let popup_block = Block::default() + .borders(Borders::ALL) + .title(prompt) + .border_style(Style::default().fg(style::BLUE)); + + let inner = popup_block.inner(popup_area); + f.render_widget(popup_block, popup_area); + + let dialog_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Input + Constraint::Min(1), // List + Constraint::Length(1), // Help + ]) + .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]); + + let results = search_results(state); + let list_items: Vec = results + .iter() + .map(|(label, _)| ListItem::new(Line::from(label.clone()))) + .collect(); + + let mut list_state = ListState::default(); + if !results.is_empty() { + list_state.select(Some(state.search_selected.min(results.len() - 1))); + } + + let list = List::new(list_items).highlight_style( + Style::default() + .fg(style::BLUE) + .add_modifier(Modifier::BOLD), + ); + + f.render_stateful_widget(list, dialog_chunks[1], &mut list_state); + + f.render_widget( + Paragraph::new("Type to search • ↑↓ select • Enter pick • Esc cancel") + .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) { + let Some(form) = &state.form_state else { + return; + }; + + let area = centered_rect(80, 70, f.area()); + f.render_widget(ratatui::widgets::Clear, area); + + let title = if form.is_reward { + "Create new reward" + } else { + "Create new goal" + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(style::BLUE)); + + let inner = block.inner(area); + f.render_widget(block, area); + + #[cfg(feature = "web")] + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Goal Name + Constraint::Length(1), // Quantity + Constraint::Length(1), // Commands + Constraint::Length(1), // Web note + Constraint::Length(1), // Spacer + Constraint::Min(1), // Filler + Constraint::Length(1), // Help text + ]) + .split(inner); + #[cfg(not(feature = "web"))] + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Goal Name + Constraint::Length(1), // Quantity + Constraint::Length(1), // Commands + Constraint::Length(1), // Spacer + Constraint::Min(1), // Filler + Constraint::Length(1), // Help text + ]) + .split(inner); + + #[cfg(feature = "web")] + { + let web_note = Paragraph::new( + "Commands are run when starting a session so that apps you used for a certain task are always opened — only available in full version", + ) + .style(Style::default().fg(style::YELLOW)); + f.render_widget(web_note, layout[3]); + } + + let name_prefix = "Name: "; + let name_style = if form.current_field == FormField::GoalName { + Style::default().fg(style::BLUE) + } 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]); + + let qty_prefix = "Quantity name (optional): "; + let qty_style = if form.current_field == FormField::Quantity { + Style::default().fg(style::BLUE) + } 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]); + + let cmd_prefix = "Commands (optional, separated by ;): "; + let cmd_style = if form.current_field == FormField::Commands { + Style::default().fg(style::BLUE) + } else { + Style::default() + }; + let cmd_line = format!("{}{}", cmd_prefix, form.commands.value); + f.render_widget(Paragraph::new(cmd_line).style(cmd_style), layout[2]); + + #[cfg(feature = "web")] + let help_text = + "↑↓/Tab: navigate • Enter: create • Esc: cancel • Note: Commands only work in full version"; + #[cfg(not(feature = "web"))] + let help_text = "↑↓/Tab: navigate • Enter: create • Esc: cancel"; + let help = Paragraph::new(help_text).style(Style::default().fg(style::GRAY_DIM)); + #[cfg(feature = "web")] + 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) { + let Mode::DurationInput { ref goal_name, .. } = state.mode else { + return; + }; + + let area = centered_rect_fixed_height(60, 4, f.area()); + f.render_widget(ratatui::widgets::Clear, area); + + let title = format!("Duration for {} (e.g., 30m, 1h)", goal_name); + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(style::BLUE)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Input + Constraint::Min(1), // Help + ]) + .split(inner); + + let input_line = format!("> {}", state.duration_input.value); + f.render_widget(Paragraph::new(input_line), layout[0]); + + 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) { + let Mode::QuantityDoneInput { + ref goal_name, + ref quantity_name, + } = state.mode + else { + return; + }; + + let area = centered_rect_fixed_height(60, 4, f.area()); + f.render_widget(ratatui::widgets::Clear, area); + + let title = if let Some(name) = quantity_name { + format!("{} done for {} (blank to skip)", name, goal_name) + } else { + format!("Quantity done for {} (blank to skip)", goal_name) + }; + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(style::BLUE)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Input + Constraint::Min(1), // Help + ]) + .split(inner); + + let input_line = format!("> {}", state.quantity_input.value); + f.render_widget(Paragraph::new(input_line), layout[0]); + + 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-core/src/utils.rs b/success-core/src/utils.rs new file mode 100644 index 0000000..192cfb4 --- /dev/null +++ b/success-core/src/utils.rs @@ -0,0 +1,238 @@ +use chrono::{Local, NaiveDate}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +use crate::app::AppState; +use crate::style; +use crate::types::*; +use crate::ui::{build_view_items, ViewItemKind}; + +pub fn is_dialog_open(mode: &Mode) -> bool { + matches!( + mode, + Mode::AddSession + | Mode::AddReward + | Mode::GoalForm + | Mode::QuantityDoneInput { .. } + | Mode::DurationInput { .. } + ) +} + +pub fn get_block_style( + current: FocusedBlock, + target: FocusedBlock, + mode: &Mode, +) -> ratatui::style::Style { + use ratatui::style::Style; + if !is_dialog_open(mode) && current == target { + Style::default().fg(style::BLUE) + } else { + Style::default() + } +} + +pub fn get_dimmed_style(mode: &Mode) -> ratatui::style::Style { + use ratatui::style::Style; + if is_dialog_open(mode) { + Style::default().fg(style::GRAY_DIM) + } else { + Style::default() + } +} + +pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +pub fn centered_rect_fixed_height(percent_x: u16, height: u16, r: Rect) -> Rect { + let vertical_pad = r.height.saturating_sub(height) / 2; + + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(vertical_pad), + Constraint::Length(height), + Constraint::Length(vertical_pad), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +pub fn wrap_text(text: &str, width: usize) -> Vec { + if width == 0 { + return vec![text.to_string()]; + } + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + if word.chars().count() > width { + let mut chars = word.chars(); + while chars.as_str().chars().count() > 0 { + let chunk: String = chars.by_ref().take(width).collect(); + if chunk.is_empty() { + break; + } + lines.push(chunk); + } + } else { + current = word.to_string(); + } + } else if current.chars().count() + 1 + word.chars().count() > width { + lines.push(std::mem::take(&mut current)); + if word.chars().count() > width { + let mut chars = word.chars(); + while chars.as_str().chars().count() > 0 { + let chunk: String = chars.by_ref().take(width).collect(); + if chunk.is_empty() { + break; + } + lines.push(chunk); + } + } else { + current = word.to_string(); + } + } else { + current.push(' '); + current.push_str(word); + } + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +pub fn parse_duration(input: &str) -> Option { + let input = input.trim(); + if input.is_empty() { + return None; + } + + let mut total_seconds = 0; + let mut current_digits = String::new(); + + for c in input.chars() { + if c.is_ascii_digit() { + current_digits.push(c); + } else if c.is_alphabetic() { + if current_digits.is_empty() { + continue; + } + let val = current_digits.parse::().ok()?; + current_digits.clear(); + match c.to_ascii_lowercase() { + 'h' => total_seconds += val * 3600, + 'm' => total_seconds += val * 60, + 's' => total_seconds += val, + _ => return None, + } + } else if c.is_whitespace() { + // ignore + } else { + return None; + } + } + + if !current_digits.is_empty() { + let val = current_digits.parse::().ok()?; + total_seconds += val * 60; // Assume minutes + } + + if total_seconds == 0 { + None + } else { + Some(total_seconds) + } +} + +pub fn format_day_label(day: NaiveDate) -> String { + let today = Local::now().date_naive(); + let base = day.format("%Y-%m-%d").to_string(); + let diff = (today - day).num_days(); + if diff == 0 { + format!("{base}, today") + } else { + format!("{base}, -{diff}d") + } +} + +pub fn format_duration_suggestion(duration_mins: i64) -> String { + if duration_mins == 0 { + return "1s".to_string(); + } + let mins = duration_mins.max(0); + if mins == 0 { + return "1s".to_string(); + } + let h = mins / 60; + let m = mins % 60; + if h > 0 && m > 0 { + format!("{}h {}m", h, m) + } else if h > 0 { + format!("{}h", h) + } else { + format!("{}m", m) + } +} + +pub fn parse_commands_input(input: &str) -> Vec { + input + .split([';', '\n']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + +pub fn parse_optional_u32(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } +} + +pub fn selected_goal_id(state: &AppState) -> Option { + let items = build_view_items(state, 20); + match items.get(state.selected).map(|v| v.kind) { + Some(ViewItemKind::RunningTimer) => state.timer.as_ref().map(|t| t.goal_id), + Some(ViewItemKind::Existing(_, idx)) => state.nodes.get(idx).map(|n| n.goal_id), + _ => state.timer.as_ref().map(|t| t.goal_id), + } +} + +pub fn goal_quantity_name(state: &AppState, goal_id: u64) -> Option { + state + .goals + .iter() + .find(|g| g.id == goal_id) + .and_then(|g| g.quantity_name.clone()) +} diff --git a/success-web/Cargo.toml b/success-web/Cargo.toml new file mode 100644 index 0000000..1579680 --- /dev/null +++ b/success-web/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "success-web" +version = "0.1.0" +edition = "2021" +description = "Web-based goal tracker built with Ratzilla" + +[dependencies] +chrono = { version = "0.4", features = ["serde", "wasmbind"] } +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 diff --git a/success-web/index.html b/success-web/index.html new file mode 100644 index 0000000..aca0f41 --- /dev/null +++ b/success-web/index.html @@ -0,0 +1,39 @@ + + + + + + + + Success — Goal Tracker + + + + + + diff --git a/success-web/src/main.rs b/success-web/src/main.rs new file mode 100644 index 0000000..77d5b85 --- /dev/null +++ b/success-web/src/main.rs @@ -0,0 +1,230 @@ +use std::{cell::RefCell, rc::Rc}; + +use chrono::{NaiveDate, TimeZone, Utc}; +use ratzilla::{ + backend::webgl2::FontAtlasConfig, + backend::webgl2::WebGl2BackendOptions, + event::{KeyCode, KeyEvent}, + WebGl2Backend, WebRenderer, +}; +use success_core::app::AppState; +use success_core::key_event::{AppKeyCode, AppKeyEvent}; +use success_core::notes::refresh_notes_for_selection; +use success_core::ui; +use successlib::Goal; + +// ── Key event conversion ───────────────────────────────────────────────── + +fn convert_key(key: &KeyEvent) -> AppKeyEvent { + let code = match key.code { + KeyCode::Char(c) => AppKeyCode::Char(c), + KeyCode::Backspace => AppKeyCode::Backspace, + KeyCode::Enter => AppKeyCode::Enter, + KeyCode::Left => AppKeyCode::Left, + KeyCode::Right => AppKeyCode::Right, + KeyCode::Up => AppKeyCode::Up, + KeyCode::Down => AppKeyCode::Down, + KeyCode::Tab => { + if key.shift { + AppKeyCode::BackTab + } else { + AppKeyCode::Tab + } + } + KeyCode::Delete => AppKeyCode::Delete, + KeyCode::Home => AppKeyCode::Home, + KeyCode::End => AppKeyCode::End, + KeyCode::Esc => AppKeyCode::Esc, + _ => AppKeyCode::Other, + }; + AppKeyEvent { + code, + ctrl: key.ctrl, + alt: key.alt, + shift: key.shift, + } +} + +// ── Seed data (only when storage is empty) ─────────────────────────────── + +fn add_seed_session( + archive: &str, + goal: &Goal, + start: chrono::DateTime, + duration_mins: u32, + is_reward: bool, + quantity: Option, +) { + let _ = successlib::add_session( + archive.to_string(), + goal.id, + goal.name.clone(), + start.timestamp(), + duration_mins.saturating_mul(60), + is_reward, + quantity, + ); +} + +fn seed_sessions_for_day( + archive: &str, + day: NaiveDate, + learn: &Goal, + exercise: &Goal, + read: &Goal, + movie: &Goal, + clean: &Goal, + is_prev: bool, +) { + if is_prev { + let start1 = Utc.from_utc_datetime(&day.and_hms_opt(8, 30, 0).unwrap()); + let start2 = Utc.from_utc_datetime(&day.and_hms_opt(9, 10, 0).unwrap()); + let start3 = Utc.from_utc_datetime(&day.and_hms_opt(11, 0, 0).unwrap()); + let start4 = Utc.from_utc_datetime(&day.and_hms_opt(12, 15, 0).unwrap()); + + add_seed_session(archive, read, start1, 30, false, Some(10)); + add_seed_session(archive, movie, start2, 20, true, None); + add_seed_session(archive, clean, start3, 60, false, Some(1)); + add_seed_session(archive, movie, start4, 30, true, None); + return; + } + + let start1 = Utc.from_utc_datetime(&day.and_hms_opt(9, 0, 0).unwrap()); + let start2 = Utc.from_utc_datetime(&day.and_hms_opt(10, 15, 0).unwrap()); + let start3 = Utc.from_utc_datetime(&day.and_hms_opt(14, 0, 0).unwrap()); + let start4 = Utc.from_utc_datetime(&day.and_hms_opt(15, 10, 0).unwrap()); + + add_seed_session(archive, learn, start1, 60, false, Some(2)); + add_seed_session(archive, movie, start2, 30, true, None); + add_seed_session(archive, exercise, start3, 60, false, Some(30)); + add_seed_session(archive, movie, start4, 30, true, None); +} + +fn seed_if_empty(state: &mut AppState) { + if !state.goals.is_empty() { + return; + } + + let archive = state.archive_path.clone(); + + let learn = successlib::add_goal( + archive.clone(), + "Learn Rust".to_string(), + false, + vec![ + "code .".to_string(), + "open https://doc.rust-lang.org".to_string(), + ], + Some("chapters".to_string()), + ) + .expect("Failed to add seed goal"); + + let exercise = successlib::add_goal( + archive.clone(), + "Exercise".to_string(), + false, + vec![], + Some("minutes".to_string()), + ) + .expect("Failed to add seed goal"); + + let read = successlib::add_goal( + archive.clone(), + "Read a book".to_string(), + false, + vec![], + Some("pages".to_string()), + ) + .expect("Failed to add seed goal"); + + let movie = successlib::add_goal( + archive.clone(), + "Watch a movie".to_string(), + true, + vec![], + None, + ) + .expect("Failed to add seed goal"); + + let clean = successlib::add_goal( + archive.clone(), + "Clean the house".to_string(), + false, + vec![], + Some("rooms".to_string()), + ) + .expect("Failed to add seed goal"); + + let _ = successlib::edit_note( + archive.clone(), + learn.id, + "Follow chapters 1-3 and take notes on ownership and borrowing.\n".to_string(), + ); + let _ = successlib::edit_note( + archive.clone(), + exercise.id, + "Warmup + 30 minutes of cardio.\n".to_string(), + ); + let _ = successlib::edit_note( + archive.clone(), + read.id, + "Read 10 pages and summarize the key ideas.\n".to_string(), + ); + let _ = successlib::edit_note( + archive.clone(), + movie.id, + "Reward: pick a feel-good movie.\n".to_string(), + ); + let _ = successlib::edit_note( + archive.clone(), + clean.id, + "Focus on 1 room and reset the space.\n".to_string(), + ); + + let today = state.current_day; + seed_sessions_for_day(&archive, today, &learn, &exercise, &read, &movie, &clean, false); + if let Some(prev_day) = today.pred_opt() { + seed_sessions_for_day(&archive, prev_day, &learn, &exercise, &read, &movie, &clean, true); + } + + state.goals = successlib::list_goals(archive.clone(), None).unwrap_or_default(); + state.nodes = successlib::list_day_sessions( + archive, + today.format("%Y-%m-%d").to_string(), + ) + .unwrap_or_default(); + state.selected = ui::build_view_items(state, 20).len().saturating_sub(1); + refresh_notes_for_selection(state); +} + +// ── Main entry point ───────────────────────────────────────────────────── + +fn main() { + console_error_panic_hook::set_once(); + + let mut app_state = AppState::new("success".to_string()); + seed_if_empty(&mut app_state); + let state = Rc::new(RefCell::new(app_state)); + + let backend = WebGl2Backend::new_with_options( + WebGl2BackendOptions::new() + .font_atlas_config(FontAtlasConfig::dynamic(&["JetBrains Mono"], 16.0)), + ) + .expect("Failed to create WebGl2Backend"); + let terminal = ratzilla::ratatui::Terminal::new(backend).expect("Failed to create terminal"); + + let state_key = Rc::clone(&state); + terminal.on_key_event(move |key| { + let mut s = state_key.borrow_mut(); + let app_key = convert_key(&key); + s.handle_key(app_key); + }); + + let state_draw = Rc::clone(&state); + terminal.draw_web(move |f| { + let mut s = state_draw.borrow_mut(); + s.tick(); + let header = "Work on goals to receive rewards — Web demo — Data not persisted. Download full version: https://github.com/Calonca/success-cli".to_string(); + ui::ui(f, &s, &header); + }); +}