diff --git a/apps/desktop/src/editor.rs b/apps/desktop/src/editor.rs index b0f6a7f..d4b5f62 100644 --- a/apps/desktop/src/editor.rs +++ b/apps/desktop/src/editor.rs @@ -2,6 +2,7 @@ use std::fmt; +use iced::widget::text_editor; use ironstorage::{ document::{DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId}, repository::SecretBytes, @@ -10,6 +11,7 @@ use ironstorage::{ pub struct EntryEditor { document: EntryDocument, focused: Option, + multiline: Vec<(EntryFieldId, text_editor::Content)>, dirty: bool, } @@ -24,9 +26,20 @@ pub enum FieldNavigation { 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, } } @@ -66,6 +79,12 @@ impl EntryEditor { 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 @@ -81,6 +100,33 @@ impl EntryEditor { } } + 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() { @@ -111,25 +157,7 @@ impl EntryEditor { } self.document .update(id, EntryFieldDraft::multiline(value.to_vec()))?; - self.dirty = true; - Ok(()) - } - - pub fn update_value_line( - &mut self, - id: EntryFieldId, - line: usize, - replacement: &[u8], - ) -> Result<(), DocumentError> { - let field = self - .document - .field(id) - .ok_or(DocumentError::UnknownField { id })?; - let mut value = field.value().to_vec(); - let range = - value_line_range(&value, line).ok_or(DocumentError::InvalidIndex { index: line })?; - value.splice(range, replacement.iter().copied()); - self.document.replace_field_value(id, value)?; + self.sync_multiline(id); self.dirty = true; Ok(()) } @@ -148,6 +176,7 @@ impl EntryEditor { 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(()) } @@ -174,6 +203,7 @@ impl EntryEditor { .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 @@ -224,6 +254,7 @@ impl EntryEditor { ) -> Result<(), DocumentError> { self.document .replace_field_value(id, value.expose().to_vec())?; + self.sync_multiline(id); self.dirty = true; Ok(()) } @@ -231,24 +262,30 @@ impl EntryEditor { pub fn copy_value(&self, id: EntryFieldId) -> Result { self.document.copy_field_value(id) } -} -fn value_line_range(value: &[u8], target: usize) -> Option> { - let mut start = 0; - let mut lines = 0; - for (line, segment) in value.split_inclusive(|byte| *byte == b'\n').enumerate() { - lines = line + 1; - let mut end = start + segment.len() - usize::from(segment.ends_with(b"\n")); - if end > start && value[end - 1] == b'\r' { - end -= 1; + 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) => {} } - if line == target { - return Some(start..end); - } - start += segment.len(); } - (target == lines && (value.is_empty() || value.ends_with(b"\n"))) - .then_some(value.len()..value.len()) } fn value_line_end(value: &[u8], target: usize) -> Option { diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index d7d64b8..3f58cbe 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -22,11 +22,11 @@ use std::{ use editor::{EntryEditor, FieldNavigation}; use iced::{ - Background, Color, Element, Event, Length, Point, Rectangle, Renderer, Size, Subscription, - Task, Theme, event, keyboard, mouse, time, touch, + Background, Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Size, + Subscription, Task, Theme, event, keyboard, mouse, time, touch, widget::{ button, canvas, column, container, mouse_area, pane_grid, row, scrollable, text, - text_input, tooltip, + text_editor, text_input, tooltip, }, window, }; @@ -206,7 +206,7 @@ enum Message { result: Result, }, FieldChanged(EntryFieldId, Zeroizing), - FieldLineChanged(EntryFieldId, usize, Zeroizing), + FieldEdited(EntryFieldId, text_editor::Action), AddFieldLine(EntryFieldId, usize), AddAfter(Option), Remove(EntryFieldId), @@ -2049,12 +2049,13 @@ impl App { Message::FieldChanged(id, value) => { self.pane_focus = PaneFocus::Content; self.edit(|editor| { + editor.select(id); editor.replace_value(id, SecretBytes::new(value.as_bytes().to_vec())) }); } - Message::FieldLineChanged(id, line, value) => { + Message::FieldEdited(id, action) => { self.pane_focus = PaneFocus::Content; - self.edit(|editor| editor.update_value_line(id, line, value.as_bytes())); + self.edit(|editor| editor.edit_multiline(id, action)); } Message::AddFieldLine(id, line) => { self.pane_focus = PaneFocus::Content; @@ -2073,6 +2074,7 @@ impl App { } } Message::SelectField(id) => { + self.pane_focus = PaneFocus::Content; if let Some(editor) = self.editor.as_mut() { editor.select(id); } @@ -3421,11 +3423,7 @@ fn settings_vault_id() -> iced::widget::Id { } fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id { - editor_field_line_input_id(id, 0) -} - -fn editor_field_line_input_id(id: EntryFieldId, line: usize) -> iced::widget::Id { - format!("desktop-entry-field-{}-{line}", id.value()).into() + format!("desktop-entry-field-{}", id.value()).into() } fn action_hint(action: UiAction) -> String { @@ -3461,7 +3459,10 @@ enum Icon { Up, } -struct IconCanvas(Icon); +struct IconCanvas { + icon: Icon, + color: Option, +} impl canvas::Program for IconCanvas { type State = (); @@ -3475,7 +3476,7 @@ impl canvas::Program for IconCanvas { _cursor: mouse::Cursor, ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); - let path = canvas::Path::new(|path| match self.0 { + let path = canvas::Path::new(|path| match self.icon { Icon::Add => { path.move_to(Point::new(8.0, 2.0)); path.line_to(Point::new(8.0, 14.0)); @@ -3547,7 +3548,7 @@ impl canvas::Program for IconCanvas { path.line_to(Point::new(13.0, 13.0)); path.line_to(Point::new(2.5, 13.0)); path.line_to(Point::new(1.5, 4.0)); - if matches!(self.0, Icon::FolderAdd) { + if matches!(self.icon, Icon::FolderAdd) { path.move_to(Point::new(9.0, 9.5)); path.line_to(Point::new(13.0, 9.5)); path.move_to(Point::new(11.0, 7.5)); @@ -3644,7 +3645,10 @@ impl canvas::Program for IconCanvas { frame.stroke( &path, canvas::Stroke::default() - .with_color(theme.extended_palette().background.base.text) + .with_color( + self.color + .unwrap_or(theme.extended_palette().background.base.text), + ) .with_width(1.5) .with_line_cap(canvas::LineCap::Round) .with_line_join(canvas::LineJoin::Round), @@ -3654,7 +3658,11 @@ impl canvas::Program for IconCanvas { } fn icon_view(icon: Icon) -> Element<'static, Message> { - canvas(IconCanvas(icon)) + colored_icon_view(icon, None) +} + +fn colored_icon_view(icon: Icon, color: Option) -> Element<'static, Message> { + canvas(IconCanvas { icon, color }) .width(Length::Fixed(16.0)) .height(Length::Fixed(16.0)) .into() @@ -3676,6 +3684,100 @@ fn icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, .into() } +const ENTRY_AREA_BACKGROUND: Color = Color::from_rgb8(18, 18, 20); +const ENTRY_FIELD_BACKGROUND: Color = Color::from_rgb8(24, 24, 27); +const ENTRY_FIELD_ACTIVE_BACKGROUND: Color = Color::from_rgb8(47, 47, 52); +const ENTRY_INPUT_BACKGROUND: Color = Color::from_rgb8(12, 12, 14); +const ENTRY_TEXT: Color = Color::from_rgb8(235, 235, 238); +const ENTRY_MUTED_TEXT: Color = Color::from_rgb8(165, 165, 172); +const ENTRY_BORDER: Color = Color::from_rgb8(72, 72, 78); +const ENTRY_FOCUSED_BORDER: Color = Color::from_rgb8(145, 145, 152); +const ENTRY_LABEL_WIDTH: f32 = 128.0; + +fn entry_icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, Message> { + tooltip( + button(colored_icon_view(icon, Some(ENTRY_TEXT))) + .padding(6) + .style(entry_icon_button) + .on_press(message), + container(text(hint).size(12)) + .padding([5, 8]) + .style(container::dark), + tooltip::Position::Bottom, + ) + .gap(4) + .delay(Duration::from_millis(350)) + .into() +} + +fn entry_icon_button(_theme: &Theme, status: button::Status) -> button::Style { + button::Style { + background: matches!(status, button::Status::Hovered | button::Status::Pressed) + .then_some(Background::Color(ENTRY_FIELD_ACTIVE_BACKGROUND)), + text_color: ENTRY_TEXT, + ..button::Style::default() + } +} + +fn entry_area_style(_theme: &Theme) -> container::Style { + container::Style::default() + .background(ENTRY_AREA_BACKGROUND) + .color(ENTRY_TEXT) +} + +fn entry_field_style(_theme: &Theme, selected: bool) -> container::Style { + container::Style::default() + .background(if selected { + ENTRY_FIELD_ACTIVE_BACKGROUND + } else { + ENTRY_FIELD_BACKGROUND + }) + .color(ENTRY_TEXT) +} + +fn entry_value_style(_theme: &Theme) -> container::Style { + container::Style::default() + .background(ENTRY_INPUT_BACKGROUND) + .color(ENTRY_TEXT) +} + +fn entry_input_style(_theme: &Theme, status: text_input::Status) -> text_input::Style { + text_input::Style { + background: Background::Color(ENTRY_INPUT_BACKGROUND), + border: Border { + radius: 3.0.into(), + width: 1.0, + color: if matches!(status, text_input::Status::Focused { .. }) { + ENTRY_FOCUSED_BORDER + } else { + ENTRY_BORDER + }, + }, + icon: ENTRY_MUTED_TEXT, + placeholder: ENTRY_MUTED_TEXT, + value: ENTRY_TEXT, + selection: ENTRY_FIELD_ACTIVE_BACKGROUND, + } +} + +fn entry_editor_style(_theme: &Theme, status: text_editor::Status) -> text_editor::Style { + text_editor::Style { + background: Background::Color(ENTRY_INPUT_BACKGROUND), + border: Border { + radius: 3.0.into(), + width: 1.0, + color: if matches!(status, text_editor::Status::Focused { .. }) { + ENTRY_FOCUSED_BORDER + } else { + ENTRY_BORDER + }, + }, + placeholder: ENTRY_MUTED_TEXT, + value: ENTRY_TEXT, + selection: ENTRY_FIELD_ACTIVE_BACKGROUND, + } +} + fn action_icon(icon: Icon, action: UiAction) -> Element<'static, Message> { icon_control(icon, action_hint(action), Message::Action(action)) } @@ -4890,28 +4992,36 @@ fn viewer_view<'a>(app: &'a App, editor: &'a EntryEditor) -> Element<'a, Message ViewerValue::Unavailable => "(binary value)", }; let copy_hint = format!("Copy {label} (⌘C)"); - let mut field_view = column![ - row![ - button(text(label.clone()).size(12)) + let copy = entry_icon_control( + Icon::Copy, + copy_hint, + Message::FieldAction(id, UiAction::CopyField), + ); + let mut field_view = if value.contains('\n') { + column![ + row![text(label).size(13).width(Length::Fill), copy,] + .align_y(iced::Alignment::Center) + .spacing(4), + container(text(value).size(15).wrapping(text::Wrapping::WordOrGlyph),) + .padding(8) .width(Length::Fill) - .padding([4, 6]) - .on_press(Message::SelectField(id)) - .style(if selected { - selected_button - } else { - button::text - }), - icon_control( - Icon::Copy, - copy_hint, - Message::FieldAction(id, UiAction::CopyField), - ), + .style(entry_value_style), ] - .align_y(iced::Alignment::Center) - .spacing(2), - text(value).size(15), - ] - .spacing(3); + .spacing(4) + } else { + column![ + row![ + text(label).size(13).width(Length::Fixed(ENTRY_LABEL_WIDTH)), + text(value) + .size(15) + .width(Length::Fill) + .wrapping(text::Wrapping::WordOrGlyph), + copy, + ] + .align_y(iced::Alignment::Center) + .spacing(8), + ] + }; if let Some(otp) = field.metadata().otp() { let cadence = otp.period().map_or_else( || format!("counter {}", otp.counter().unwrap_or_default()), @@ -4957,40 +5067,53 @@ fn viewer_view<'a>(app: &'a App, editor: &'a EntryEditor) -> Element<'a, Message .size(12), ); field_view = field_view.push( - row![ - icon_control( - Icon::Refresh, - "Generate current OTP code".to_owned(), - Message::FieldAction(id, UiAction::GenerateOtp), - ), - icon_control( - Icon::Copy, - "Copy current OTP code".to_owned(), - Message::FieldAction(id, UiAction::CopyOtp), - ), - icon_control( - Icon::Link, - "Copy OTP provisioning URI".to_owned(), - Message::FieldAction(id, UiAction::CopyOtpUri), - ), - icon_control( - Icon::Qr, - "Show OTP provisioning QR code".to_owned(), - Message::FieldAction(id, UiAction::ShowOtpQr), - ), - ] - .spacing(2), + container( + row![ + entry_icon_control( + Icon::Refresh, + "Generate current OTP code".to_owned(), + Message::FieldAction(id, UiAction::GenerateOtp), + ), + entry_icon_control( + Icon::Copy, + "Copy current OTP code".to_owned(), + Message::FieldAction(id, UiAction::CopyOtp), + ), + entry_icon_control( + Icon::Link, + "Copy OTP provisioning URI".to_owned(), + Message::FieldAction(id, UiAction::CopyOtpUri), + ), + entry_icon_control( + Icon::Qr, + "Show OTP provisioning QR code".to_owned(), + Message::FieldAction(id, UiAction::ShowOtpQr), + ), + ] + .spacing(2), + ) + .align_right(Length::Fill), ); } if let Some(diagnostic) = field.metadata().diagnostic() { field_view = field_view.push(text(viewer_diagnostic(diagnostic)).size(12)); } - rows = rows.push(container(field_view).padding([6, 4]).width(Length::Fill)); + rows = rows.push( + mouse_area( + container(field_view) + .padding([7, 9]) + .width(Length::Fill) + .style(move |theme| entry_field_style(theme, selected)), + ) + .on_press(Message::SelectField(id)), + ); } - scrollable(rows) - .id(viewer_scroll_id()) + container(scrollable(rows).id(viewer_scroll_id()).height(Length::Fill)) + .padding([8, 10]) + .width(Length::Fill) .height(Length::Fill) + .style(entry_area_style) .into() } @@ -5020,8 +5143,12 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { )) .size(16) .width(Length::Fill), - action_icon(Icon::Check, UiAction::Save), - icon_control(Icon::Add, "Add field".to_owned(), Message::AddAfter(None),), + entry_icon_control( + Icon::Check, + action_hint(UiAction::Save), + Message::Action(UiAction::Save), + ), + entry_icon_control(Icon::Add, "Add field".to_owned(), Message::AddAfter(None),), ] .align_y(iced::Alignment::Center) .spacing(2) @@ -5041,6 +5168,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { for field in editor.fields() { let id = field.id(); + let selected = editor.focused() == Some(id); let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive; let value = std::str::from_utf8(field.value()).ok(); let label = field @@ -5048,65 +5176,110 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { .name() .map(str::to_owned) .unwrap_or_else(|| format!("{:?}", field.metadata().kind())); - let input: Element<'_, Message> = if let Some(value) = value { - let multiline = value.contains('\n'); - let mut lines = column![].spacing(2); - for (line_index, line) in value.split('\n').enumerate() { - let line = line.strip_suffix('\r').unwrap_or(line); + let mut actions = row![ + entry_icon_control(Icon::Up, "Move field up".to_owned(), Message::MoveUp(id)), + entry_icon_control( + Icon::Down, + "Move field down".to_owned(), + Message::MoveDown(id) + ), + entry_icon_control( + Icon::Add, + "Add field below".to_owned(), + Message::AddAfter(Some(id)), + ), + entry_icon_control(Icon::Delete, "Remove field".to_owned(), Message::Remove(id)), + ] + .spacing(2); + if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri { + actions = actions.push(entry_icon_control( + Icon::Generate, + "Generate password".to_owned(), + Message::FieldAction(id, UiAction::GeneratePassword), + )); + } + actions = actions.push(entry_icon_control( + Icon::Copy, + "Copy field value (⌘C)".to_owned(), + Message::FieldAction(id, UiAction::CopyEditedField), + )); + + let field_view = match value { + Some(value) if value.contains('\n') => { + let input: Element<'_, Message> = text_editor( + editor + .multiline_content(id) + .expect("multiline fields have one editor content"), + ) + .id(editor_field_input_id(id)) + .height(Length::Fixed(120.0)) + .on_action(move |action| Message::FieldEdited(id, action)) + .style(entry_editor_style) + .into(); + column![ + row![text(label).size(14).width(Length::Fill), actions,] + .align_y(iced::Alignment::Center) + .spacing(4), + input, + ] + .spacing(4) + } + Some(value) => { let submit = if matches!( field.metadata().kind(), EntryFieldKind::Password | EntryFieldKind::OtpUri ) { Message::AddAfter(Some(id)) } else { - Message::AddFieldLine(id, line_index) + Message::AddFieldLine(id, 0) }; - let input = text_input("Entry value", line) - .id(editor_field_line_input_id(id, line_index)) - .on_input(move |value| { - if multiline { - Message::FieldLineChanged(id, line_index, Zeroizing::new(value)) - } else { - Message::FieldChanged(id, Zeroizing::new(value)) - } - }) - .on_submit(submit); - lines = lines.push(input); + let input: Element<'_, Message> = text_input("Entry value", value) + .id(editor_field_input_id(id)) + .on_input(move |value| Message::FieldChanged(id, Zeroizing::new(value))) + .on_submit(submit) + .style(entry_input_style) + .into(); + column![ + row![ + text(label).size(14).width(Length::Fixed(ENTRY_LABEL_WIDTH)), + input, + actions, + ] + .align_y(iced::Alignment::Center) + .spacing(8), + ] + } + None => { + let input: Element<'_, Message> = text_input("Non-UTF-8 value preserved", "") + .style(entry_input_style) + .into(); + column![ + row![ + text(label).size(14).width(Length::Fixed(ENTRY_LABEL_WIDTH)), + input, + actions, + ] + .align_y(iced::Alignment::Center) + .spacing(8), + ] } - lines.into() - } else { - text_input("Non-UTF-8 value preserved", "").into() }; - let mut actions = row![ - icon_control(Icon::Up, "Move field up".to_owned(), Message::MoveUp(id)), - icon_control( - Icon::Down, - "Move field down".to_owned(), - Message::MoveDown(id) - ), - icon_control( - Icon::Add, - "Add field below".to_owned(), - Message::AddAfter(Some(id)), - ), - icon_control(Icon::Delete, "Remove field".to_owned(), Message::Remove(id)), - icon_control( - Icon::Copy, - "Copy field value (⌘C)".to_owned(), - Message::FieldAction(id, UiAction::CopyEditedField), - ), - ] - .spacing(2); - if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri { - actions = actions.push(icon_control( - Icon::Generate, - "Generate password".to_owned(), - Message::FieldAction(id, UiAction::GeneratePassword), - )); - } - fields = fields.push(column![text(label).size(14), input, actions].spacing(4)); + fields = fields.push( + mouse_area( + container(field_view) + .padding([7, 9]) + .width(Length::Fill) + .style(move |theme| entry_field_style(theme, selected)), + ) + .on_press(Message::SelectField(id)), + ); } - scrollable(fields).height(Length::Fill).into() + container(scrollable(fields).height(Length::Fill)) + .padding([8, 10]) + .width(Length::Fill) + .height(Length::Fill) + .style(entry_area_style) + .into() } fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { @@ -5857,12 +6030,32 @@ mod tests { assert_eq!(viewer_value(&editor.fields()[9]), ViewerValue::Unavailable); let comments = editor.fields()[6].id(); editor - .update_value_line(comments, 1, b"alpha") - .expect("update multiline row"); + .update_raw(comments, b"comments: Recovery codes:\nalpha\ntwo") + .expect("update multiline field"); editor .add_value_line_after(comments, 2) .expect("append multiline row"); assert_eq!(editor.fields()[6].value(), b"Recovery codes:\nalpha\ntwo\n"); + assert_eq!( + editor + .multiline_content(comments) + .expect("multiline editor") + .text(), + "Recovery codes:\nalpha\ntwo\n" + ); + editor + .edit_multiline( + comments, + text_editor::Action::Edit(text_editor::Edit::Insert('!')), + ) + .expect("edit multiline content"); + assert_eq!( + std::str::from_utf8(editor.fields()[6].value()).expect("UTF-8 field"), + editor + .multiline_content(comments) + .expect("multiline editor") + .text() + ); assert!(document_has_single_totp(editor.document())); editor.navigate(FieldNavigation::First); assert_eq!(editor.focused(), Some(password));