//! In-process structured document editing state. use std::fmt; use ironstorage::{ document::{ DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId, EntryFieldKind, EntrySensitivity, }, repository::SecretBytes, }; /// Presentation state for editing one storage-owned document. /// /// The focused buffer contains one complete storage-owned logical field. /// Committing it delegates classification back to `EntryDocument`, so names, /// multiline values, duplicate fields, notes, and OTP URI recognition remain /// storage behavior. pub struct EntryEditor { document: EntryDocument, focused: usize, buffer: SecretBytes, cursor: usize, input_active: bool, dirty: bool, } impl EntryEditor { pub fn new(document: EntryDocument) -> Self { let buffer = document.fields().first().map_or_else( || SecretBytes::new(Vec::new()), |field| SecretBytes::new(field.contents().expose().to_vec()), ); let cursor = buffer.expose().len(); Self { document, focused: 0, buffer, cursor, input_active: false, dirty: false, } } pub fn document(&self) -> &EntryDocument { &self.document } pub fn focused_index(&self) -> Option { (!self.document.fields().is_empty()).then_some(self.focused) } pub fn focused_field(&self) -> Option<&EntryField> { self.document.fields().get(self.focused) } pub fn focused_contents(&self, id: EntryFieldId) -> Option<&[u8]> { self.focused_field() .filter(|field| field.id() == id) .map(|_| self.buffer.expose()) } pub fn cursor(&self) -> usize { self.cursor } pub fn is_input_active(&self) -> bool { self.input_active } pub fn begin_input(&mut self) { if self.focused_field().is_some() { self.input_active = true; } } pub fn end_input(&mut self) { self.input_active = false; } pub fn is_dirty(&self) -> bool { self.dirty } pub fn focus_next(&mut self) -> Result<(), DocumentError> { self.flush()?; if !self.document.fields().is_empty() { self.focused = (self.focused + 1) % self.document.fields().len(); self.load_focused(); } Ok(()) } pub fn focus_previous(&mut self) -> Result<(), DocumentError> { self.flush()?; if !self.document.fields().is_empty() { self.focused = self .focused .checked_sub(1) .unwrap_or(self.document.fields().len() - 1); self.load_focused(); } Ok(()) } pub fn add_after_focused(&mut self) -> Result<(), DocumentError> { self.flush()?; let index = if self.document.fields().is_empty() { 0 } else { self.focused + 1 }; self.document.add(index, EntryFieldDraft::blank())?; self.focused = index; self.dirty = true; self.load_focused(); self.input_active = true; Ok(()) } pub fn remove_focused(&mut self) -> Result<(), DocumentError> { self.flush()?; let Some(field) = self.focused_field() else { return Ok(()); }; self.document.remove(field.id())?; self.focused = self .focused .min(self.document.fields().len().saturating_sub(1)); self.dirty = true; self.load_focused(); Ok(()) } pub fn move_focused_up(&mut self) -> Result<(), DocumentError> { self.flush()?; if self.focused == 0 { return Ok(()); } let id = self .focused_field() .expect("nonzero focus has a field") .id(); self.focused -= 1; self.document.reorder(id, self.focused)?; self.dirty = true; self.load_focused(); Ok(()) } pub fn move_focused_down(&mut self) -> Result<(), DocumentError> { self.flush()?; if self.focused + 1 >= self.document.fields().len() { return Ok(()); } let id = self.focused_field().expect("focus has a field").id(); self.focused += 1; self.document.reorder(id, self.focused)?; self.dirty = true; self.load_focused(); Ok(()) } pub fn generation_target(&mut self) -> Result, DocumentError> { self.flush()?; Ok(self.focused_field().and_then(|field| { (field.metadata().sensitivity() == EntrySensitivity::Sensitive && field.metadata().kind() != EntryFieldKind::OtpUri) .then_some(field.id()) })) } pub fn apply_generated( &mut self, target: EntryFieldId, password: SecretBytes, ) -> Result<(), DocumentError> { self.flush()?; self.document .replace_field_value(target, password.expose().to_vec())?; self.dirty = true; if self .focused_field() .is_some_and(|field| field.id() == target) { self.load_focused(); } Ok(()) } pub fn prepare_save(mut self) -> Result> { match self.flush() { Ok(()) => Ok(self), Err(error) => Err(Box::new((self, error))), } } pub fn into_document(mut self) -> Result> { match self.flush() { Ok(()) => Ok(self.document), Err(error) => Err(Box::new((self, error))), } } pub fn insert_character(&mut self, character: char) { if !self.input_active || self.focused_field().is_none() || character.is_control() { return; } let encoded = character.to_string(); let mut replacement = Vec::with_capacity(self.buffer.expose().len() + encoded.len()); replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]); replacement.extend_from_slice(encoded.as_bytes()); replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]); self.cursor += encoded.len(); self.replace_buffer(replacement); } pub fn backspace(&mut self) { if !self.input_active || self.cursor == 0 { return; } let start = previous_character_boundary(self.buffer.expose(), self.cursor); let mut replacement = Vec::with_capacity(self.buffer.expose().len() - (self.cursor - start)); replacement.extend_from_slice(&self.buffer.expose()[..start]); replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]); self.cursor = start; self.replace_buffer(replacement); } pub fn delete(&mut self) { if !self.input_active || self.cursor >= self.buffer.expose().len() { return; } let end = next_character_boundary(self.buffer.expose(), self.cursor); let mut replacement = Vec::with_capacity(self.buffer.expose().len() - (end - self.cursor)); replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]); replacement.extend_from_slice(&self.buffer.expose()[end..]); self.replace_buffer(replacement); } pub fn move_cursor_left(&mut self) { self.cursor = previous_character_boundary(self.buffer.expose(), self.cursor); } pub fn move_cursor_right(&mut self) { self.cursor = next_character_boundary(self.buffer.expose(), self.cursor); } pub fn move_cursor_home(&mut self) { self.cursor = 0; } pub fn move_cursor_end(&mut self) { self.cursor = self.buffer.expose().len(); } pub fn split_line(&mut self) -> Result<(), DocumentError> { if !self.input_active { return Ok(()); } let Some(field) = self.focused_field() else { return Ok(()); }; if matches!( field.metadata().kind(), EntryFieldKind::Username | EntryFieldKind::Email | EntryFieldKind::Url | EntryFieldKind::Field | EntryFieldKind::Note ) { let mut replacement = Vec::with_capacity(self.buffer.expose().len() + 1); replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]); replacement.push(b'\n'); replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]); self.cursor += 1; self.replace_buffer(replacement); return Ok(()); } let id = field.id(); let left = self.buffer.expose()[..self.cursor].to_vec(); let right = self.buffer.expose()[self.cursor..].to_vec(); self.document.update(id, EntryFieldDraft::line(left)?)?; self.document .add(self.focused + 1, EntryFieldDraft::line(right)?)?; self.focused += 1; self.dirty = true; self.load_focused(); Ok(()) } fn flush(&mut self) -> Result<(), DocumentError> { let Some(field) = self.focused_field() else { return Ok(()); }; if field.contents().expose() == self.buffer.expose() { return Ok(()); } let id = field.id(); self.document.update( id, EntryFieldDraft::multiline(self.buffer.expose().to_vec()), )?; self.dirty = true; Ok(()) } fn load_focused(&mut self) { self.buffer = self.focused_field().map_or_else( || SecretBytes::new(Vec::new()), |field| SecretBytes::new(field.contents().expose().to_vec()), ); self.cursor = self.buffer.expose().len(); if self.focused_field().is_none() { self.input_active = false; } } fn replace_buffer(&mut self, replacement: Vec) { self.buffer = SecretBytes::new(replacement); let Some(id) = self.focused_field().map(EntryField::id) else { return; }; let draft = EntryFieldDraft::multiline(self.buffer.expose().to_vec()); self.document .update(id, draft) .expect("the focused field identifier belongs to the document"); self.dirty = true; } } impl fmt::Debug for EntryEditor { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("EntryEditor") .field("document", &self.document) .field("focused", &self.focused) .field("buffer", &"[REDACTED]") .field("cursor", &self.cursor) .field("input_active", &self.input_active) .field("dirty", &self.dirty) .finish() } } fn previous_character_boundary(value: &[u8], cursor: usize) -> usize { let mut boundary = cursor.saturating_sub(1); while boundary > 0 && value[boundary].is_ascii_continuation() { boundary -= 1; } boundary } fn next_character_boundary(value: &[u8], cursor: usize) -> usize { let mut boundary = cursor.saturating_add(1).min(value.len()); while boundary < value.len() && value[boundary].is_ascii_continuation() { boundary += 1; } boundary } trait Utf8Continuation { fn is_ascii_continuation(&self) -> bool; } impl Utf8Continuation for u8 { fn is_ascii_continuation(&self) -> bool { self & 0b1100_0000 == 0b1000_0000 } } #[cfg(test)] mod tests { use super::*; use crate::viewer::test_support::fixture_document; #[test] fn unicode_input_and_focus_are_predictable() { let mut editor = EntryEditor::new(fixture_document("unicode/咖啡")); editor.begin_input(); editor.move_cursor_end(); editor.insert_character('界'); editor.focus_next().expect("focus next"); editor.focus_previous().expect("focus previous"); assert!( editor .focused_field() .expect("password") .contents() .expose() .ends_with("界".as_bytes()) ); } #[test] fn add_multiline_remove_and_reorder_keep_stable_focus() { let mut editor = EntryEditor::new(fixture_document("email/personal")); let initial = editor.document().fields().len(); editor.add_after_focused().expect("add"); assert_eq!(editor.focused_index(), Some(1)); editor.insert_character('a'); editor.insert_character('b'); editor.move_cursor_left(); editor.split_line().expect("split"); assert_eq!(editor.document().fields().len(), initial + 1); assert_eq!( editor.focused_contents(editor.focused_field().expect("field").id()), Some(b"a\nb".as_slice()) ); editor.move_focused_up().expect("move up"); assert_eq!(editor.focused_index(), Some(0)); editor.remove_focused().expect("remove"); assert_eq!(editor.document().fields().len(), initial); } #[test] fn generated_values_preserve_structured_names_and_empty_documents_are_editable() { let mut editor = EntryEditor::new(fixture_document("email/personal")); editor.focus_next().expect("username"); assert_eq!(editor.generation_target().expect("target"), None); editor.focus_previous().expect("password"); let target = editor .generation_target() .expect("target") .expect("sensitive target"); editor .apply_generated(target, SecretBytes::new(b"generated".to_vec())) .expect("apply generated"); assert_eq!( editor.focused_field().expect("password").value(), b"generated" ); let mut empty = EntryEditor::new(fixture_document("new/empty-document")); assert_eq!(empty.focused_index(), None); empty.add_after_focused().expect("add first field"); assert_eq!(empty.focused_index(), Some(0)); let mut otp = EntryEditor::new(fixture_document("otp/totp")); otp.focus_next().expect("OTP URI"); assert_eq!(otp.generation_target().expect("OTP target"), None); } #[test] fn duplicate_labels_and_arbitrary_reordering_round_trip_without_schema_assumptions() { let mut document = fixture_document("new/duplicate-document"); document .add( 0, EntryFieldDraft::line(b"password".to_vec()).expect("password"), ) .expect("add password"); document .add( 1, EntryFieldDraft::field("custom", b"one".to_vec()).expect("field"), ) .expect("add first duplicate"); document .add( 2, EntryFieldDraft::field("custom", b"two".to_vec()).expect("field"), ) .expect("add second duplicate"); let mut editor = EntryEditor::new(document); editor.focus_next().expect("first duplicate"); editor.focus_next().expect("second duplicate"); editor.move_focused_up().expect("reorder duplicate"); let serialized = editor.into_document().expect("valid editor").serialize(); assert_eq!(serialized.expose(), b"password\ncustom: two\ncustom: one"); } }