From b099157a172268fa2706afd24e6c4fafdbb1b12e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 16 Aug 2026 19:42:44 +0000 Subject: [PATCH] fixed the multi-line note and some utf-8 stuff --- Cargo.toml | 4 ++ README.md | 18 +++++++- scripts/quality-gate.sh | 13 ++++++ src/app.rs | 97 +++++++++++++++++++++++++++++++++++++++-- src/ui.rs | 84 ++++++++++++++++++++++++++++++++--- 5 files changed, 206 insertions(+), 10 deletions(-) create mode 100755 scripts/quality-gate.sh diff --git a/Cargo.toml b/Cargo.toml index aa5b2c3..c67eafb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" description = "A category-driven personal information manager for the terminal" license = "MIT" +[lints.rust] +# Keep compiler warnings fatal for every Cargo entry point, not only CI. +warnings = "deny" + [dependencies] anyhow = "1.0" chrono = { version = "0.4", features = ["clock"] } diff --git a/README.md b/README.md index a7cb733..5b19b49 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,20 @@ cargo run -- notes.agnd --export ics --output tasks.ics cargo run -- imported.agnd --import tasks.ics ``` +## Development quality gate + +Compiler warnings are denied by project configuration. Before accepting any +change, run the complete gate: + +```sh +./scripts/quality-gate.sh +``` + +It requires clean `cargo fmt --check` output, runs Clippy across every target and +feature with `-D warnings`, executes all tests, builds warning-free documentation, +and produces the release binary. Any warning, formatting difference, test failure, +or build failure stops the gate. + ## Essential keys | Key | Action | @@ -89,7 +103,9 @@ cargo run -- imported.agnd --import tasks.ics | `q` | Quit | Inside forms, `Tab` moves between fields, `Enter` accepts, and `Esc` cancels. -Mouse clicks select rows, switch views, and activate the bottom command strip. +In the `F5` note editor, `Enter` inserts a line, arrows/Home/End move the cursor, +and `Ctrl-S` saves. Mouse clicks select rows, switch views, and activate the +bottom command strip. ## Designing live views diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh new file mode 100755 index 0000000..9c6ad15 --- /dev/null +++ b/scripts/quality-gate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Formatting has no warning level: --check makes any difference a hard failure. +cargo fmt --all -- --check + +# Keep the explicit flag even though Cargo.toml denies Rust warnings so direct +# and automated Clippy runs have the same, visible contract. +cargo clippy --all-targets --all-features -- -D warnings + +cargo test --all-targets --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features +cargo build --release --all-targets --all-features diff --git a/src/app.rs b/src/app.rs index 566e34f..99160f7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,8 @@ pub struct InputState { pub title: String, pub value: String, pub multiline: bool, + /// UTF-8 byte offset kept on a character boundary. + pub cursor: usize, } #[derive(Debug, Clone)] @@ -237,11 +239,13 @@ impl App { Ok(()) } fn open_input(&mut self, kind: InputKind, title: &str, value: String, multiline: bool) { + let cursor = value.len(); self.mode = Mode::Input(InputState { kind, title: title.into(), value, multiline, + cursor, }); } fn edit_selected(&mut self) { @@ -330,13 +334,22 @@ impl App { return self.accept_input(input); } match key.code { - KeyCode::Enter if input.multiline => input.value.push('\n'), + KeyCode::Enter if input.multiline => insert_at_cursor(&mut input, '\n'), KeyCode::Enter => return self.accept_input(input), - KeyCode::Backspace => { - input.value.pop(); + KeyCode::Backspace => delete_before_cursor(&mut input), + KeyCode::Delete => delete_at_cursor(&mut input), + KeyCode::Left => input.cursor = previous_boundary(&input.value, input.cursor), + KeyCode::Right => input.cursor = next_boundary(&input.value, input.cursor), + KeyCode::Home => input.cursor = line_start(&input.value, input.cursor), + KeyCode::End => input.cursor = line_end(&input.value, input.cursor), + KeyCode::Up if input.multiline => move_cursor_vertically(&mut input, -1), + KeyCode::Down if input.multiline => move_cursor_vertically(&mut input, 1), + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => input.cursor = 0, + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + input.cursor = input.value.len() } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - input.value.push(c) + insert_at_cursor(&mut input, c) } _ => {} } @@ -810,3 +823,79 @@ fn normalize_date(value: &str) -> Option { fn inside(r: Rect, x: u16, y: u16) -> bool { x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height } + +fn insert_at_cursor(input: &mut InputState, c: char) { + input.value.insert(input.cursor, c); + input.cursor += c.len_utf8(); +} + +fn delete_before_cursor(input: &mut InputState) { + let previous = previous_boundary(&input.value, input.cursor); + if previous != input.cursor { + input.value.drain(previous..input.cursor); + input.cursor = previous; + } +} + +fn delete_at_cursor(input: &mut InputState) { + let next = next_boundary(&input.value, input.cursor); + if next != input.cursor { + input.value.drain(input.cursor..next); + } +} + +fn previous_boundary(value: &str, cursor: usize) -> usize { + value[..cursor] + .char_indices() + .next_back() + .map_or(0, |(index, _)| index) +} + +fn next_boundary(value: &str, cursor: usize) -> usize { + value[cursor..] + .chars() + .next() + .map_or(cursor, |c| cursor + c.len_utf8()) +} + +fn line_start(value: &str, cursor: usize) -> usize { + value[..cursor].rfind('\n').map_or(0, |index| index + 1) +} + +fn line_end(value: &str, cursor: usize) -> usize { + value[cursor..] + .find('\n') + .map_or(value.len(), |index| cursor + index) +} + +fn byte_at_character(value: &str, start: usize, end: usize, column: usize) -> usize { + value[start..end] + .char_indices() + .nth(column) + .map_or(end, |(offset, _)| start + offset) +} + +fn move_cursor_vertically(input: &mut InputState, delta: isize) { + let start = line_start(&input.value, input.cursor); + let end = line_end(&input.value, input.cursor); + let column = input.value[start..input.cursor].chars().count(); + input.cursor = if delta < 0 { + if start == 0 { + input.cursor + } else { + let target_end = start - 1; + let target_start = input.value[..target_end] + .rfind('\n') + .map_or(0, |index| index + 1); + byte_at_character(&input.value, target_start, target_end, column) + } + } else if end == input.value.len() { + input.cursor + } else { + let target_start = end + 1; + let target_end = input.value[target_start..] + .find('\n') + .map_or(input.value.len(), |index| target_start + index); + byte_at_character(&input.value, target_start, target_end, column) + }; +} diff --git a/src/ui.rs b/src/ui.rs index b6b2828..fc823f9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -503,17 +503,40 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { } Mode::Input(input) => { let h = if input.multiline { 18 } else { 7 }; + let area = center(frame.area(), 78, h); + let inner = area.inner(Margin { + horizontal: 1, + vertical: 1, + }); + let before_cursor = &input.value[..input.cursor]; + let cursor_line = before_cursor.bytes().filter(|byte| *byte == b'\n').count(); + let current_line_start = before_cursor.rfind('\n').map_or(0, |index| index + 1); + let cursor_column = Line::from(&before_cursor[current_line_start..]).width(); + let vertical_scroll = + cursor_line.saturating_sub(inner.height.saturating_sub(1) as usize); + let horizontal_scroll = + cursor_column.saturating_sub(inner.width.saturating_sub(1) as usize); let shown = if input.value.is_empty() { - Span::styled("Type here…", Style::default().fg(Color::DarkGray)) + Text::from(Line::from(Span::styled( + "Type here…", + Style::default().fg(Color::DarkGray), + ))) } else { - Span::raw(input.value.clone()) + Text::from(input.value.as_str()) }; popup( frame, - center(frame.area(), 78, h), + area, &input.title, - Paragraph::new(Line::from(shown)).wrap(Wrap { trim: false }), + Paragraph::new(shown).scroll(( + vertical_scroll.min(u16::MAX as usize) as u16, + horizontal_scroll.min(u16::MAX as usize) as u16, + )), ); + frame.set_cursor_position(( + inner.x + cursor_column.saturating_sub(horizontal_scroll) as u16, + inner.y + cursor_line.saturating_sub(vertical_scroll) as u16, + )); } Mode::Properties(p) => { let names = [ @@ -841,7 +864,11 @@ fn center(area: Rect, width: u16, height: u16) -> Rect { mod tests { use super::*; use crate::{app::App, db::Database, model::ItemChanges}; - use ratatui::{Terminal, backend::TestBackend}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{ + Terminal, + backend::{Backend, TestBackend}, + }; use tempfile::tempdir; #[test] fn renders_main_workspace() { @@ -932,4 +959,51 @@ mod tests { assert!(screen.contains("Compact item")); assert!(screen.contains("When")); } + + #[test] + fn note_editor_renders_and_saves_entered_line_breaks() { + let d = tempdir().unwrap(); + let p = d.path().join("notes.agnd"); + let mut db = Database::open(&p).unwrap(); + db.add_item("Write release notes").unwrap(); + let mut app = App::new(db, p).unwrap(); + app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)) + .unwrap(); + for c in "first line".chars() { + app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) + .unwrap(); + } + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + for c in "second line".chars() { + app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) + .unwrap(); + } + + let backend = TestBackend::new(100, 30); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|frame| draw(frame, &mut app)).unwrap(); + let cursor = term.backend_mut().get_cursor_position().unwrap(); + let rows = term + .backend() + .buffer() + .content + .chunks(100) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>(); + let first_row = rows + .iter() + .position(|row| row.contains("first line")) + .unwrap(); + let second_row = rows + .iter() + .position(|row| row.contains("second line")) + .unwrap(); + assert_eq!(second_row, first_row + 1); + assert_eq!(cursor.y as usize, second_row); + + app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(app.items[0].note, "first line\nsecond line"); + } }