Refine desktop entry field layout

This commit is contained in:
2026-08-11 06:26:59 +02:00
parent 4163ec5e25
commit 2a8c941a65
2 changed files with 379 additions and 149 deletions

View File

@@ -2,6 +2,7 @@
use std::fmt; use std::fmt;
use iced::widget::text_editor;
use ironstorage::{ use ironstorage::{
document::{DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId}, document::{DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId},
repository::SecretBytes, repository::SecretBytes,
@@ -10,6 +11,7 @@ use ironstorage::{
pub struct EntryEditor { pub struct EntryEditor {
document: EntryDocument, document: EntryDocument,
focused: Option<EntryFieldId>, focused: Option<EntryFieldId>,
multiline: Vec<(EntryFieldId, text_editor::Content)>,
dirty: bool, dirty: bool,
} }
@@ -24,9 +26,20 @@ pub enum FieldNavigation {
impl EntryEditor { impl EntryEditor {
pub fn new(document: EntryDocument) -> Self { pub fn new(document: EntryDocument) -> Self {
let focused = document.fields().first().map(EntryField::id); 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 { Self {
document, document,
focused, focused,
multiline,
dirty: false, dirty: false,
} }
} }
@@ -66,6 +79,12 @@ impl EntryEditor {
self.focused 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 { pub fn focused_ratio(&self) -> f32 {
let fields = self.document.fields(); let fields = self.document.fields();
self.focused 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) { pub fn navigate(&mut self, navigation: FieldNavigation) {
let fields = self.document.fields(); let fields = self.document.fields();
if fields.is_empty() { if fields.is_empty() {
@@ -111,25 +157,7 @@ impl EntryEditor {
} }
self.document self.document
.update(id, EntryFieldDraft::multiline(value.to_vec()))?; .update(id, EntryFieldDraft::multiline(value.to_vec()))?;
self.dirty = true; self.sync_multiline(id);
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.dirty = true; self.dirty = true;
Ok(()) Ok(())
} }
@@ -148,6 +176,7 @@ impl EntryEditor {
value_line_end(&value, line).ok_or(DocumentError::InvalidIndex { index: line })?; value_line_end(&value, line).ok_or(DocumentError::InvalidIndex { index: line })?;
value.splice(insertion..insertion, *b"\n"); value.splice(insertion..insertion, *b"\n");
self.document.replace_field_value(id, value)?; self.document.replace_field_value(id, value)?;
self.sync_multiline(id);
self.dirty = true; self.dirty = true;
Ok(()) Ok(())
} }
@@ -174,6 +203,7 @@ impl EntryEditor {
.position(|field| field.id() == id) .position(|field| field.id() == id)
.ok_or(DocumentError::UnknownField { id })?; .ok_or(DocumentError::UnknownField { id })?;
self.document.remove(id)?; self.document.remove(id)?;
self.multiline.retain(|(field_id, _)| *field_id != id);
if self.focused == Some(id) { if self.focused == Some(id) {
self.focused = self self.focused = self
.document .document
@@ -224,6 +254,7 @@ impl EntryEditor {
) -> Result<(), DocumentError> { ) -> Result<(), DocumentError> {
self.document self.document
.replace_field_value(id, value.expose().to_vec())?; .replace_field_value(id, value.expose().to_vec())?;
self.sync_multiline(id);
self.dirty = true; self.dirty = true;
Ok(()) Ok(())
} }
@@ -231,24 +262,30 @@ impl EntryEditor {
pub fn copy_value(&self, id: EntryFieldId) -> Result<SecretBytes, DocumentError> { pub fn copy_value(&self, id: EntryFieldId) -> Result<SecretBytes, DocumentError> {
self.document.copy_field_value(id) self.document.copy_field_value(id)
} }
}
fn value_line_range(value: &[u8], target: usize) -> Option<std::ops::Range<usize>> { fn sync_multiline(&mut self, id: EntryFieldId) {
let mut start = 0; let value = self
let mut lines = 0; .document
for (line, segment) in value.split_inclusive(|byte| *byte == b'\n').enumerate() { .field(id)
lines = line + 1; .and_then(|field| std::str::from_utf8(field.value()).ok())
let mut end = start + segment.len() - usize::from(segment.ends_with(b"\n")); .filter(|value| value.contains('\n'));
if end > start && value[end - 1] == b'\r' { let existing = self
end -= 1; .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<usize> { fn value_line_end(value: &[u8], target: usize) -> Option<usize> {

View File

@@ -22,11 +22,11 @@ use std::{
use editor::{EntryEditor, FieldNavigation}; use editor::{EntryEditor, FieldNavigation};
use iced::{ use iced::{
Background, Color, Element, Event, Length, Point, Rectangle, Renderer, Size, Subscription, Background, Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Size,
Task, Theme, event, keyboard, mouse, time, touch, Subscription, Task, Theme, event, keyboard, mouse, time, touch,
widget::{ widget::{
button, canvas, column, container, mouse_area, pane_grid, row, scrollable, text, button, canvas, column, container, mouse_area, pane_grid, row, scrollable, text,
text_input, tooltip, text_editor, text_input, tooltip,
}, },
window, window,
}; };
@@ -206,7 +206,7 @@ enum Message {
result: Result<NativeAuthenticationHandle, String>, result: Result<NativeAuthenticationHandle, String>,
}, },
FieldChanged(EntryFieldId, Zeroizing<String>), FieldChanged(EntryFieldId, Zeroizing<String>),
FieldLineChanged(EntryFieldId, usize, Zeroizing<String>), FieldEdited(EntryFieldId, text_editor::Action),
AddFieldLine(EntryFieldId, usize), AddFieldLine(EntryFieldId, usize),
AddAfter(Option<EntryFieldId>), AddAfter(Option<EntryFieldId>),
Remove(EntryFieldId), Remove(EntryFieldId),
@@ -2049,12 +2049,13 @@ impl App {
Message::FieldChanged(id, value) => { Message::FieldChanged(id, value) => {
self.pane_focus = PaneFocus::Content; self.pane_focus = PaneFocus::Content;
self.edit(|editor| { self.edit(|editor| {
editor.select(id);
editor.replace_value(id, SecretBytes::new(value.as_bytes().to_vec())) 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.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) => { Message::AddFieldLine(id, line) => {
self.pane_focus = PaneFocus::Content; self.pane_focus = PaneFocus::Content;
@@ -2073,6 +2074,7 @@ impl App {
} }
} }
Message::SelectField(id) => { Message::SelectField(id) => {
self.pane_focus = PaneFocus::Content;
if let Some(editor) = self.editor.as_mut() { if let Some(editor) = self.editor.as_mut() {
editor.select(id); editor.select(id);
} }
@@ -3421,11 +3423,7 @@ fn settings_vault_id() -> iced::widget::Id {
} }
fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id { fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id {
editor_field_line_input_id(id, 0) format!("desktop-entry-field-{}", id.value()).into()
}
fn editor_field_line_input_id(id: EntryFieldId, line: usize) -> iced::widget::Id {
format!("desktop-entry-field-{}-{line}", id.value()).into()
} }
fn action_hint(action: UiAction) -> String { fn action_hint(action: UiAction) -> String {
@@ -3461,7 +3459,10 @@ enum Icon {
Up, Up,
} }
struct IconCanvas(Icon); struct IconCanvas {
icon: Icon,
color: Option<Color>,
}
impl<Message> canvas::Program<Message> for IconCanvas { impl<Message> canvas::Program<Message> for IconCanvas {
type State = (); type State = ();
@@ -3475,7 +3476,7 @@ impl<Message> canvas::Program<Message> for IconCanvas {
_cursor: mouse::Cursor, _cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> { ) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size()); 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 => { Icon::Add => {
path.move_to(Point::new(8.0, 2.0)); path.move_to(Point::new(8.0, 2.0));
path.line_to(Point::new(8.0, 14.0)); path.line_to(Point::new(8.0, 14.0));
@@ -3547,7 +3548,7 @@ impl<Message> canvas::Program<Message> for IconCanvas {
path.line_to(Point::new(13.0, 13.0)); path.line_to(Point::new(13.0, 13.0));
path.line_to(Point::new(2.5, 13.0)); path.line_to(Point::new(2.5, 13.0));
path.line_to(Point::new(1.5, 4.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.move_to(Point::new(9.0, 9.5));
path.line_to(Point::new(13.0, 9.5)); path.line_to(Point::new(13.0, 9.5));
path.move_to(Point::new(11.0, 7.5)); path.move_to(Point::new(11.0, 7.5));
@@ -3644,7 +3645,10 @@ impl<Message> canvas::Program<Message> for IconCanvas {
frame.stroke( frame.stroke(
&path, &path,
canvas::Stroke::default() 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_width(1.5)
.with_line_cap(canvas::LineCap::Round) .with_line_cap(canvas::LineCap::Round)
.with_line_join(canvas::LineJoin::Round), .with_line_join(canvas::LineJoin::Round),
@@ -3654,7 +3658,11 @@ impl<Message> canvas::Program<Message> for IconCanvas {
} }
fn icon_view(icon: Icon) -> Element<'static, Message> { fn icon_view(icon: Icon) -> Element<'static, Message> {
canvas(IconCanvas(icon)) colored_icon_view(icon, None)
}
fn colored_icon_view(icon: Icon, color: Option<Color>) -> Element<'static, Message> {
canvas(IconCanvas { icon, color })
.width(Length::Fixed(16.0)) .width(Length::Fixed(16.0))
.height(Length::Fixed(16.0)) .height(Length::Fixed(16.0))
.into() .into()
@@ -3676,6 +3684,100 @@ fn icon_control(icon: Icon, hint: String, message: Message) -> Element<'static,
.into() .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> { fn action_icon(icon: Icon, action: UiAction) -> Element<'static, Message> {
icon_control(icon, action_hint(action), Message::Action(action)) 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)", ViewerValue::Unavailable => "(binary value)",
}; };
let copy_hint = format!("Copy {label} (⌘C)"); let copy_hint = format!("Copy {label} (⌘C)");
let mut field_view = column![ let copy = entry_icon_control(
row![ Icon::Copy,
button(text(label.clone()).size(12)) 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) .width(Length::Fill)
.padding([4, 6]) .style(entry_value_style),
.on_press(Message::SelectField(id))
.style(if selected {
selected_button
} else {
button::text
}),
icon_control(
Icon::Copy,
copy_hint,
Message::FieldAction(id, UiAction::CopyField),
),
] ]
.align_y(iced::Alignment::Center) .spacing(4)
.spacing(2), } else {
text(value).size(15), column![
] row![
.spacing(3); 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() { if let Some(otp) = field.metadata().otp() {
let cadence = otp.period().map_or_else( let cadence = otp.period().map_or_else(
|| format!("counter {}", otp.counter().unwrap_or_default()), || 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), .size(12),
); );
field_view = field_view.push( field_view = field_view.push(
row![ container(
icon_control( row![
Icon::Refresh, entry_icon_control(
"Generate current OTP code".to_owned(), Icon::Refresh,
Message::FieldAction(id, UiAction::GenerateOtp), "Generate current OTP code".to_owned(),
), Message::FieldAction(id, UiAction::GenerateOtp),
icon_control( ),
Icon::Copy, entry_icon_control(
"Copy current OTP code".to_owned(), Icon::Copy,
Message::FieldAction(id, UiAction::CopyOtp), "Copy current OTP code".to_owned(),
), Message::FieldAction(id, UiAction::CopyOtp),
icon_control( ),
Icon::Link, entry_icon_control(
"Copy OTP provisioning URI".to_owned(), Icon::Link,
Message::FieldAction(id, UiAction::CopyOtpUri), "Copy OTP provisioning URI".to_owned(),
), Message::FieldAction(id, UiAction::CopyOtpUri),
icon_control( ),
Icon::Qr, entry_icon_control(
"Show OTP provisioning QR code".to_owned(), Icon::Qr,
Message::FieldAction(id, UiAction::ShowOtpQr), "Show OTP provisioning QR code".to_owned(),
), Message::FieldAction(id, UiAction::ShowOtpQr),
] ),
.spacing(2), ]
.spacing(2),
)
.align_right(Length::Fill),
); );
} }
if let Some(diagnostic) = field.metadata().diagnostic() { if let Some(diagnostic) = field.metadata().diagnostic() {
field_view = field_view.push(text(viewer_diagnostic(diagnostic)).size(12)); 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) container(scrollable(rows).id(viewer_scroll_id()).height(Length::Fill))
.id(viewer_scroll_id()) .padding([8, 10])
.width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(entry_area_style)
.into() .into()
} }
@@ -5020,8 +5143,12 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
)) ))
.size(16) .size(16)
.width(Length::Fill), .width(Length::Fill),
action_icon(Icon::Check, UiAction::Save), entry_icon_control(
icon_control(Icon::Add, "Add field".to_owned(), Message::AddAfter(None),), 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) .align_y(iced::Alignment::Center)
.spacing(2) .spacing(2)
@@ -5041,6 +5168,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
for field in editor.fields() { for field in editor.fields() {
let id = field.id(); let id = field.id();
let selected = editor.focused() == Some(id);
let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive; let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive;
let value = std::str::from_utf8(field.value()).ok(); let value = std::str::from_utf8(field.value()).ok();
let label = field let label = field
@@ -5048,65 +5176,110 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
.name() .name()
.map(str::to_owned) .map(str::to_owned)
.unwrap_or_else(|| format!("{:?}", field.metadata().kind())); .unwrap_or_else(|| format!("{:?}", field.metadata().kind()));
let input: Element<'_, Message> = if let Some(value) = value { let mut actions = row![
let multiline = value.contains('\n'); entry_icon_control(Icon::Up, "Move field up".to_owned(), Message::MoveUp(id)),
let mut lines = column![].spacing(2); entry_icon_control(
for (line_index, line) in value.split('\n').enumerate() { Icon::Down,
let line = line.strip_suffix('\r').unwrap_or(line); "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!( let submit = if matches!(
field.metadata().kind(), field.metadata().kind(),
EntryFieldKind::Password | EntryFieldKind::OtpUri EntryFieldKind::Password | EntryFieldKind::OtpUri
) { ) {
Message::AddAfter(Some(id)) Message::AddAfter(Some(id))
} else { } else {
Message::AddFieldLine(id, line_index) Message::AddFieldLine(id, 0)
}; };
let input = text_input("Entry value", line) let input: Element<'_, Message> = text_input("Entry value", value)
.id(editor_field_line_input_id(id, line_index)) .id(editor_field_input_id(id))
.on_input(move |value| { .on_input(move |value| Message::FieldChanged(id, Zeroizing::new(value)))
if multiline { .on_submit(submit)
Message::FieldLineChanged(id, line_index, Zeroizing::new(value)) .style(entry_input_style)
} else { .into();
Message::FieldChanged(id, Zeroizing::new(value)) column![
} row![
}) text(label).size(14).width(Length::Fixed(ENTRY_LABEL_WIDTH)),
.on_submit(submit); input,
lines = lines.push(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![ fields = fields.push(
icon_control(Icon::Up, "Move field up".to_owned(), Message::MoveUp(id)), mouse_area(
icon_control( container(field_view)
Icon::Down, .padding([7, 9])
"Move field down".to_owned(), .width(Length::Fill)
Message::MoveDown(id) .style(move |theme| entry_field_style(theme, selected)),
), )
icon_control( .on_press(Message::SelectField(id)),
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));
} }
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> { fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
@@ -5857,12 +6030,32 @@ mod tests {
assert_eq!(viewer_value(&editor.fields()[9]), ViewerValue::Unavailable); assert_eq!(viewer_value(&editor.fields()[9]), ViewerValue::Unavailable);
let comments = editor.fields()[6].id(); let comments = editor.fields()[6].id();
editor editor
.update_value_line(comments, 1, b"alpha") .update_raw(comments, b"comments: Recovery codes:\nalpha\ntwo")
.expect("update multiline row"); .expect("update multiline field");
editor editor
.add_value_line_after(comments, 2) .add_value_line_after(comments, 2)
.expect("append multiline row"); .expect("append multiline row");
assert_eq!(editor.fields()[6].value(), b"Recovery codes:\nalpha\ntwo\n"); 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())); assert!(document_has_single_totp(editor.document()));
editor.navigate(FieldNavigation::First); editor.navigate(FieldNavigation::First);
assert_eq!(editor.focused(), Some(password)); assert_eq!(editor.focused(), Some(password));