fixed the multi-line note and some utf-8 stuff
This commit is contained in:
@@ -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"] }
|
||||
|
||||
18
README.md
18
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
|
||||
|
||||
|
||||
13
scripts/quality-gate.sh
Executable file
13
scripts/quality-gate.sh
Executable file
@@ -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
|
||||
97
src/app.rs
97
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<String> {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
84
src/ui.rs
84
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::<String>())
|
||||
.collect::<Vec<_>>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user