1use ratatui::crossterm::event::{KeyCode, KeyEvent};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Kind {
9 Text,
10 Secret,
12 Check,
13 Area,
15}
16
17pub const AREA_ROWS: usize = 7;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Field {
22 pub id: &'static str,
24 pub label: &'static str,
26 pub kind: Kind,
27 pub value: String,
28 pub checked: bool,
29 pub locked: Option<&'static str>,
31 pub cursor: usize,
33}
34
35impl Field {
36 pub fn text(id: &'static str, label: &'static str, value: &str) -> Field {
37 Field {
38 id,
39 label,
40 kind: Kind::Text,
41 value: value.to_owned(),
42 checked: false,
43 locked: None,
44 cursor: 0,
45 }
46 }
47
48 pub fn area(id: &'static str, label: &'static str, value: &str) -> Field {
50 Field {
51 kind: Kind::Area,
52 cursor: value.chars().count(),
53 ..Field::text(id, label, value)
54 }
55 }
56
57 pub fn line_col(&self) -> (usize, usize) {
59 let before: String = self.value.chars().take(self.cursor).collect();
60 let line = before.matches('\n').count();
61 let col = before.rsplit('\n').next().map_or(0, |l| l.chars().count());
62 (line, col)
63 }
64
65 fn lines(&self) -> Vec<&str> {
66 self.value.split('\n').collect()
67 }
68
69 fn set_line_col(&mut self, line: usize, col: usize) {
71 let lines = self.lines();
72 let line = line.min(lines.len() - 1);
73 let before: usize = lines.iter().take(line).map(|l| l.chars().count() + 1).sum();
74 self.cursor = before + col.min(lines[line].chars().count());
75 }
76
77 fn byte_at(&self, chars: usize) -> usize {
78 self.value
79 .char_indices()
80 .nth(chars)
81 .map_or(self.value.len(), |(at, _)| at)
82 }
83
84 fn insert_at_cursor(&mut self, text: &str) {
85 let at = self.byte_at(self.cursor);
86 self.value.insert_str(at, text);
87 self.cursor += text.chars().count();
88 }
89
90 pub fn secret(id: &'static str, label: &'static str, value: &str) -> Field {
91 Field {
92 kind: Kind::Secret,
93 ..Field::text(id, label, value)
94 }
95 }
96
97 pub fn check(id: &'static str, label: &'static str, checked: bool) -> Field {
98 Field {
99 kind: Kind::Check,
100 checked,
101 ..Field::text(id, label, "")
102 }
103 }
104
105 pub fn locked(mut self, reason: &'static str) -> Field {
106 self.locked = Some(reason);
107 self
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum Purpose {
114 TokenCreate {
116 clone_of: Option<(String, String)>,
117 },
118 TokenEdit {
120 id: String,
121 name: String,
122 expires: String,
123 },
124 TopicCreate,
125 TopicEdit {
127 name: String,
128 was: Vec<(&'static str, String)>,
129 },
130 ProfileNew,
131 ProfileEdit {
132 original: String,
133 },
134 MessagePublish {
136 topic: String,
137 },
138 MessageReceive {
140 topic: String,
141 },
142 MessageVisibility {
144 topic: String,
145 id: String,
146 receipt: String,
147 },
148}
149
150impl Purpose {
151 pub fn title(&self) -> &'static str {
153 match self {
154 Purpose::TokenCreate { clone_of: None } => "editor.token-create",
155 Purpose::TokenCreate { .. } => "editor.token-clone",
156 Purpose::TokenEdit { .. } => "editor.token-edit",
157 Purpose::TopicCreate => "editor.topic-create",
158 Purpose::TopicEdit { .. } => "editor.topic-edit",
159 Purpose::ProfileNew => "editor.profile-new",
160 Purpose::ProfileEdit { .. } => "editor.profile-edit",
161 Purpose::MessagePublish { .. } => "editor.message-publish",
162 Purpose::MessageReceive { .. } => "editor.message-receive",
163 Purpose::MessageVisibility { .. } => "editor.message-visibility",
164 }
165 }
166
167 pub fn help(&self) -> &'static str {
169 if self.is_profile() {
170 "editor.help-profile"
171 } else if matches!(self, Purpose::MessageReceive { .. }) {
172 "editor.help-receive"
173 } else if matches!(self, Purpose::MessageVisibility { .. }) {
174 "editor.help-visibility"
175 } else if self.is_message() {
176 "editor.help-publish"
177 } else if self.is_topic() {
178 "editor.help-topic"
179 } else {
180 "editor.help-token"
181 }
182 }
183
184 pub fn is_topic(&self) -> bool {
185 matches!(self, Purpose::TopicCreate | Purpose::TopicEdit { .. })
186 }
187
188 pub fn is_message(&self) -> bool {
189 matches!(
190 self,
191 Purpose::MessagePublish { .. }
192 | Purpose::MessageReceive { .. }
193 | Purpose::MessageVisibility { .. }
194 )
195 }
196
197 pub fn is_profile(&self) -> bool {
198 matches!(self, Purpose::ProfileNew | Purpose::ProfileEdit { .. })
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct Editor {
204 pub purpose: Purpose,
205 pub fields: Vec<Field>,
206 pub focus: usize,
207 pub error: Option<String>,
209 pub note: Option<(bool, String)>,
211 pub busy: bool,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Outcome {
218 None,
219 Cancel,
220 Submit,
221 Test,
222}
223
224impl Editor {
225 pub fn new(purpose: Purpose, fields: Vec<Field>) -> Editor {
226 let mut editor = Editor {
227 purpose,
228 fields,
229 focus: 0,
230 error: None,
231 note: None,
232 busy: false,
233 };
234 if editor.fields.first().is_some_and(|f| f.locked.is_some()) {
235 editor.focus = editor.step(0, 1);
236 }
237 editor
238 }
239
240 pub fn field(&self, id: &str) -> Option<&Field> {
241 self.fields.iter().find(|f| f.id == id)
242 }
243
244 pub fn text(&self, id: &str) -> &str {
245 self.field(id).map_or("", |f| f.value.trim())
246 }
247
248 pub fn checked(&self, id: &str) -> bool {
249 self.field(id).is_some_and(|f| f.checked)
250 }
251
252 pub fn focus_on(&mut self, id: &str) {
254 if let Some(at) = self.fields.iter().position(|f| f.id == id) {
255 self.focus = at;
256 }
257 }
258
259 fn step(&self, from: usize, delta: i32) -> usize {
261 let mut at = from;
262 loop {
263 let next = i64::try_from(at).unwrap_or(0) + i64::from(delta);
264 if next < 0 || next >= i64::try_from(self.fields.len()).unwrap_or(0) {
265 return from;
266 }
267 at = next as usize;
268 if self.fields[at].locked.is_none() {
269 return at;
270 }
271 }
272 }
273
274 fn is_last(&self) -> bool {
275 self.step(self.focus, 1) == self.focus
276 }
277
278 pub fn insert(&mut self, text: &str) {
280 if let Some(field) = self.fields.get_mut(self.focus) {
281 if field.locked.is_some() || field.kind == Kind::Check {
282 return;
283 }
284 if field.kind == Kind::Area {
285 let text = text.replace("\r\n", "\n").replace('\r', "\n");
286 let clean: String = text
287 .chars()
288 .map(|c| if c == '\t' { ' ' } else { c })
289 .filter(|c| *c == '\n' || !c.is_control())
290 .collect();
291 field.insert_at_cursor(&clean);
292 } else {
293 field.value.extend(text.chars().filter(|c| !c.is_control()));
294 }
295 self.error = None;
296 }
297 }
298
299 fn area_key(&mut self, event: &KeyEvent) -> Option<()> {
301 let ctrl = crate::tui::keymap::ctrl_of(event);
302 let at = self.focus;
303 let field = self.fields.get_mut(at)?;
304 if field.kind != Kind::Area || field.locked.is_some() {
305 return None;
306 }
307 let (line, col) = field.line_col();
308 let last = field.lines().len() - 1;
309 match event.code {
310 KeyCode::Enter => field.insert_at_cursor("\n"),
311 KeyCode::Left => field.cursor = field.cursor.saturating_sub(1),
312 KeyCode::Right => field.cursor = (field.cursor + 1).min(field.value.chars().count()),
313 KeyCode::Home => field.set_line_col(line, 0),
314 KeyCode::End => field.set_line_col(line, usize::MAX),
315 KeyCode::Up if line > 0 => field.set_line_col(line - 1, col),
316 KeyCode::Down if line < last => field.set_line_col(line + 1, col),
317 KeyCode::Up | KeyCode::Down => return None,
319 KeyCode::Backspace if field.cursor > 0 => {
320 let to = field.byte_at(field.cursor);
321 let from = field.byte_at(field.cursor - 1);
322 field.value.replace_range(from..to, "");
323 field.cursor -= 1;
324 }
325 KeyCode::Delete if field.cursor < field.value.chars().count() => {
326 let from = field.byte_at(field.cursor);
327 let to = field.byte_at(field.cursor + 1);
328 field.value.replace_range(from..to, "");
329 }
330 KeyCode::Backspace | KeyCode::Delete => {}
331 KeyCode::Char('u') if ctrl => {
332 field.value.clear();
333 field.cursor = 0;
334 }
335 KeyCode::Char(c) if !ctrl => {
336 let mut buffer = [0u8; 4];
337 field.insert_at_cursor(c.encode_utf8(&mut buffer));
338 }
339 _ => return None,
340 }
341 self.error = None;
342 Some(())
343 }
344
345 pub fn handle(&mut self, event: &KeyEvent) -> Outcome {
346 if self.busy {
347 return Outcome::None;
348 }
349 let ctrl = crate::tui::keymap::ctrl_of(event);
350 let sends = matches!(event.code, KeyCode::Char('s' | 'S')) && ctrl;
351 if !sends && event.code != KeyCode::Esc && self.area_key(event).is_some() {
352 return Outcome::None;
353 }
354 match event.code {
355 KeyCode::Esc => return Outcome::Cancel,
356 KeyCode::Char('s' | 'S') if ctrl => return Outcome::Submit,
357 KeyCode::Char('t' | 'T') if ctrl && self.purpose.is_profile() => return Outcome::Test,
358 KeyCode::Tab | KeyCode::Down => self.focus = self.step(self.focus, 1),
359 KeyCode::BackTab | KeyCode::Up => self.focus = self.step(self.focus, -1),
360 KeyCode::Enter => {
361 if self.is_last() {
362 return Outcome::Submit;
363 }
364 self.focus = self.step(self.focus, 1);
365 }
366 KeyCode::Backspace => {
367 if let Some(field) = self.fields.get_mut(self.focus) {
368 if field.locked.is_none() && field.kind != Kind::Check {
369 field.value.pop();
370 self.error = None;
371 }
372 }
373 }
374 KeyCode::Char('u') if ctrl => {
375 if let Some(field) = self.fields.get_mut(self.focus) {
376 if field.locked.is_none() && field.kind != Kind::Check {
377 field.value.clear();
378 }
379 }
380 }
381 KeyCode::Char(' ')
382 if self
383 .fields
384 .get(self.focus)
385 .is_some_and(|f| f.kind == Kind::Check && f.locked.is_none()) =>
386 {
387 let field = &mut self.fields[self.focus];
388 field.checked = !field.checked;
389 self.error = None;
390 }
391 KeyCode::Char(c) if !ctrl => {
392 let mut buffer = [0u8; 4];
393 self.insert(c.encode_utf8(&mut buffer));
394 }
395 _ => {}
396 }
397 Outcome::None
398 }
399}
400
401#[cfg(test)]
402mod tests;