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

@@ -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));