314 lines
9.2 KiB
Rust
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()
|
|
}
|
|
}
|