Files
IronStorage/apps/desktop/src/editor.rs

314 lines
9.2 KiB
Rust

//! 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<EntryFieldId>,
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<Self, DocumentError> {
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<EntryFieldId> {
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<EntryFieldId>) -> 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<SecretBytes, DocumentError> {
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<usize> {
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()
}
}