use ratatui::crossterm::event::{KeyCode, KeyEvent};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Text,
Secret,
Check,
Area,
}
pub const AREA_ROWS: usize = 7;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Field {
pub id: &'static str,
pub label: &'static str,
pub kind: Kind,
pub value: String,
pub checked: bool,
pub locked: Option<&'static str>,
pub cursor: usize,
}
impl Field {
pub fn text(id: &'static str, label: &'static str, value: &str) -> Field {
Field {
id,
label,
kind: Kind::Text,
value: value.to_owned(),
checked: false,
locked: None,
cursor: 0,
}
}
pub fn area(id: &'static str, label: &'static str, value: &str) -> Field {
Field {
kind: Kind::Area,
cursor: value.chars().count(),
..Field::text(id, label, value)
}
}
pub fn line_col(&self) -> (usize, usize) {
let before: String = self.value.chars().take(self.cursor).collect();
let line = before.matches('\n').count();
let col = before.rsplit('\n').next().map_or(0, |l| l.chars().count());
(line, col)
}
fn lines(&self) -> Vec<&str> {
self.value.split('\n').collect()
}
fn set_line_col(&mut self, line: usize, col: usize) {
let lines = self.lines();
let line = line.min(lines.len() - 1);
let before: usize = lines.iter().take(line).map(|l| l.chars().count() + 1).sum();
self.cursor = before + col.min(lines[line].chars().count());
}
fn byte_at(&self, chars: usize) -> usize {
self.value
.char_indices()
.nth(chars)
.map_or(self.value.len(), |(at, _)| at)
}
fn insert_at_cursor(&mut self, text: &str) {
let at = self.byte_at(self.cursor);
self.value.insert_str(at, text);
self.cursor += text.chars().count();
}
pub fn secret(id: &'static str, label: &'static str, value: &str) -> Field {
Field {
kind: Kind::Secret,
..Field::text(id, label, value)
}
}
pub fn check(id: &'static str, label: &'static str, checked: bool) -> Field {
Field {
kind: Kind::Check,
checked,
..Field::text(id, label, "")
}
}
pub fn locked(mut self, reason: &'static str) -> Field {
self.locked = Some(reason);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Purpose {
TokenCreate {
clone_of: Option<(String, String)>,
},
TokenEdit {
id: String,
name: String,
expires: String,
},
TopicCreate,
TopicEdit {
name: String,
was: Vec<(&'static str, String)>,
},
ProfileNew,
ProfileEdit {
original: String,
},
MessagePublish {
topic: String,
},
MessageReceive {
topic: String,
},
MessageVisibility {
topic: String,
id: String,
receipt: String,
},
}
impl Purpose {
pub fn title(&self) -> &'static str {
match self {
Purpose::TokenCreate { clone_of: None } => "editor.token-create",
Purpose::TokenCreate { .. } => "editor.token-clone",
Purpose::TokenEdit { .. } => "editor.token-edit",
Purpose::TopicCreate => "editor.topic-create",
Purpose::TopicEdit { .. } => "editor.topic-edit",
Purpose::ProfileNew => "editor.profile-new",
Purpose::ProfileEdit { .. } => "editor.profile-edit",
Purpose::MessagePublish { .. } => "editor.message-publish",
Purpose::MessageReceive { .. } => "editor.message-receive",
Purpose::MessageVisibility { .. } => "editor.message-visibility",
}
}
pub fn help(&self) -> &'static str {
if self.is_profile() {
"editor.help-profile"
} else if matches!(self, Purpose::MessageReceive { .. }) {
"editor.help-receive"
} else if matches!(self, Purpose::MessageVisibility { .. }) {
"editor.help-visibility"
} else if self.is_message() {
"editor.help-publish"
} else if self.is_topic() {
"editor.help-topic"
} else {
"editor.help-token"
}
}
pub fn is_topic(&self) -> bool {
matches!(self, Purpose::TopicCreate | Purpose::TopicEdit { .. })
}
pub fn is_message(&self) -> bool {
matches!(
self,
Purpose::MessagePublish { .. }
| Purpose::MessageReceive { .. }
| Purpose::MessageVisibility { .. }
)
}
pub fn is_profile(&self) -> bool {
matches!(self, Purpose::ProfileNew | Purpose::ProfileEdit { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editor {
pub purpose: Purpose,
pub fields: Vec<Field>,
pub focus: usize,
pub error: Option<String>,
pub note: Option<(bool, String)>,
pub busy: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
None,
Cancel,
Submit,
Test,
}
impl Editor {
pub fn new(purpose: Purpose, fields: Vec<Field>) -> Editor {
let mut editor = Editor {
purpose,
fields,
focus: 0,
error: None,
note: None,
busy: false,
};
if editor.fields.first().is_some_and(|f| f.locked.is_some()) {
editor.focus = editor.step(0, 1);
}
editor
}
pub fn field(&self, id: &str) -> Option<&Field> {
self.fields.iter().find(|f| f.id == id)
}
pub fn text(&self, id: &str) -> &str {
self.field(id).map_or("", |f| f.value.trim())
}
pub fn checked(&self, id: &str) -> bool {
self.field(id).is_some_and(|f| f.checked)
}
pub fn focus_on(&mut self, id: &str) {
if let Some(at) = self.fields.iter().position(|f| f.id == id) {
self.focus = at;
}
}
fn step(&self, from: usize, delta: i32) -> usize {
let mut at = from;
loop {
let next = i64::try_from(at).unwrap_or(0) + i64::from(delta);
if next < 0 || next >= i64::try_from(self.fields.len()).unwrap_or(0) {
return from;
}
at = next as usize;
if self.fields[at].locked.is_none() {
return at;
}
}
}
fn is_last(&self) -> bool {
self.step(self.focus, 1) == self.focus
}
pub fn insert(&mut self, text: &str) {
if let Some(field) = self.fields.get_mut(self.focus) {
if field.locked.is_some() || field.kind == Kind::Check {
return;
}
if field.kind == Kind::Area {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let clean: String = text
.chars()
.map(|c| if c == '\t' { ' ' } else { c })
.filter(|c| *c == '\n' || !c.is_control())
.collect();
field.insert_at_cursor(&clean);
} else {
field.value.extend(text.chars().filter(|c| !c.is_control()));
}
self.error = None;
}
}
fn area_key(&mut self, event: &KeyEvent) -> Option<()> {
let ctrl = crate::tui::keymap::ctrl_of(event);
let at = self.focus;
let field = self.fields.get_mut(at)?;
if field.kind != Kind::Area || field.locked.is_some() {
return None;
}
let (line, col) = field.line_col();
let last = field.lines().len() - 1;
match event.code {
KeyCode::Enter => field.insert_at_cursor("\n"),
KeyCode::Left => field.cursor = field.cursor.saturating_sub(1),
KeyCode::Right => field.cursor = (field.cursor + 1).min(field.value.chars().count()),
KeyCode::Home => field.set_line_col(line, 0),
KeyCode::End => field.set_line_col(line, usize::MAX),
KeyCode::Up if line > 0 => field.set_line_col(line - 1, col),
KeyCode::Down if line < last => field.set_line_col(line + 1, col),
KeyCode::Up | KeyCode::Down => return None,
KeyCode::Backspace if field.cursor > 0 => {
let to = field.byte_at(field.cursor);
let from = field.byte_at(field.cursor - 1);
field.value.replace_range(from..to, "");
field.cursor -= 1;
}
KeyCode::Delete if field.cursor < field.value.chars().count() => {
let from = field.byte_at(field.cursor);
let to = field.byte_at(field.cursor + 1);
field.value.replace_range(from..to, "");
}
KeyCode::Backspace | KeyCode::Delete => {}
KeyCode::Char('u') if ctrl => {
field.value.clear();
field.cursor = 0;
}
KeyCode::Char(c) if !ctrl => {
let mut buffer = [0u8; 4];
field.insert_at_cursor(c.encode_utf8(&mut buffer));
}
_ => return None,
}
self.error = None;
Some(())
}
pub fn handle(&mut self, event: &KeyEvent) -> Outcome {
if self.busy {
return Outcome::None;
}
let ctrl = crate::tui::keymap::ctrl_of(event);
let sends = matches!(event.code, KeyCode::Char('s' | 'S')) && ctrl;
if !sends && event.code != KeyCode::Esc && self.area_key(event).is_some() {
return Outcome::None;
}
match event.code {
KeyCode::Esc => return Outcome::Cancel,
KeyCode::Char('s' | 'S') if ctrl => return Outcome::Submit,
KeyCode::Char('t' | 'T') if ctrl && self.purpose.is_profile() => return Outcome::Test,
KeyCode::Tab | KeyCode::Down => self.focus = self.step(self.focus, 1),
KeyCode::BackTab | KeyCode::Up => self.focus = self.step(self.focus, -1),
KeyCode::Enter => {
if self.is_last() {
return Outcome::Submit;
}
self.focus = self.step(self.focus, 1);
}
KeyCode::Backspace => {
if let Some(field) = self.fields.get_mut(self.focus) {
if field.locked.is_none() && field.kind != Kind::Check {
field.value.pop();
self.error = None;
}
}
}
KeyCode::Char('u') if ctrl => {
if let Some(field) = self.fields.get_mut(self.focus) {
if field.locked.is_none() && field.kind != Kind::Check {
field.value.clear();
}
}
}
KeyCode::Char(' ')
if self
.fields
.get(self.focus)
.is_some_and(|f| f.kind == Kind::Check && f.locked.is_none()) =>
{
let field = &mut self.fields[self.focus];
field.checked = !field.checked;
self.error = None;
}
KeyCode::Char(c) if !ctrl => {
let mut buffer = [0u8; 4];
self.insert(c.encode_utf8(&mut buffer));
}
_ => {}
}
Outcome::None
}
}
#[cfg(test)]
mod tests;