Refine desktop entry field layout
This commit is contained in:
@@ -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<EntryFieldId>,
|
||||
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<SecretBytes, DocumentError> {
|
||||
self.document.copy_field_value(id)
|
||||
}
|
||||
}
|
||||
|
||||
fn value_line_range(value: &[u8], target: usize) -> Option<std::ops::Range<usize>> {
|
||||
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<usize> {
|
||||
|
||||
@@ -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<NativeAuthenticationHandle, String>,
|
||||
},
|
||||
FieldChanged(EntryFieldId, Zeroizing<String>),
|
||||
FieldLineChanged(EntryFieldId, usize, Zeroizing<String>),
|
||||
FieldEdited(EntryFieldId, text_editor::Action),
|
||||
AddFieldLine(EntryFieldId, usize),
|
||||
AddAfter(Option<EntryFieldId>),
|
||||
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<Color>,
|
||||
}
|
||||
|
||||
impl<Message> canvas::Program<Message> for IconCanvas {
|
||||
type State = ();
|
||||
@@ -3475,7 +3476,7 @@ impl<Message> canvas::Program<Message> for IconCanvas {
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
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<Message> canvas::Program<Message> 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<Message> canvas::Program<Message> 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<Message> canvas::Program<Message> 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<Color>) -> 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));
|
||||
|
||||
Reference in New Issue
Block a user