Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
27 changes: 16 additions & 11 deletions crates/torromail-tui/src/app.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -155,6 +156,9 @@ pub struct App {
/// The account being edited, apart from the stored one until it is saved.
pub draft: Option<MailAccount>,
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<Message>,
/// What the event loop should do next; it reports back through
Expand Down Expand Up @@ -201,6 +205,7 @@ impl App {
focus: Focus::List,
draft: None,
cursor: 0,
caret_back: 0,
confirm_discard: false,
message: None,
request: None,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions crates/torromail-tui/src/input.rs
Original file line number Diff line number Diff line change
@@ -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()));
}
}
1 change: 1 addition & 0 deletions crates/torromail-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion crates/torromail-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
6 changes: 2 additions & 4 deletions crates/torromail-tui/src/ui/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
8 changes: 8 additions & 0 deletions crates/torromail-tui/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ pub(crate) fn field(label: &str, value: impl Into<String>) -> 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<usize>) -> Vec<Span<'static>> {
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 {
Expand Down
10 changes: 4 additions & 6 deletions crates/torromail-tui/src/ui/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]
};
Expand Down
Loading
Loading