//! Structured desktop editing state over storage-owned entry documents. use std::fmt; use iced::widget::text_editor; use ironstorage::{ document::{DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId}, repository::SecretBytes, }; pub struct EntryEditor { document: EntryDocument, focused: Option, multiline: Vec<(EntryFieldId, text_editor::Content)>, dirty: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FieldNavigation { First, Last, Next, Previous, } impl EntryEditor { pub fn new(document: EntryDocument) -> Self { let focused = document.fields().first().map(EntryField::id); let multiline = document .fields() .iter() .filter_map(|field| { let value = std::str::from_utf8(field.value()).ok()?; value .contains('\n') .then(|| (field.id(), text_editor::Content::with_text(value))) }) .collect(); Self { document, focused, multiline, dirty: false, } } pub fn new_entry( document: EntryDocument, password: SecretBytes, ) -> Result { let mut editor = Self::new(document); editor.add_after(None)?; let id = editor .focused .expect("new entry receives its password field"); if !password.expose().is_empty() { editor.replace_value(id, password)?; } Ok(editor) } pub fn entry(&self) -> String { self.document.path().to_string() } pub fn document(&self) -> &EntryDocument { &self.document } pub fn fields(&self) -> &[EntryField] { self.document.fields() } pub fn is_dirty(&self) -> bool { self.dirty } pub fn focused(&self) -> Option { self.focused } pub fn multiline_content(&self, id: EntryFieldId) -> Option<&text_editor::Content> { self.multiline .iter() .find_map(|(field_id, content)| (*field_id == id).then_some(content)) } pub fn focused_ratio(&self) -> f32 { let fields = self.document.fields(); self.focused .and_then(|id| fields.iter().position(|field| field.id() == id)) .map_or(0.0, |index| { index as f32 / fields.len().saturating_sub(1).max(1) as f32 }) } pub fn select(&mut self, id: EntryFieldId) { if self.document.field(id).is_some() { self.focused = Some(id); } } pub fn edit_multiline( &mut self, id: EntryFieldId, action: text_editor::Action, ) -> Result<(), DocumentError> { let previous = self .document .field(id) .ok_or(DocumentError::UnknownField { id })? .value(); let value = { let content = self .multiline .iter_mut() .find_map(|(field_id, content)| (*field_id == id).then_some(content)) .ok_or(DocumentError::UnknownField { id })?; content.perform(action); content.text().into_bytes() }; self.focused = Some(id); if value != previous { self.document.replace_field_value(id, value)?; self.dirty = true; } Ok(()) } pub fn navigate(&mut self, navigation: FieldNavigation) { let fields = self.document.fields(); if fields.is_empty() { self.focused = None; return; } let current = self .focused .and_then(|id| fields.iter().position(|field| field.id() == id)) .unwrap_or(0); let index = match navigation { FieldNavigation::First => 0, FieldNavigation::Last => fields.len() - 1, FieldNavigation::Next => (current + 1).min(fields.len() - 1), FieldNavigation::Previous => current.saturating_sub(1), }; self.focused = Some(fields[index].id()); } #[cfg(test)] pub fn update_raw(&mut self, id: EntryFieldId, value: &[u8]) -> Result<(), DocumentError> { let unchanged = self .document .field(id) .is_some_and(|field| field.contents().expose() == value); if unchanged { return Ok(()); } self.document .update(id, EntryFieldDraft::multiline(value.to_vec()))?; self.sync_multiline(id); self.dirty = true; Ok(()) } pub fn add_value_line_after( &mut self, id: EntryFieldId, line: usize, ) -> Result<(), DocumentError> { let field = self .document .field(id) .ok_or(DocumentError::UnknownField { id })?; let mut value = field.value().to_vec(); let insertion = value_line_end(&value, line).ok_or(DocumentError::InvalidIndex { index: line })?; value.splice(insertion..insertion, *b"\n"); self.document.replace_field_value(id, value)?; self.sync_multiline(id); self.dirty = true; Ok(()) } pub fn add_after(&mut self, id: Option) -> Result<(), DocumentError> { let index = id .and_then(|id| { self.document .fields() .iter() .position(|field| field.id() == id) }) .map_or(self.document.fields().len(), |index| index + 1); self.focused = Some(self.document.add(index, EntryFieldDraft::blank())?); self.dirty = true; Ok(()) } pub fn remove(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { let index = self .document .fields() .iter() .position(|field| field.id() == id) .ok_or(DocumentError::UnknownField { id })?; self.document.remove(id)?; self.multiline.retain(|(field_id, _)| *field_id != id); if self.focused == Some(id) { self.focused = self .document .fields() .get(index.min(self.document.fields().len().saturating_sub(1))) .map(EntryField::id); } self.dirty = true; Ok(()) } pub fn move_up(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { let Some(index) = self .document .fields() .iter() .position(|field| field.id() == id) else { return Err(DocumentError::UnknownField { id }); }; if index > 0 { self.document.reorder(id, index - 1)?; self.dirty = true; } Ok(()) } pub fn move_down(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { let Some(index) = self .document .fields() .iter() .position(|field| field.id() == id) else { return Err(DocumentError::UnknownField { id }); }; if index + 1 < self.document.fields().len() { self.document.reorder(id, index + 1)?; self.dirty = true; } Ok(()) } pub fn replace_value( &mut self, id: EntryFieldId, value: SecretBytes, ) -> Result<(), DocumentError> { self.document .replace_field_value(id, value.expose().to_vec())?; self.sync_multiline(id); self.dirty = true; Ok(()) } pub fn copy_value(&self, id: EntryFieldId) -> Result { self.document.copy_field_value(id) } fn sync_multiline(&mut self, id: EntryFieldId) { let value = self .document .field(id) .and_then(|field| std::str::from_utf8(field.value()).ok()) .filter(|value| value.contains('\n')); let existing = self .multiline .iter() .position(|(field_id, _)| *field_id == id); match (existing, value) { (Some(index), Some(value)) => { self.multiline[index].1 = text_editor::Content::with_text(value); } (None, Some(value)) => self .multiline .push((id, text_editor::Content::with_text(value))), (Some(index), None) => { self.multiline.remove(index); } (None, None) => {} } } } fn value_line_end(value: &[u8], target: usize) -> Option { let mut end = 0; let mut lines = 0; for (line, segment) in value.split_inclusive(|byte| *byte == b'\n').enumerate() { lines = line + 1; end += segment.len(); if line == target { return Some(end); } } (target == lines && (value.is_empty() || value.ends_with(b"\n"))).then_some(value.len()) } 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("dirty", &self.dirty) .finish() } }