diff --git a/Cargo.lock b/Cargo.lock index 1a5e1ee..8e141bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1728,7 +1728,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "torromail-cache" -version = "0.10.3" +version = "0.10.4" dependencies = [ "rusqlite", "torromail-core", @@ -1736,7 +1736,7 @@ dependencies = [ [[package]] name = "torromail-control" -version = "0.10.3" +version = "0.10.4" dependencies = [ "getrandom 0.2.17", "keyring", @@ -1747,11 +1747,11 @@ dependencies = [ [[package]] name = "torromail-core" -version = "0.10.3" +version = "0.10.4" [[package]] name = "torromail-discovery" -version = "0.10.3" +version = "0.10.4" dependencies = [ "getrandom 0.2.17", "torromail-control", @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "torromail-imap-tls" -version = "0.10.3" +version = "0.10.4" dependencies = [ "rustls", "torromail-core", @@ -1769,7 +1769,7 @@ dependencies = [ [[package]] name = "torromail-mcp" -version = "0.10.3" +version = "0.10.4" dependencies = [ "security-framework", "serde_json", @@ -1783,7 +1783,7 @@ dependencies = [ [[package]] name = "torromail-oauth" -version = "0.10.3" +version = "0.10.4" dependencies = [ "serde_json", "ureq", @@ -1791,7 +1791,7 @@ dependencies = [ [[package]] name = "torromail-tui" -version = "0.10.3" +version = "0.10.4" dependencies = [ "chrono", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 48b58d8..2b434d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.10.3" +version = "0.10.4" edition = "2024" license = "MIT OR Apache-2.0" authors = ["TorroMail contributors"] diff --git a/crates/torromail-tui/src/app.rs b/crates/torromail-tui/src/app.rs index a57e9cd..ce9fd76 100644 --- a/crates/torromail-tui/src/app.rs +++ b/crates/torromail-tui/src/app.rs @@ -1,12 +1,13 @@ //! What the user is looking at, and what a key does to it. -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; use torromail_control::policy::ClientAccountAccess; use torromail_control::{CacheLevel, ConnectionSecurity, FolderRule, MailAccount, PermissionPreset, ReadAccess}; use crate::data::Snapshot; use crate::i18n::Lang; +use crate::input; use crate::wizard::{Outcome, Secret, Wizard}; /// The same seven places, in the same order, as the macOS app's sidebar. @@ -155,6 +156,9 @@ pub struct App { /// The account being edited, apart from the stored one until it is saved. pub draft: Option, pub cursor: usize, + /// Where the caret sits in the text row under the cursor, in characters + /// from its end. + pub caret_back: usize, pub confirm_discard: bool, pub message: Option, /// What the event loop should do next; it reports back through @@ -201,6 +205,7 @@ impl App { focus: Focus::List, draft: None, cursor: 0, + caret_back: 0, confirm_discard: false, message: None, request: None, @@ -257,6 +262,7 @@ impl App { self.draft = Some(view.account.clone()); self.focus = Focus::Detail; self.cursor = 0; + self.caret_back = 0; self.detail_scroll = 0; } @@ -320,18 +326,17 @@ impl App { }; } } - KeyCode::Char(character) => { + code => { + let mut caret_back = self.caret_back; if let Some(text) = self.connection_text() { - text.push(character); - } - } - KeyCode::Backspace => { - if let Some(text) = self.connection_text() { - text.pop(); + input::edit(text, &mut caret_back, code); } + self.caret_back = caret_back; + return; } - _ => {} } + // Another row takes the caret to its end. + self.caret_back = 0; } /// Row 0 is the per-folder switch, then the folders, then the five roles. @@ -524,7 +529,7 @@ impl App { pub fn on_key(&mut self, key: KeyEvent) { if let Some(wizard) = &mut self.wizard { - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + if input::is_command(&key) && key.code == KeyCode::Char('c') { self.should_quit = true; return; } @@ -536,7 +541,7 @@ impl App { } return; } - if key.modifiers.contains(KeyModifiers::CONTROL) { + if input::is_command(&key) { match key.code { // Quitting over unsaved changes asks first, like leaving does. KeyCode::Char('c' | 'q') if self.is_dirty() && !self.confirm_discard => self.confirm_discard = true, diff --git a/crates/torromail-tui/src/input.rs b/crates/torromail-tui/src/input.rs new file mode 100644 index 0000000..2a9171e --- /dev/null +++ b/crates/torromail-tui/src/input.rs @@ -0,0 +1,108 @@ +//! Typing into a one-line field: the keys that edit text, the caret they +//! move, and which keys are commands rather than characters. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Whether a key is a Ctrl shortcut rather than something typed. +/// +/// On Windows AltGr arrives as Ctrl+Alt, so a German `@` (AltGr+Q), `€`, `{` +/// or `\` carries Ctrl too. The character has already been resolved by +/// then; treating it as a shortcut would make those characters untypable. +#[must_use] +pub fn is_command(key: &KeyEvent) -> bool { + key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) +} + +/// Applies an editing key to `text`. The caret is counted in characters from +/// the end, so 0 — the default, and where a freshly focused field wants it — +/// is behind the last character. Returns whether the key was an editing key. +pub fn edit(text: &mut String, caret_back: &mut usize, code: KeyCode) -> bool { + let length = text.chars().count(); + *caret_back = (*caret_back).min(length); + let at = length - *caret_back; + match code { + KeyCode::Char(character) => text.insert(byte_index(text, at), character), + KeyCode::Backspace if at > 0 => { + text.remove(byte_index(text, at - 1)); + } + KeyCode::Delete if *caret_back > 0 => { + text.remove(byte_index(text, at)); + *caret_back -= 1; + } + KeyCode::Left => *caret_back = (*caret_back + 1).min(length), + KeyCode::Right => *caret_back = caret_back.saturating_sub(1), + KeyCode::Home => *caret_back = length, + KeyCode::End => *caret_back = 0, + KeyCode::Backspace | KeyCode::Delete => {} + _ => return false, + } + true +} + +/// `shown` split at the caret, for drawing: what is before it, the character +/// under it (a space at the end), and what follows. +#[must_use] +pub fn split(shown: &str, caret_back: usize) -> (String, String, String) { + let length = shown.chars().count(); + let at = length - caret_back.min(length); + let before = shown.chars().take(at).collect(); + let under = shown.chars().nth(at).map_or_else(|| " ".to_owned(), String::from); + let after = shown.chars().skip(at + 1).collect(); + (before, under, after) +} + +fn byte_index(text: &str, chars: usize) -> usize { + text.char_indices().nth(chars).map_or(text.len(), |(index, _)| index) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(keys: &[KeyCode]) -> (String, usize) { + let (mut text, mut caret) = (String::new(), 0); + for key in keys { + edit(&mut text, &mut caret, *key); + } + (text, caret) + } + + #[test] + fn typing_inserts_at_the_caret() { + use KeyCode::{Char, Left}; + assert_eq!(run(&[Char('a'), Char('c'), Left, Char('b')]).0, "abc"); + } + + #[test] + fn deleting_works_on_both_sides_of_the_caret() { + use KeyCode::{Backspace, Char, Delete, Home, Left}; + assert_eq!(run(&[Char('a'), Char('b'), Char('c'), Left, Backspace]).0, "ac"); + assert_eq!(run(&[Char('a'), Char('b'), Home, Delete]), ("b".to_owned(), 1)); + } + + #[test] + fn multibyte_characters_are_one_step() { + use KeyCode::{Backspace, Char, Left}; + assert_eq!(run(&[Char('ü'), Char('€'), Left, Backspace]).0, "€"); + } + + #[test] + fn the_caret_stays_inside_the_text() { + use KeyCode::{Char, Left, Right}; + assert_eq!(run(&[Char('a'), Left, Left, Left]).1, 1); + assert_eq!(run(&[Char('a'), Right, Right]).1, 0); + } + + #[test] + fn altgr_is_typing_not_a_shortcut() { + let altgr = KeyEvent::new(KeyCode::Char('@'), KeyModifiers::CONTROL | KeyModifiers::ALT); + assert!(!is_command(&altgr)); + assert!(is_command(&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))); + } + + #[test] + fn split_marks_the_character_under_the_caret() { + assert_eq!(split("abc", 1), ("ab".to_owned(), "c".to_owned(), String::new())); + assert_eq!(split("abc", 0), ("abc".to_owned(), " ".to_owned(), String::new())); + } +} diff --git a/crates/torromail-tui/src/lib.rs b/crates/torromail-tui/src/lib.rs index 2d5e006..92847a7 100644 --- a/crates/torromail-tui/src/lib.rs +++ b/crates/torromail-tui/src/lib.rs @@ -10,6 +10,7 @@ pub mod autocheck; pub mod check; pub mod data; pub mod i18n; +pub mod input; pub mod rebuild; pub mod settings; pub mod status; diff --git a/crates/torromail-tui/src/main.rs b/crates/torromail-tui/src/main.rs index ee8c203..3ce22c0 100644 --- a/crates/torromail-tui/src/main.rs +++ b/crates/torromail-tui/src/main.rs @@ -74,7 +74,8 @@ fn main() -> std::io::Result<()> { } match event::poll(Duration::from_millis(250)) { Ok(true) => match event::read() { - Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => app.on_key(key), + // Windows reports releases too; a held key repeats as presses. + Ok(Event::Key(key)) if key.kind != KeyEventKind::Release => app.on_key(key), Ok(_) => {} Err(error) => break Err(error), }, diff --git a/crates/torromail-tui/src/ui/accounts.rs b/crates/torromail-tui/src/ui/accounts.rs index 45d7d61..e69d717 100644 --- a/crates/torromail-tui/src/ui/accounts.rs +++ b/crates/torromail-tui/src/ui/accounts.rs @@ -187,10 +187,8 @@ fn connection( lines.push(rule(lang.t("Connection"), width)); for (index, (label, value, is_text)) in rows.into_iter().enumerate() { let on = index == app.cursor; - let mut spans = vec![Span::styled(format!("{:<17} ", lang.t(label)), theme::muted()), Span::raw(value)]; - if on && is_text { - spans.push(Span::styled("█", Style::new().fg(theme::SILVER))); - } + let mut spans = vec![Span::styled(format!("{:<17} ", lang.t(label)), theme::muted())]; + spans.extend(super::caret_spans(value, (on && is_text).then_some(app.caret_back))); let line = Line::from(spans); lines.push(if on { line.style(theme::selected()) } else { line }); } diff --git a/crates/torromail-tui/src/ui/mod.rs b/crates/torromail-tui/src/ui/mod.rs index ba21812..e42aed2 100644 --- a/crates/torromail-tui/src/ui/mod.rs +++ b/crates/torromail-tui/src/ui/mod.rs @@ -179,6 +179,14 @@ pub(crate) fn field(label: &str, value: impl Into) -> Line<'static> { Line::from(vec![Span::styled(format!("{label:<17} "), theme::muted()), Span::raw(value.into())]) } +/// A field's text, with the caret drawn as a block over the character it sits +/// on when the field has focus. +pub(crate) fn caret_spans(shown: String, caret_back: Option) -> Vec> { + let Some(caret_back) = caret_back else { return vec![Span::raw(shown)] }; + let (before, under, after) = crate::input::split(&shown, caret_back); + vec![Span::raw(before), Span::styled(under, Style::new().fg(theme::SILVER).add_modifier(Modifier::REVERSED)), Span::raw(after)] +} + /// The dot and the word for an account's state — never the colour alone. pub(crate) fn health_mark(lang: Lang, health: &AccountHealth) -> (Span<'static>, &'static str) { match health { diff --git a/crates/torromail-tui/src/ui/wizard.rs b/crates/torromail-tui/src/ui/wizard.rs index bdc0014..3d285bc 100644 --- a/crates/torromail-tui/src/ui/wizard.rs +++ b/crates/torromail-tui/src/ui/wizard.rs @@ -45,14 +45,12 @@ pub fn draw(frame: &mut Frame<'_>, area: Rect, lang: Lang, wizard: &Wizard) { let input = |label: &'static str, value: &str, focused: bool, masked: bool| { let shown = if masked { "•".repeat(value.chars().count()) } else { value.to_owned() }; - let cursor = if focused { "█" } else { "" }; + let mut spans = + vec![Span::styled(if focused { "▌ " } else { "│ " }, Style::new().fg(if focused { theme::ACCENT } else { theme::LINE }))]; + spans.extend(super::caret_spans(shown, focused.then_some(wizard.caret_back))); vec![ Line::styled(lang.t(label), if focused { theme::bold() } else { theme::muted() }), - Line::from(vec![ - Span::styled(if focused { "▌ " } else { "│ " }, Style::new().fg(if focused { theme::ACCENT } else { theme::LINE })), - Span::raw(shown), - Span::styled(cursor, Style::new().fg(theme::SILVER)), - ]), + Line::from(spans), Line::default(), ] }; diff --git a/crates/torromail-tui/src/wizard.rs b/crates/torromail-tui/src/wizard.rs index c68e935..d7c8ddc 100644 --- a/crates/torromail-tui/src/wizard.rs +++ b/crates/torromail-tui/src/wizard.rs @@ -3,10 +3,12 @@ //! account comes to exist. A failed check returns to the sign-in step with //! everything typed still there. -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; use torromail_control::providers::{self, AuthPath, DiscoveredConfig}; use torromail_control::{ConnectionSecurity, MailAccount, PermissionPreset, enroll}; +use crate::input; + /// Typed text that must never show up in a debug print or a panic message. #[derive(Clone, Default, PartialEq, Eq)] pub struct Secret(pub String); @@ -64,6 +66,8 @@ pub struct Wizard { pub password: Secret, /// Which field has the cursor within the current step. pub field: usize, + /// Where the caret sits in that field, in characters from its end. + pub caret_back: usize, /// Whether the server fields are open for typing. pub manual: bool, pub provider_label: String, @@ -102,6 +106,7 @@ impl Default for Wizard { smtp_port: "587".to_owned(), password: Secret::default(), field: 0, + caret_back: 0, manual: false, provider_label: String::new(), provider: providers::PROVIDER_IMAP_SMTP, @@ -143,7 +148,17 @@ impl Wizard { } pub fn on_key(&mut self, key: KeyEvent) -> Outcome { - if key.modifiers.contains(KeyModifiers::CONTROL) { + let place = (self.step, self.manual, self.field); + let outcome = self.handle(key); + // Another field takes the caret to its end. + if (self.step, self.manual, self.field) != place { + self.caret_back = 0; + } + outcome + } + + fn handle(&mut self, key: KeyEvent) -> Outcome { + if input::is_command(&key) { if key.code == KeyCode::Char('d') && self.step == Step::SignIn { self.manual = !self.manual; // The cursor lands on the first server field, or back on the @@ -161,18 +176,16 @@ impl Wizard { self.field = (self.field + count - 1) % count; } KeyCode::Char(' ') if self.step == Step::Rights => self.preset = self.field, - KeyCode::Char(character) => { - self.error = None; - if let Some(text) = self.text() { - text.push(character); - } - } - KeyCode::Backspace => { - if let Some(text) = self.text() { - text.pop(); + code => { + let mut caret_back = self.caret_back; + if let Some(text) = self.text() + && input::edit(text, &mut caret_back, code) + && matches!(code, KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete) + { + self.error = None; } + self.caret_back = caret_back; } - _ => {} } if self.step == Step::Rights { self.preset = self.field; @@ -239,6 +252,7 @@ impl Wizard { self.looking_up = false; self.step = Step::SignIn; self.field = 0; + self.caret_back = 0; let Some(config) = found else { self.hint = Hint::Unknown; self.manual = true; diff --git a/crates/torromail-tui/tests/render.rs b/crates/torromail-tui/tests/render.rs index a2345dd..f517655 100644 --- a/crates/torromail-tui/tests/render.rs +++ b/crates/torromail-tui/tests/render.rs @@ -1197,3 +1197,21 @@ fn the_status_document_carries_the_glance_and_none_of_what_was_searched_for() { assert!(plain.contains("BROKEN") && plain.contains("Claude Code") && plain.contains("connected")); assert!(torromail_tui::status::plain(&Snapshot::default()).contains("nothing set up yet")); } + +#[test] +fn altgr_characters_and_arrow_keys_work_in_fields() { + let scene = scene("wizard-altgr"); + let mut app = App::new(Lang::De, scene.backend.load()); + press(&mut app, KeyCode::Char('n')); + // Windows sends AltGr+Q — a German @ — as Ctrl+Alt with the resolved character. + type_text(&mut app, "svenmailbox.org"); + for _ in 0.."mailbox.org".len() { + press(&mut app, KeyCode::Left); + } + app.on_key(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::CONTROL | KeyModifiers::ALT)); + press(&mut app, KeyCode::End); + press(&mut app, KeyCode::Backspace); + type_text(&mut app, "g"); + assert_eq!(app.wizard.as_ref().expect("open").email, "sven@mailbox.org"); + assert_shows(&render(&app), &["sven@mailbox.org"]); +}