diff --git a/notes/DEBT.md b/notes/DEBT.md index 821f654..0eba76c 100644 --- a/notes/DEBT.md +++ b/notes/DEBT.md @@ -12,7 +12,6 @@ - Автоблокировка «при сворачивании» зависит от того, сообщает ли WM о minimized ## UX -- Иконки — глифы шрифтов; часть эмодзи отсутствует (проверять dev-шагом `glyphs:`) - Оценка стойкости пароля грубая (без словаря) - Переключение записи при открытом редакторе молча отбрасывает несохранённые правки diff --git a/notes/DECISIONS.md b/notes/DECISIONS.md index 23fa0c9..0572fdb 100644 --- a/notes/DECISIONS.md +++ b/notes/DECISIONS.md @@ -23,3 +23,7 @@ версии меняют API, а эти широко проверены. **Язык интерфейса — русский.** Локализация — если понадобится. + +**Строгий визуальный стиль.** Нейтрали делают работу, акцент (стальной синий) — только главная кнопка, +выделение, фокус и ссылки. Иконки рисуются вектором (`src/icons.rs`, сетка 16×16, одна толщина линии) — +шрифтовые эмодзи на разных системах выглядят по-разному. diff --git a/notes/LOG.md b/notes/LOG.md index e9687e5..d168a38 100644 --- a/notes/LOG.md +++ b/notes/LOG.md @@ -1,6 +1,10 @@ # Журнал (новые сверху) +## 2026-09-30 (дизайн) +- Строгий стиль: графитовая палитра, один приглушённый акцент, скругления 4–6 px, без теней на панелях +- Свои векторные иконки (src/icons.rs) вместо эмодзи; нейтральные монограммы вместо цветных аватаров + ## 2026-09-30 - Создан проект: крипто (Argon2id + XChaCha20-Poly1305), модель данных, слияние, атомарная запись - UI на egui 0.36: экраны приветствия/создания/разблокировки, основной экран, генератор, настройки diff --git a/notes/PLAN.md b/notes/PLAN.md index a7e443a..b7ae59b 100644 --- a/notes/PLAN.md +++ b/notes/PLAN.md @@ -15,5 +15,4 @@ - [ ] Импорт .kdbx (KeePass) - [ ] Вложения и произвольные поля - [ ] Вложенные папки / теги -- [ ] Свои векторные иконки вместо шрифтовых глифов - [ ] Расширение браузера для автозаполнения diff --git a/src/app.rs b/src/app.rs index d59cd51..9e5d2bd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,6 +16,7 @@ use zeroize::Zeroizing; use crate::config::{Config, ThemeMode}; use crate::crypto::VaultKey; use crate::generator; +use crate::icons::Icon; use crate::storage::{self, ForeignKey, Session}; use crate::theme::{self, Pal, bold}; use crate::totp::Totp; @@ -89,12 +90,12 @@ impl Toasts { Frame::new() .fill(pal.card) .stroke(Stroke::new(1.0, pal.border)) - .corner_radius(CornerRadius::same(10)) + .corner_radius(CornerRadius::same(theme::R_CARD)) .inner_margin(Margin::symmetric(14, 9)) .shadow(ui.visuals().popup_shadow) .show(ui, |ui| { ui.horizontal(|ui| { - ui.label(RichText::new("●").color(dot).size(11.0)); + crate::widgets::dot(ui, dot); ui.label(RichText::new(text).color(pal.text)); }); }); @@ -312,7 +313,7 @@ impl MainView { self.dirty = false; self.saved_at = Some(Self::now_label()); if r.any() { - toasts.info(format!("🔄 Подтянуты изменения с другого устройства: {}", describe(&r))); + toasts.info(format!("Подтянуты изменения с другого устройства: {}", describe(&r))); } } Err(e) if e.is::() => self.conflict = true, @@ -333,7 +334,7 @@ impl MainView { Ok(Some(r)) => { self.saved_at = Some(Self::now_label()); if r.any() { - toasts.info(format!("🔄 Синхронизировано: {}", describe(&r))); + toasts.info(format!("Синхронизировано: {}", describe(&r))); } } Err(e) if e.is::() => self.conflict = true, @@ -610,8 +611,7 @@ fn centered(ui: &mut Ui, pal: &Pal, width: f32, add: impl FnOnce(&mut Ui)) { ui.vertical_centered(|ui| { let r = frame_card(pal) .inner_margin(Margin::same(32)) - .corner_radius(CornerRadius::same(18)) - .shadow(ui.visuals().window_shadow) + .corner_radius(CornerRadius::same(theme::R_WIN)) .show(ui, |ui| { ui.set_width(width); add(ui) @@ -647,7 +647,7 @@ impl QulypApp { centered(ui, pal, 380.0, |ui| { logo(ui, 64.0); ui.add_space(10.0); - ui.label(RichText::new("Qulyp").font(egui::FontId::new(30.0, bold())).color(pal.text)); + ui.label(RichText::new("Qulyp").font(egui::FontId::new(24.0, bold())).color(pal.text)); ui.label( RichText::new("Локальный менеджер паролей.\nОдин зашифрованный файл — данные остаются у вас.") .color(pal.weak), @@ -657,7 +657,7 @@ impl QulypApp { next = Some(Screen::Create(CreateForm::new())); } ui.add_space(2.0); - if secondary(ui, "Открыть файл…", 380.0).clicked() + if secondary(ui, pal, "Открыть файл…", 380.0).clicked() && let Some(p) = pick_vault_file() { next = Some(Screen::Unlock(UnlockForm::new(p))); @@ -674,7 +674,7 @@ impl QulypApp { if r.clicked() { next = Some(Screen::Unlock(UnlockForm::new(p.clone()))); } - if icon_btn(ui, "×", "Убрать из списка").clicked() { + if icon_btn(ui, pal, Icon::Close, "Убрать из списка").clicked() { remove = Some(i); } }); @@ -698,12 +698,12 @@ impl QulypApp { centered(ui, pal, 360.0, |ui| { logo(ui, 56.0); ui.add_space(8.0); - ui.label(RichText::new(&name).font(egui::FontId::new(22.0, bold())).color(pal.text)); + ui.label(RichText::new(&name).font(egui::FontId::new(20.0, bold())).color(pal.text)); ui.add(egui::Label::new(RichText::new(f.path.display().to_string()).size(12.0).color(pal.weak)).truncate()); ui.add_space(16.0); left(ui, |ui| { ui.add_enabled_ui(!f.busy, |ui| { - let r = password_field(ui, &mut f.pw, &mut f.show, "Мастер-пароль", |_| {}); + let r = password_field(ui, pal, &mut f.pw, &mut f.show, "Мастер-пароль", |_| {}); if !f.busy && ui.memory(|m| m.focused().is_none()) { r.request_focus(); } @@ -751,7 +751,7 @@ impl QulypApp { centered(ui, pal, 400.0, |ui| { logo(ui, 48.0); ui.add_space(6.0); - ui.label(RichText::new("Новое хранилище").font(egui::FontId::new(22.0, bold())).color(pal.text)); + ui.label(RichText::new("Новое хранилище").font(egui::FontId::new(20.0, bold())).color(pal.text)); ui.add_space(12.0); left(ui, |ui| { ui.add_enabled_ui(!f.busy, |ui| { @@ -787,20 +787,21 @@ impl QulypApp { ); field_label(ui, pal, "Мастер-пароль"); let mut show = f.show; - password_field(ui, &mut f.pw, &mut show, "Минимум 8 символов", |_| {}); + password_field(ui, pal, &mut f.pw, &mut show, "Минимум 8 символов", |_| {}); strength_bar(ui, pal, &f.pw); field_label(ui, pal, "Повторите пароль"); - let r = password_field(ui, &mut f.pw2, &mut show, "Ещё раз", |_| {}); + let r = password_field(ui, pal, &mut f.pw2, &mut show, "Ещё раз", |_| {}); f.show = show; if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { submit = true; } }); ui.add_space(4.0); - ui.label( - RichText::new("⚠ Мастер-пароль нельзя восстановить. Без него данные не расшифровать — запомните или запишите его.") - .size(12.0) - .color(pal.warn), + note( + ui, + Icon::Warning, + "Мастер-пароль нельзя восстановить. Без него данные не расшифровать — запомните или запишите его.", + pal.warn, ); if let Some(e) = &f.error { ui.label(RichText::new(e).color(pal.danger).size(13.0)); @@ -846,10 +847,12 @@ impl QulypApp { fn entry_row_small(ui: &mut Ui, pal: &Pal, name: &str, dir: &str, exists: bool, width: f32) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(width, 42.0), egui::Sense::click()); if resp.hovered() { - ui.painter().rect_filled(rect, CornerRadius::same(8), pal.hover); + ui.painter().rect_filled(rect, CornerRadius::same(theme::R_CTRL), pal.hover); } + let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 18.0, rect.center().y), vec2(16.0, 16.0)); + crate::icons::paint(ui.painter(), icon, Icon::File, pal.weak); let clip = ui.painter().with_clip_rect(rect.shrink(4.0)); - let x = rect.left() + 10.0; + let x = rect.left() + 36.0; let c = if exists { pal.text } else { pal.weak }; clip.text( egui::pos2(x, rect.center().y - 8.0), @@ -890,16 +893,20 @@ impl QulypApp { ui.label(RichText::new(&mv.s.vault.name).font(egui::FontId::new(16.0, bold())).color(pal.text)); ui.label(RichText::new(mv.s.file_name()).size(12.5).color(pal.weak)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if secondary(ui, "🔒 Заблокировать", 0.0).on_hover_text("Ctrl+L").clicked() { + if button(ui, pal, Some(Icon::Lock), "Заблокировать", Tone::Secondary, 0.0) + .on_hover_text("Ctrl+L") + .clicked() + { next = Some(self.lock(mv)); } - if icon_btn(ui, "⚙", "Настройки").clicked() { + if icon_btn(ui, pal, Icon::Gear, "Настройки").clicked() { self.settings_open = !self.settings_open; } - if icon_btn(ui, "🎲", "Генератор паролей (Ctrl+G)").clicked() { + if icon_btn(ui, pal, Icon::Dice, "Генератор паролей (Ctrl+G)").clicked() { self.gen_open = !self.gen_open; } - if icon_btn(ui, "🔄", "Синхронизация с другим файлом").clicked() { + if icon_btn(ui, pal, Icon::Sync, "Синхронизация с другим файлом").clicked() + { mv.sync = Some(SyncDialog { path: String::new(), pw: secret(), @@ -930,7 +937,7 @@ impl QulypApp { .map_or("Всё сохранено".into(), |t| format!("Сохранено в {t}")), ) }; - ui.label(RichText::new("●").color(dot).size(10.0)); + crate::widgets::dot(ui, dot); ui.label(RichText::new(text).size(12.0).color(pal.weak)); ui.label(RichText::new("·").color(pal.weak)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { @@ -1051,17 +1058,17 @@ impl QulypApp { let v = &mv.s.vault; let fav = v.entries.iter().filter(|e| e.favorite).count(); section_label(ui, pal, " ХРАНИЛИЩЕ"); - if nav_item(ui, pal, "☰", "Все записи", v.entries.len(), mv.filter == Filter::All).clicked() { + if nav_item(ui, pal, Icon::List, "Все записи", v.entries.len(), mv.filter == Filter::All).clicked() { mv.filter = Filter::All; } - if nav_item(ui, pal, "★", "Избранное", fav, mv.filter == Filter::Favorites).clicked() { + if nav_item(ui, pal, Icon::Star, "Избранное", fav, mv.filter == Filter::Favorites).clicked() { mv.filter = Filter::Favorites; } ui.add_space(12.0); ui.horizontal(|ui| { section_label(ui, pal, " ПАПКИ"); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if icon_btn(ui, "+", "Новая папка").clicked() { + if icon_btn(ui, pal, Icon::Plus, "Новая папка").clicked() { mv.group_dialog = Some(GroupDialog::New(String::new())); } }); @@ -1074,16 +1081,16 @@ impl QulypApp { ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { for (id, name) in groups { let count = mv.s.vault.entries.iter().filter(|e| e.group == Some(id)).count(); - let r = nav_item(ui, pal, "🗀", &name, count, mv.filter == Filter::Group(id)); + let r = nav_item(ui, pal, Icon::Folder, &name, count, mv.filter == Filter::Group(id)); if r.clicked() { mv.filter = Filter::Group(id); } r.context_menu(|ui| { - if ui.button("✏ Переименовать").clicked() { + if ui.button("Переименовать").clicked() { mv.group_dialog = Some(GroupDialog::Rename(id, name.clone())); ui.close(); } - if ui.button("🗑 Удалить папку").clicked() { + if ui.button("Удалить папку").clicked() { mv.confirm_delete_group = Some(id); ui.close(); } @@ -1095,12 +1102,8 @@ impl QulypApp { fn entry_list(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) { ui.horizontal(|ui| { ui.add_space(8.0); - let r = ui.add( - TextEdit::singleline(&mut mv.search) - .hint_text("🔍 Поиск Ctrl+F") - .desired_width(ui.available_width() - 8.0) - .margin(Margin::symmetric(10, 7)), - ); + let w = ui.available_width() - 8.0; + let r = search_field(ui, pal, &mut mv.search, "Поиск Ctrl+F", w); if mv.focus_search { r.request_focus(); mv.focus_search = false; @@ -1108,7 +1111,9 @@ impl QulypApp { }); ui.horizontal(|ui| { ui.add_space(8.0); - if primary(ui, pal, "+ Новая запись", ui.available_width() - 8.0).on_hover_text("Ctrl+N").clicked() + if button(ui, pal, Some(Icon::Plus), "Новая запись", Tone::Primary, ui.available_width() - 8.0) + .on_hover_text("Ctrl+N") + .clicked() { start_new(mv); } @@ -1146,25 +1151,25 @@ impl QulypApp { copy = Some((Zeroizing::new(e.password.clone()), "Пароль")); } r.context_menu(|ui| { - if ui.button("👤 Копировать логин").clicked() { + if ui.button("Копировать логин").clicked() { copy = Some((Zeroizing::new(e.username.clone()), "Логин")); ui.close(); } - if ui.button("🔑 Копировать пароль").clicked() { + if ui.button("Копировать пароль").clicked() { copy = Some((Zeroizing::new(e.password.clone()), "Пароль")); ui.close(); } - if !e.url.is_empty() && ui.button("🌐 Открыть сайт").clicked() { + if !e.url.is_empty() && ui.button("Открыть сайт").clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(full_url(&e.url))); ui.close(); } ui.separator(); - if ui.button("✏ Изменить").clicked() { + if ui.button("Изменить").clicked() { mv.selected = Some(id); mv.editor = Some(Editor { draft: e.clone(), is_new: false, show_pw: false }); ui.close(); } - if ui.button("🗑 Удалить").clicked() { + if ui.button("Удалить").clicked() { mv.confirm_delete = Some(id); ui.close(); } @@ -1178,17 +1183,21 @@ impl QulypApp { fn conflict_banner(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) { Frame::new() - .fill(pal.warn.gamma_multiply(0.15)) - .stroke(Stroke::new(1.0, pal.warn.gamma_multiply(0.6))) - .corner_radius(CornerRadius::same(12)) + .fill(pal.card) + .stroke(Stroke::new(1.0, pal.warn.gamma_multiply(0.7))) + .corner_radius(CornerRadius::same(theme::R_CARD)) .inner_margin(Margin::same(14)) .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.label( - RichText::new("⚠ Файл изменён с другим мастер-паролем") - .font(egui::FontId::new(15.0, bold())) - .color(pal.text), - ); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, Icon::Warning, pal.warn); + ui.label( + RichText::new("Файл изменён с другим мастер-паролем") + .font(egui::FontId::new(15.0, bold())) + .color(pal.text), + ); + }); ui.label( RichText::new( "Похоже, пароль сменили на другом устройстве. Автосинхронизация приостановлена, \ @@ -1207,7 +1216,7 @@ impl QulypApp { busy: false, }); } - if secondary(ui, "Перезаписать моей версией", 0.0).clicked() { + if secondary(ui, pal, "Перезаписать моей версией", 0.0).clicked() { mv.confirm_overwrite = true; } }); @@ -1221,30 +1230,34 @@ impl QulypApp { let mut copy: Option<(Secret, &'static str)> = None; ui.horizontal(|ui| { - avatar(ui, if e.title.is_empty() { "?" } else { &e.title }, 52.0); + avatar(ui, pal, if e.title.is_empty() { "?" } else { &e.title }, 44.0); ui.add_space(6.0); ui.vertical(|ui| { ui.add( egui::Label::new( RichText::new(if e.title.is_empty() { "Без названия" } else { &e.title }) - .font(egui::FontId::new(22.0, bold())) + .font(egui::FontId::new(20.0, bold())) .color(pal.text), ) .truncate(), ); - ui.label( - RichText::new(format!("🗀 {}", group.as_deref().unwrap_or("Без папки"))).size(12.5).color(pal.weak), - ); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (r, _) = ui.allocate_exact_size(vec2(13.0, 13.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, Icon::Folder, pal.weak); + ui.label(RichText::new(group.as_deref().unwrap_or("Без папки")).size(12.5).color(pal.weak)); + }); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if icon_btn(ui, "🗑", "Удалить (Del)").clicked() { + if icon_btn_tone(ui, pal, Icon::Trash, "Удалить (Del)", false, pal.danger).clicked() { mv.confirm_delete = Some(id); } - if icon_btn(ui, if e.favorite { "★" } else { "☆" }, "Избранное").clicked() { + if icon_btn(ui, pal, if e.favorite { Icon::StarFilled } else { Icon::Star }, "Избранное").clicked() + { mv.s.vault.toggle_favorite(id); mv.persist(&mut self.toasts); } - if secondary(ui, "✏ Изменить", 0.0).clicked() { + if button(ui, pal, Some(Icon::Edit), "Изменить", Tone::Secondary, 0.0).clicked() { mv.editor = Some(Editor { draft: e.clone(), is_new: false, show_pw: false }); } }); @@ -1258,7 +1271,7 @@ impl QulypApp { if !e.username.is_empty() { any = true; value_row(ui, pal, "Логин", |ui| { - if icon_btn(ui, "🗐", "Копировать (Ctrl+B)").clicked() { + if icon_btn(ui, pal, Icon::Copy, "Копировать (Ctrl+B)").clicked() { copy = Some((Zeroizing::new(e.username.clone()), "Логин")); } ui.with_layout(Layout::left_to_right(Align::Center), |ui| { @@ -1269,10 +1282,18 @@ impl QulypApp { if !e.password.is_empty() { any = true; value_row(ui, pal, "Пароль", |ui| { - if icon_btn(ui, "🗐", "Копировать (Ctrl+C)").clicked() { + if icon_btn(ui, pal, Icon::Copy, "Копировать (Ctrl+C)").clicked() { copy = Some((Zeroizing::new(e.password.clone()), "Пароль")); } - if toggle_btn(ui, "👁", mv.reveal, "Показать/скрыть").clicked() { + if toggle_btn( + ui, + pal, + if mv.reveal { Icon::EyeOff } else { Icon::Eye }, + mv.reveal, + "Показать/скрыть", + ) + .clicked() + { mv.reveal = !mv.reveal; } ui.with_layout(Layout::left_to_right(Align::Center), |ui| { @@ -1290,10 +1311,10 @@ impl QulypApp { if !e.url.is_empty() { any = true; value_row(ui, pal, "Сайт", |ui| { - if icon_btn(ui, "🗐", "Копировать").clicked() { + if icon_btn(ui, pal, Icon::Copy, "Копировать").clicked() { copy = Some((Zeroizing::new(e.url.clone()), "Адрес")); } - if icon_btn(ui, "↗", "Открыть в браузере").clicked() { + if icon_btn(ui, pal, Icon::External, "Открыть в браузере").clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(full_url(&e.url))); } ui.with_layout(Layout::left_to_right(Align::Center), |ui| { @@ -1312,7 +1333,7 @@ impl QulypApp { any = true; let (code, left) = t.now(); value_row(ui, pal, "Код 2FA", |ui| { - if icon_btn(ui, "🗐", "Копировать код").clicked() { + if icon_btn(ui, pal, Icon::Copy, "Копировать код").clicked() { copy = Some((Zeroizing::new(code.clone()), "Код")); } ui.with_layout(Layout::left_to_right(Align::Center), |ui| { @@ -1351,7 +1372,7 @@ impl QulypApp { .show(ui, |ui| { for h in &e.history { ui.horizontal(|ui| { - if icon_btn(ui, "🗐", "Копировать").clicked() { + if icon_btn(ui, pal, Icon::Copy, "Копировать").clicked() { copy = Some((Zeroizing::new(h.password.clone()), "Старый пароль")); } let shown = if mv.reveal { h.password.clone() } else { "•".repeat(12) }; @@ -1376,7 +1397,7 @@ impl QulypApp { ui.label( RichText::new(if ed.is_new { "Новая запись" } else { "Редактирование" }) - .font(egui::FontId::new(22.0, bold())) + .font(egui::FontId::new(20.0, bold())) .color(pal.text), ); ui.add_space(10.0); @@ -1393,8 +1414,9 @@ impl QulypApp { text_field(ui, &mut d.username, "user@example.com"); field_label(ui, pal, "Пароль"); let mut generate = false; - password_field(ui, &mut d.password, &mut ed.show_pw, "Пароль", |ui| { - if icon_btn(ui, "🎲", "Сгенерировать надёжный пароль").clicked() { + password_field(ui, pal, &mut d.password, &mut ed.show_pw, "Пароль", |ui| { + if icon_btn(ui, pal, Icon::Dice, "Сгенерировать надёжный пароль").clicked() + { generate = true; } }); @@ -1425,7 +1447,7 @@ impl QulypApp { match Totp::parse(&d.totp) { Some(t) => { ui.label( - RichText::new(format!("✓ Текущий код: {}", t.now().0)).size(12.0).color(pal.success), + RichText::new(format!("Текущий код: {}", t.now().0)).size(12.0).color(pal.success), ); } None => { @@ -1442,14 +1464,14 @@ impl QulypApp { .hint_text("Секретные вопросы, PIN-коды, что угодно — всё шифруется"), ); ui.add_space(4.0); - ui.checkbox(&mut d.favorite, "★ В избранном"); + ui.checkbox(&mut d.favorite, "В избранном"); }); ui.add_space(14.0); ui.horizontal(|ui| { if primary(ui, pal, "Сохранить", 140.0).on_hover_text("Ctrl+S").clicked() { save = true; } - if secondary(ui, "Отмена", 110.0).on_hover_text("Esc").clicked() { + if secondary(ui, pal, "Отмена", 110.0).on_hover_text("Esc").clicked() { cancel = true; } }); @@ -1503,7 +1525,7 @@ impl QulypApp { mv.persist(&mut self.toasts); close = true; } - if secondary(ui, "Отмена", 100.0).clicked() { + if secondary(ui, pal, "Отмена", 100.0).clicked() { close = true; } }); @@ -1530,7 +1552,7 @@ impl QulypApp { mv.persist(&mut self.toasts); close = true; } - if secondary(ui, "Отмена", 100.0).clicked() { + if secondary(ui, pal, "Отмена", 100.0).clicked() { close = true; } }); @@ -1566,7 +1588,7 @@ impl QulypApp { } close = true; } - if secondary(ui, "Отмена", 100.0).clicked() { + if secondary(ui, pal, "Отмена", 100.0).clicked() { close = true; } }); @@ -1597,7 +1619,7 @@ impl QulypApp { if primary(ui, pal, "Готово", 100.0).clicked() { done = true; } - if secondary(ui, "Отмена", 100.0).clicked() { + if secondary(ui, pal, "Отмена", 100.0).clicked() { close = true; } }); @@ -1659,7 +1681,7 @@ impl QulypApp { } }); field_label(ui, pal, "Пароль того файла"); - let r = password_field(ui, &mut dlg.pw, &mut dlg.show, "Пусто — если пароль тот же", |_| {}); + let r = password_field(ui, pal, &mut dlg.pw, &mut dlg.show, "Пусто — если пароль тот же", |_| {}); if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { submit = true; } @@ -1679,7 +1701,7 @@ impl QulypApp { if primary(ui, pal, "Синхронизировать", 170.0).clicked() { submit = true; } - if secondary(ui, "Отмена", 100.0).clicked() { + if secondary(ui, pal, "Отмена", 100.0).clicked() { close = true; } } @@ -1722,12 +1744,12 @@ impl QulypApp { ui.add_enabled_ui(!dlg.busy, |ui| { let mut show = dlg.show; field_label(ui, pal, "Текущий пароль"); - password_field(ui, &mut dlg.current, &mut show, "", |_| {}); + password_field(ui, pal, &mut dlg.current, &mut show, "", |_| {}); field_label(ui, pal, "Новый пароль"); - password_field(ui, &mut dlg.new, &mut show, "Минимум 8 символов", |_| {}); + password_field(ui, pal, &mut dlg.new, &mut show, "Минимум 8 символов", |_| {}); strength_bar(ui, pal, &dlg.new); field_label(ui, pal, "Повторите новый"); - password_field(ui, &mut dlg.confirm, &mut show, "", |_| {}); + password_field(ui, pal, &mut dlg.confirm, &mut show, "", |_| {}); dlg.show = show; }); ui.label( @@ -1747,7 +1769,7 @@ impl QulypApp { if primary(ui, pal, "Сменить", 130.0).clicked() { submit = true; } - if secondary(ui, "Отмена", 100.0).clicked() { + if secondary(ui, pal, "Отмена", 100.0).clicked() { close = true; } } @@ -1798,9 +1820,11 @@ impl QulypApp { .anchor(Align2::RIGHT_TOP, vec2(-20.0, 64.0)) .show(ctx, |ui| { ui.set_width(380.0); - Frame::new().fill(pal.field).corner_radius(CornerRadius::same(10)).inner_margin(Margin::same(12)).show( - ui, - |ui| { + Frame::new() + .fill(pal.field) + .corner_radius(CornerRadius::same(theme::R_CTRL)) + .inner_margin(Margin::same(12)) + .show(ui, |ui| { ui.set_width(ui.available_width()); ui.add( egui::Label::new( @@ -1809,18 +1833,17 @@ impl QulypApp { .wrap() .selectable(true), ); - }, - ); + }); strength_bar(ui, pal, &self.gen_value); ui.horizontal(|ui| { - if primary(ui, pal, "🗐 Копировать", 0.0).clicked() { + if button(ui, pal, Some(Icon::Copy), "Копировать", Tone::Primary, 0.0).clicked() { copy = true; } - if secondary(ui, "🔄 Ещё", 0.0).clicked() { + if button(ui, pal, Some(Icon::Dice), "Другой", Tone::Secondary, 0.0).clicked() { self.gen_value = Zeroizing::new(generator::generate(&self.cfg.generator)); } if let Some(ed) = mv.editor.as_mut() - && secondary(ui, "Вставить в запись", 0.0).clicked() + && secondary(ui, pal, "Вставить в запись", 0.0).clicked() { ed.draft.password = self.gen_value.to_string(); ed.show_pw = true; @@ -1889,7 +1912,7 @@ impl QulypApp { }); if in_main { ui.add_space(8.0); - if secondary(ui, "🔑 Сменить мастер-пароль…", 0.0).clicked() { + if button(ui, pal, Some(Icon::Key), "Сменить мастер-пароль…", Tone::Secondary, 0.0).clicked() { change_pw = true; } } diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..33175ae --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,184 @@ +//! Векторные иконки: сетка 16×16, одна толщина линии, без шрифтовых глифов. + +use std::f32::consts::{PI, TAU}; + +use eframe::egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Icon { + Copy, + Eye, + EyeOff, + External, + Trash, + Star, + StarFilled, + Edit, + Lock, + Gear, + Sync, + Dice, + Plus, + Folder, + List, + Search, + Close, + Warning, + Key, + File, +} + +/// Нарисовать иконку в квадрате `rect` (обычно 16×16). +pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { + let s = rect.width() / 16.0; + let o = rect.min; + let at = |x: f32, y: f32| pos2(o.x + x * s, o.y + y * s); + let st = Stroke::new((1.35 * s).max(1.1), color); + let line = |pts: &[(f32, f32)]| { + p.add(Shape::line(pts.iter().map(|&(x, y)| at(x, y)).collect(), st)); + }; + let closed = |pts: &[(f32, f32)]| { + p.add(Shape::closed_line(pts.iter().map(|&(x, y)| at(x, y)).collect(), st)); + }; + let arc = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| -> Vec { + let n = 20; + (0..=n) + .map(|i| { + let a = a0 + (a1 - a0) * i as f32 / n as f32; + at(cx + r * a.cos(), cy + r * a.sin()) + }) + .collect() + }; + + match icon { + Icon::Copy => { + line(&[(5.5, 5.0), (5.5, 2.5), (13.5, 2.5), (13.5, 10.5), (11.0, 10.5)]); + closed(&[(2.5, 5.5), (10.5, 5.5), (10.5, 13.5), (2.5, 13.5)]); + } + Icon::Eye | Icon::EyeOff => { + let mut top = arc(8.0, 13.0, 8.2, -PI + 0.66, -0.66); + let bottom = arc(8.0, 3.0, 8.2, 0.66, PI - 0.66); + top.extend(bottom); + p.add(Shape::closed_line(top, st)); + p.circle_stroke(at(8.0, 8.0), 2.1 * s, st); + if icon == Icon::EyeOff { + line(&[(2.5, 13.5), (13.5, 2.5)]); + } + } + Icon::External => { + line(&[(7.0, 3.0), (3.0, 3.0), (3.0, 13.0), (13.0, 13.0), (13.0, 9.0)]); + line(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)]); + line(&[(13.3, 2.7), (7.5, 8.5)]); + } + Icon::Trash => { + line(&[(2.5, 4.5), (13.5, 4.5)]); + line(&[(6.0, 4.5), (6.0, 2.5), (10.0, 2.5), (10.0, 4.5)]); + line(&[(4.0, 4.5), (4.8, 13.5), (11.2, 13.5), (12.0, 4.5)]); + line(&[(6.8, 7.0), (6.8, 11.0)]); + line(&[(9.2, 7.0), (9.2, 11.0)]); + } + Icon::Star | Icon::StarFilled => { + let pts: Vec = (0..10) + .map(|i| { + let r = if i % 2 == 0 { 6.3 } else { 2.7 }; + let a = -PI / 2.0 + i as f32 * TAU / 10.0; + at(8.0 + r * a.cos(), 8.6 + r * a.sin()) + }) + .collect(); + if icon == Icon::StarFilled { + let mut mesh = Mesh::default(); + mesh.colored_vertex(at(8.0, 8.6), color); + for &q in &pts { + mesh.colored_vertex(q, color); + } + for i in 0..10u32 { + mesh.add_triangle(0, 1 + i, 1 + (i + 1) % 10); + } + p.add(Shape::mesh(mesh)); + } + p.add(Shape::closed_line(pts, st)); + } + Icon::Edit => { + closed(&[(3.0, 13.0), (3.0, 10.5), (10.5, 3.0), (13.0, 5.5), (5.5, 13.0)]); + line(&[(8.8, 4.7), (11.3, 7.2)]); + } + Icon::Lock => { + let mut sh = vec![at(5.0, 7.5)]; + sh.extend(arc(8.0, 5.5, 3.0, PI, TAU)); + sh.push(at(11.0, 7.5)); + p.add(Shape::line(sh, st)); + p.rect_stroke( + Rect::from_min_max(at(3.0, 7.5), at(13.0, 14.0)), + 1.5 * s, + st, + eframe::egui::StrokeKind::Middle, + ); + line(&[(8.0, 10.0), (8.0, 11.6)]); + } + Icon::Gear => { + p.circle_stroke(at(8.0, 8.0), 2.2 * s, st); + p.circle_stroke(at(8.0, 8.0), 4.6 * s, st); + for i in 0..8 { + let a = i as f32 * TAU / 8.0; + let (c, sn) = (a.cos(), a.sin()); + p.line_segment( + [at(8.0 + 4.6 * c, 8.0 + 4.6 * sn), at(8.0 + 6.4 * c, 8.0 + 6.4 * sn)], + Stroke::new(st.width * 1.6, color), + ); + } + } + Icon::Sync => { + p.add(Shape::line(arc(8.0, 8.0, 5.2, PI * 1.1, PI * 1.9), st)); + line(&[(10.8, 2.6), (12.9, 4.9), (10.4, 5.9)]); + p.add(Shape::line(arc(8.0, 8.0, 5.2, PI * 0.1, PI * 0.9), st)); + line(&[(5.2, 13.4), (3.1, 11.1), (5.6, 10.1)]); + } + Icon::Dice => { + p.rect_stroke( + Rect::from_min_max(at(2.5, 2.5), at(13.5, 13.5)), + 2.5 * s, + st, + eframe::egui::StrokeKind::Middle, + ); + for (x, y) in [(5.5, 5.5), (8.0, 8.0), (10.5, 10.5)] { + p.circle_filled(at(x, y), 1.1 * s, color); + } + } + Icon::Plus => { + line(&[(8.0, 3.0), (8.0, 13.0)]); + line(&[(3.0, 8.0), (13.0, 8.0)]); + } + Icon::Folder => { + closed(&[(2.0, 3.5), (6.3, 3.5), (7.8, 5.2), (14.0, 5.2), (14.0, 13.0), (2.0, 13.0)]); + } + Icon::List => { + for y in [4.0, 8.0, 12.0] { + line(&[(5.5, y), (13.5, y)]); + p.circle_filled(at(2.8, y), 0.95 * s, color); + } + } + Icon::Search => { + p.circle_stroke(at(7.0, 7.0), 4.3 * s, st); + line(&[(10.2, 10.2), (13.5, 13.5)]); + } + Icon::Close => { + line(&[(4.0, 4.0), (12.0, 12.0)]); + line(&[(12.0, 4.0), (4.0, 12.0)]); + } + Icon::Warning => { + closed(&[(8.0, 2.2), (14.5, 13.5), (1.5, 13.5)]); + line(&[(8.0, 6.3), (8.0, 9.6)]); + p.circle_filled(at(8.0, 11.6), 0.9 * s, color); + } + Icon::Key => { + p.circle_stroke(at(5.2, 10.8), 2.9 * s, st); + line(&[(7.3, 8.7), (13.5, 2.5)]); + line(&[(11.2, 4.8), (12.9, 6.5)]); + line(&[(9.6, 6.4), (10.9, 7.7)]); + } + Icon::File => { + closed(&[(3.5, 1.8), (9.5, 1.8), (12.5, 4.8), (12.5, 14.2), (3.5, 14.2)]); + line(&[(9.3, 2.0), (9.3, 5.0), (12.3, 5.0)]); + } + } +} diff --git a/src/main.rs b/src/main.rs index 6cf5ec0..3ab6d3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod crypto; #[cfg(feature = "dev-shots")] mod devshots; mod generator; +mod icons; mod storage; mod theme; mod totp; diff --git a/src/theme.rs b/src/theme.rs index 9f488a0..7522a93 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,7 @@ //! Цвета, стиль виджетов, шрифты и иконка окна. +//! +//! Строгая графитовая палитра: нейтрали делают основную работу, единственный +//! приглушённый стальной акцент — только для главного действия, выделения и фокуса. use std::sync::Arc; @@ -7,6 +10,11 @@ use eframe::egui::{ Style, TextStyle, Theme, Visuals, vec2, }; +/// Радиусы скругления: контролы, карточки/панели, окна. +pub const R_CTRL: u8 = 4; +pub const R_CARD: u8 = 6; +pub const R_WIN: u8 = 8; + #[derive(Clone, Copy)] pub struct Pal { pub bg: Color32, @@ -14,10 +22,16 @@ pub struct Pal { pub list: Color32, pub card: Color32, pub field: Color32, + /// Разделители и рамки панелей. pub border: Color32, + /// Рамки интерактивных контролов (контраст ≥ 3:1 к фону). + pub control: Color32, pub text: Color32, pub weak: Color32, + /// Заливка главной кнопки (под белый текст). pub accent: Color32, + /// Акцент как цвет текста/иконки на фоне. + pub accent_fg: Color32, pub accent_soft: Color32, pub hover: Color32, pub danger: Color32, @@ -25,42 +39,46 @@ pub struct Pal { pub warn: Color32, } -pub const ACCENT: Color32 = Color32::from_rgb(0x5B, 0x8C, 0xFF); +pub const ACCENT: Color32 = Color32::from_rgb(0x44, 0x6F, 0xB0); pub fn pal(dark: bool) -> Pal { if dark { Pal { - bg: Color32::from_rgb(0x12, 0x14, 0x19), - sidebar: Color32::from_rgb(0x0E, 0x10, 0x14), - list: Color32::from_rgb(0x16, 0x19, 0x1F), - card: Color32::from_rgb(0x1C, 0x20, 0x28), - field: Color32::from_rgb(0x23, 0x27, 0x31), - border: Color32::from_rgb(0x2A, 0x2F, 0x3A), - text: Color32::from_rgb(0xE8, 0xEA, 0xF0), - weak: Color32::from_rgb(0x8A, 0x91, 0xA2), + bg: Color32::from_rgb(0x11, 0x12, 0x14), + sidebar: Color32::from_rgb(0x0D, 0x0E, 0x10), + list: Color32::from_rgb(0x14, 0x15, 0x18), + card: Color32::from_rgb(0x17, 0x19, 0x1C), + field: Color32::from_rgb(0x1B, 0x1D, 0x21), + border: Color32::from_rgb(0x25, 0x28, 0x2D), + control: Color32::from_rgb(0x3A, 0x3E, 0x45), + text: Color32::from_rgb(0xE4, 0xE6, 0xE9), + weak: Color32::from_rgb(0x8C, 0x92, 0x9A), accent: ACCENT, - accent_soft: Color32::from_rgba_unmultiplied(0x5B, 0x8C, 0xFF, 40), - hover: Color32::from_rgb(0x20, 0x24, 0x2D), - danger: Color32::from_rgb(0xFF, 0x5C, 0x6C), - success: Color32::from_rgb(0x3C, 0xCB, 0x7F), - warn: Color32::from_rgb(0xF5, 0xB9, 0x42), + accent_fg: Color32::from_rgb(0x8F, 0xAE, 0xDB), + accent_soft: Color32::from_rgba_unmultiplied(0x5B, 0x84, 0xC4, 34), + hover: Color32::from_rgb(0x1D, 0x1F, 0x23), + danger: Color32::from_rgb(0xD2, 0x6A, 0x6F), + success: Color32::from_rgb(0x6B, 0xB0, 0x8A), + warn: Color32::from_rgb(0xC8, 0xA5, 0x5A), } } else { Pal { - bg: Color32::from_rgb(0xF6, 0xF7, 0xF9), - sidebar: Color32::from_rgb(0xEC, 0xEE, 0xF2), - list: Color32::from_rgb(0xFB, 0xFB, 0xFC), + bg: Color32::from_rgb(0xF4, 0xF5, 0xF6), + sidebar: Color32::from_rgb(0xEB, 0xEC, 0xEE), + list: Color32::from_rgb(0xF9, 0xF9, 0xFA), card: Color32::WHITE, - field: Color32::from_rgb(0xF0, 0xF2, 0xF5), - border: Color32::from_rgb(0xDD, 0xE1, 0xE7), - text: Color32::from_rgb(0x1C, 0x1F, 0x26), - weak: Color32::from_rgb(0x6B, 0x72, 0x80), - accent: Color32::from_rgb(0x3B, 0x6E, 0xF0), - accent_soft: Color32::from_rgba_unmultiplied(0x3B, 0x6E, 0xF0, 30), - hover: Color32::from_rgb(0xE9, 0xEC, 0xF1), - danger: Color32::from_rgb(0xE0, 0x3A, 0x4B), - success: Color32::from_rgb(0x1F, 0xA8, 0x62), - warn: Color32::from_rgb(0xC9, 0x8A, 0x10), + field: Color32::from_rgb(0xFF, 0xFF, 0xFF), + border: Color32::from_rgb(0xDD, 0xE0, 0xE4), + control: Color32::from_rgb(0xB9, 0xBE, 0xC6), + text: Color32::from_rgb(0x16, 0x18, 0x1B), + weak: Color32::from_rgb(0x5C, 0x62, 0x6A), + accent: Color32::from_rgb(0x2F, 0x5C, 0xA0), + accent_fg: Color32::from_rgb(0x2F, 0x5C, 0xA0), + accent_soft: Color32::from_rgba_unmultiplied(0x2F, 0x5C, 0xA0, 22), + hover: Color32::from_rgb(0xE6, 0xE8, 0xEB), + danger: Color32::from_rgb(0xB0, 0x3E, 0x46), + success: Color32::from_rgb(0x2E, 0x7D, 0x55), + warn: Color32::from_rgb(0x8C, 0x68, 0x14), } } } @@ -77,20 +95,23 @@ fn visuals(dark: bool) -> Visuals { v.faint_bg_color = p.list; v.extreme_bg_color = p.field; v.text_edit_bg_color = Some(p.field); + v.code_bg_color = p.field; v.window_stroke = Stroke::new(1.0, p.border); - v.window_corner_radius = CornerRadius::same(14); - v.menu_corner_radius = CornerRadius::same(10); - v.window_shadow = - Shadow { offset: [0, 10], blur: 30, spread: 0, color: Color32::from_black_alpha(if dark { 110 } else { 40 }) }; - v.popup_shadow = - Shadow { offset: [0, 6], blur: 18, spread: 0, color: Color32::from_black_alpha(if dark { 90 } else { 30 }) }; - v.hyperlink_color = p.accent; - v.selection.bg_fill = p.accent.gamma_multiply(0.45); - v.selection.stroke = Stroke::new(1.0, p.accent); + v.window_corner_radius = CornerRadius::same(R_WIN); + v.menu_corner_radius = CornerRadius::same(R_CARD); + let a = if dark { 90 } else { 26 }; + v.window_shadow = Shadow { offset: [0, 6], blur: 20, spread: 0, color: Color32::from_black_alpha(a) }; + v.popup_shadow = Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(a) }; + v.hyperlink_color = p.accent_fg; + v.selection.bg_fill = p.accent.gamma_multiply(if dark { 0.55 } else { 0.25 }); + v.selection.stroke = Stroke::new(1.0, p.accent_fg); v.error_fg_color = p.danger; v.warn_fg_color = p.warn; + v.slider_trailing_fill = true; + v.handle_shape = egui::style::HandleShape::Rect { aspect_ratio: 0.5 }; + v.striped = false; - let r = CornerRadius::same(8); + let r = CornerRadius::same(R_CTRL); let w = &mut v.widgets; for s in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { s.corner_radius = r; @@ -100,37 +121,37 @@ fn visuals(dark: bool) -> Visuals { w.noninteractive.fg_stroke = Stroke::new(1.0, p.text); w.inactive.weak_bg_fill = p.field; w.inactive.bg_fill = p.field; - w.inactive.bg_stroke = Stroke::new(1.0, p.border); + w.inactive.bg_stroke = Stroke::new(1.0, p.control); w.inactive.fg_stroke = Stroke::new(1.0, p.text); w.hovered.weak_bg_fill = p.hover; w.hovered.bg_fill = p.hover; - w.hovered.bg_stroke = Stroke::new(1.0, p.accent.gamma_multiply(0.6)); - w.hovered.fg_stroke = Stroke::new(1.5, p.text); - w.active.weak_bg_fill = p.accent_soft; - w.active.bg_fill = p.accent_soft; - w.active.bg_stroke = Stroke::new(1.0, p.accent); - w.active.fg_stroke = Stroke::new(1.5, p.text); + w.hovered.bg_stroke = Stroke::new(1.0, p.weak); + w.hovered.fg_stroke = Stroke::new(1.0, p.text); + w.active.weak_bg_fill = p.hover; + w.active.bg_fill = p.hover; + w.active.bg_stroke = Stroke::new(1.0, p.accent_fg); + w.active.fg_stroke = Stroke::new(1.0, p.text); w.open.weak_bg_fill = p.hover; + w.open.bg_stroke = Stroke::new(1.0, p.control); v } pub fn install(ctx: &egui::Context) { install_fonts(ctx); for (theme, dark) in [(Theme::Dark, true), (Theme::Light, false)] { - let mut style = Style::default(); - style.visuals = visuals(dark); + let mut style = Style { visuals: visuals(dark), ..Default::default() }; style.spacing.item_spacing = vec2(8.0, 8.0); - style.spacing.button_padding = vec2(12.0, 6.0); + style.spacing.button_padding = vec2(12.0, 5.0); style.spacing.interact_size.y = 30.0; - style.spacing.window_margin = Margin::same(18); - style.spacing.menu_margin = Margin::same(8); - style.spacing.scroll.bar_width = 8.0; + style.spacing.window_margin = Margin::same(20); + style.spacing.menu_margin = Margin::same(6); + style.spacing.scroll.bar_width = 6.0; style.spacing.scroll.floating = true; style.text_styles = [ (TextStyle::Small, FontId::proportional(12.0)), - (TextStyle::Body, FontId::proportional(14.5)), - (TextStyle::Button, FontId::proportional(14.5)), - (TextStyle::Heading, FontId::new(22.0, bold())), + (TextStyle::Body, FontId::proportional(14.0)), + (TextStyle::Button, FontId::proportional(14.0)), + (TextStyle::Heading, FontId::new(20.0, bold())), (TextStyle::Monospace, FontId::monospace(14.0)), ] .into(); @@ -202,16 +223,10 @@ pub fn app_icon() -> IconData { for px in 0..N { let (x, y) = (px as f32 + 0.5, py as f32 + 0.5); let i = (py * N + px) * 4; - if !inside_rrect(x, y, 2.0, 2.0, 62.0, 62.0, 14.0) { + if !inside_rrect(x, y, 2.0, 2.0, 62.0, 62.0, 10.0) { continue; } - // Градиент фона - let t = (x + y) / 128.0; - let (r, g, b) = ( - (0x5B as f32 * (1.0 - t) + 0x8B as f32 * t) as u8, - (0x8C as f32 * (1.0 - t) + 0x5C as f32 * t) as u8, - (0xFF as f32 * (1.0 - t) + 0xF6 as f32 * t) as u8, - ); + let [r, g, b, _] = ACCENT.to_array(); let mut c = [r, g, b, 255]; // Дужка замка let d = ((x - 32.0).powi(2) + (y - 27.0).powi(2)).sqrt(); diff --git a/src/widgets.rs b/src/widgets.rs index 0e20187..f5c16cb 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -1,69 +1,60 @@ -//! Небольшие переиспользуемые виджеты. - -use std::f32::consts::PI; +//! Небольшие переиспользуемые виджеты в строгом стиле. use eframe::egui::{ - self, Align, Align2, Button, Color32, CornerRadius, FontId, Frame, Layout, Margin, Response, RichText, Sense, - Shape, Stroke, TextEdit, Ui, Vec2, pos2, vec2, + self, Align, Align2, Color32, CornerRadius, FontFamily, FontId, Frame, Layout, Margin, Rect, Response, RichText, + Sense, Stroke, StrokeKind, TextEdit, Ui, Vec2, pos2, vec2, }; use crate::generator; -use crate::theme::{Pal, bold}; +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Pal, R_CARD, R_CTRL, bold}; pub fn frame_card(pal: &Pal) -> Frame { Frame::new() .fill(pal.card) .stroke(Stroke::new(1.0, pal.border)) - .corner_radius(CornerRadius::same(14)) - .inner_margin(Margin::same(18)) + .corner_radius(CornerRadius::same(R_CARD)) + .inner_margin(Margin::same(16)) } -/// Логотип: скруглённый квадрат с замком. +fn focus_ring(ui: &Ui, rect: Rect, resp: &Response, pal: &Pal) { + if resp.has_focus() { + ui.painter().rect_stroke( + rect.expand(1.5), + CornerRadius::same(R_CTRL + 1), + Stroke::new(1.5, pal.accent_fg), + StrokeKind::Outside, + ); + } +} + +fn icon_rect(center: egui::Pos2, size: f32) -> Rect { + Rect::from_center_size(center, Vec2::splat(size)) +} + +/// Знак приложения: квадрат с замком. pub fn logo(ui: &mut Ui, size: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); let p = ui.painter(); - let s = size / 64.0; - p.rect_filled(rect, CornerRadius::same((14.0 * s) as u8), crate::theme::ACCENT); - let o = rect.min; - let at = |x: f32, y: f32| pos2(o.x + x * s, o.y + y * s); - let arc: Vec<_> = (0..=24) - .map(|i| { - let a = PI + PI * i as f32 / 24.0; - at(32.0 + 10.8 * a.cos(), 29.0 + 10.8 * a.sin()) - }) - .collect(); - let mut pts = vec![at(21.2, 33.0)]; - pts.extend(arc); - pts.push(at(42.8, 33.0)); - p.add(Shape::line(pts, Stroke::new(4.6 * s, Color32::WHITE))); - let body = egui::Rect::from_min_max(at(15.0, 31.0), at(49.0, 54.0)); - p.rect_filled(body, CornerRadius::same((5.0 * s) as u8), Color32::WHITE); - p.circle_filled(at(32.0, 40.0), 3.6 * s, crate::theme::ACCENT); - p.rect_filled(egui::Rect::from_min_max(at(30.4, 40.0), at(33.6, 47.5)), 1.0, crate::theme::ACCENT); -} - -pub fn avatar_color(seed: &str) -> Color32 { - let h = seed.to_lowercase().bytes().fold(2166136261u32, |h, b| (h ^ b as u32).wrapping_mul(16777619)); - egui::ecolor::Hsva::new((h % 360) as f32 / 360.0, 0.55, 0.78, 1.0).into() + p.rect_filled(rect, CornerRadius::same((size * 0.16) as u8), ACCENT); + icons::paint(p, icon_rect(rect.center(), size * 0.58), Icon::Lock, Color32::WHITE); } pub fn initial(title: &str) -> String { - title.chars().find(|c| c.is_alphanumeric()).map(|c| c.to_uppercase().collect()).unwrap_or_else(|| "•".into()) + title.chars().find(|c| c.is_alphanumeric()).map(|c| c.to_uppercase().collect()).unwrap_or_else(|| "·".into()) } -pub fn avatar(ui: &mut Ui, title: &str, size: f32) { +/// Нейтральная монограмма записи. +pub fn avatar(ui: &mut Ui, pal: &Pal, title: &str, size: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - paint_avatar(ui, rect.center(), size, title); + paint_avatar(ui, pal, rect.center(), size, title); } -fn paint_avatar(ui: &Ui, center: egui::Pos2, size: f32, title: &str) { +fn paint_avatar(ui: &Ui, pal: &Pal, center: egui::Pos2, size: f32, title: &str) { let p = ui.painter(); - p.rect_filled( - egui::Rect::from_center_size(center, Vec2::splat(size)), - CornerRadius::same((size * 0.28) as u8), - avatar_color(title), - ); - p.text(center, Align2::CENTER_CENTER, initial(title), FontId::new(size * 0.46, bold()), Color32::WHITE); + let rect = icon_rect(center, size); + p.rect(rect, CornerRadius::same(R_CTRL), pal.field, Stroke::new(1.0, pal.border), StrokeKind::Inside); + p.text(center, Align2::CENTER_CENTER, initial(title), FontId::new(size * 0.42, bold()), pal.weak); } fn truncate(s: &str, max: usize) -> String { @@ -72,9 +63,9 @@ fn truncate(s: &str, max: usize) -> String { /// Строка в списке записей. pub fn entry_row(ui: &mut Ui, pal: &Pal, title: &str, subtitle: &str, favorite: bool, selected: bool) -> Response { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 56.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click()); if ui.is_rect_visible(rect) { - let inner = rect.shrink2(vec2(8.0, 2.0)); + let inner = rect.shrink2(vec2(8.0, 1.0)); let bg = if selected { pal.accent_soft } else if resp.hovered() { @@ -82,45 +73,36 @@ pub fn entry_row(ui: &mut Ui, pal: &Pal, title: &str, subtitle: &str, favorite: } else { Color32::TRANSPARENT }; - ui.painter().rect_filled(inner, CornerRadius::same(10), bg); - let av = pos2(inner.left() + 26.0, inner.center().y); - paint_avatar(ui, av, 34.0, if title.is_empty() { "?" } else { title }); + ui.painter().rect_filled(inner, CornerRadius::same(R_CTRL), bg); + focus_ring(ui, inner, &resp, pal); + let seed = if title.is_empty() { "?" } else { title }; + paint_avatar(ui, pal, pos2(inner.left() + 22.0, inner.center().y), 30.0, seed); - let right = inner.right() - if favorite { 30.0 } else { 10.0 }; - let text_rect = egui::Rect::from_min_max(pos2(inner.left() + 52.0, inner.top()), pos2(right, inner.bottom())); - let chars = ((text_rect.width() / 7.6) as usize).max(4); + let right = inner.right() - if favorite { 28.0 } else { 10.0 }; + let text_rect = Rect::from_min_max(pos2(inner.left() + 46.0, inner.top()), pos2(right, inner.bottom())); + let chars = ((text_rect.width() / 7.4) as usize).max(4); let painter = ui.painter().with_clip_rect(text_rect); let x = text_rect.left(); + let y = inner.center().y; + let name = if title.is_empty() { "Без названия" } else { title }; + painter.text(pos2(x, y - 8.0), Align2::LEFT_CENTER, truncate(name, chars), FontId::new(14.0, bold()), pal.text); painter.text( - pos2(x, inner.center().y - 9.0), - Align2::LEFT_CENTER, - truncate(if title.is_empty() { "Без названия" } else { title }, chars), - FontId::new(14.5, bold()), - pal.text, - ); - painter.text( - pos2(x, inner.center().y + 10.0), + pos2(x, y + 9.0), Align2::LEFT_CENTER, truncate(subtitle, chars + 4), FontId::proportional(12.5), pal.weak, ); if favorite { - ui.painter().text( - pos2(inner.right() - 16.0, inner.center().y), - Align2::CENTER_CENTER, - "★", - FontId::proportional(14.0), - pal.warn, - ); + icons::paint(ui.painter(), icon_rect(pos2(inner.right() - 16.0, y), 12.0), Icon::StarFilled, pal.warn); } } resp } /// Пункт бокового меню. -pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: &str, label: &str, count: usize, selected: bool) -> Response { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); +pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: Icon, label: &str, count: usize, selected: bool) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); if ui.is_rect_visible(rect) { let bg = if selected { pal.accent_soft @@ -129,18 +111,14 @@ pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: &str, label: &str, count: usize, s } else { Color32::TRANSPARENT }; - ui.painter().rect_filled(rect, CornerRadius::same(8), bg); - let color = if selected { pal.accent } else { pal.weak }; + ui.painter().rect_filled(rect, CornerRadius::same(R_CTRL), bg); + focus_ring(ui, rect, &resp, pal); let y = rect.center().y; - ui.painter().text(pos2(rect.left() + 18.0, y), Align2::CENTER_CENTER, icon, FontId::proportional(14.0), color); - let clip = egui::Rect::from_min_max(rect.min, pos2(rect.right() - 34.0, rect.bottom())); - ui.painter().with_clip_rect(clip).text( - pos2(rect.left() + 36.0, y), - Align2::LEFT_CENTER, - label, - FontId::proportional(14.0), - if selected { pal.text } else { pal.text.gamma_multiply(0.9) }, - ); + let icon_color = if selected { pal.accent_fg } else { pal.weak }; + icons::paint(ui.painter(), icon_rect(pos2(rect.left() + 18.0, y), 15.0), icon, icon_color); + let clip = Rect::from_min_max(rect.min, pos2(rect.right() - 32.0, rect.bottom())); + let font = if selected { FontId::new(14.0, bold()) } else { FontId::proportional(14.0) }; + ui.painter().with_clip_rect(clip).text(pos2(rect.left() + 36.0, y), Align2::LEFT_CENTER, label, font, pal.text); ui.painter().text( pos2(rect.right() - 10.0, y), Align2::RIGHT_CENTER, @@ -153,53 +131,126 @@ pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: &str, label: &str, count: usize, s } pub fn section_label(ui: &mut Ui, pal: &Pal, text: &str) { - ui.label(RichText::new(text).size(11.0).color(pal.weak).strong()); + ui.label(RichText::new(text).size(11.0).color(pal.weak)); } pub fn field_label(ui: &mut Ui, pal: &Pal, text: &str) { ui.add_space(2.0); - ui.label(RichText::new(text).size(12.5).color(pal.weak)); + ui.label(RichText::new(text).size(12.0).color(pal.weak)); +} + +#[derive(Clone, Copy, PartialEq)] +pub enum Tone { + Primary, + Secondary, + Danger, +} + +/// Кнопка: необязательная иконка + текст. Высота 34, ширина не меньше `min_width`. +pub fn button(ui: &mut Ui, pal: &Pal, icon: Option, text: &str, tone: Tone, min_width: f32) -> Response { + let solid = tone != Tone::Secondary; + let font = if solid { FontId::new(14.0, bold()) } else { FontId::new(14.0, FontFamily::Proportional) }; + let fg = if solid { Color32::WHITE } else { pal.text }; + let galley = ui.painter().layout_no_wrap(text.to_owned(), font, fg); + let icon_w = match (icon, text.is_empty()) { + (None, _) => 0.0, + (Some(_), true) => 16.0, + (Some(_), false) => 24.0, + }; + let content_w = icon_w + galley.size().x; + let width = (content_w + 28.0).max(min_width); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 34.0), Sense::click()); + if ui.is_rect_visible(rect) { + let hot = resp.hovered() || resp.has_focus(); + let enabled = ui.is_enabled(); + let (fill, stroke) = match tone { + Tone::Primary => (if hot { pal.accent.gamma_multiply(1.12) } else { pal.accent }, Stroke::NONE), + Tone::Danger => (if hot { pal.danger.gamma_multiply(1.1) } else { pal.danger }, Stroke::NONE), + Tone::Secondary => ( + if hot { pal.hover } else { Color32::TRANSPARENT }, + Stroke::new(1.0, if hot { pal.weak } else { pal.control }), + ), + }; + let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.45) }; + ui.painter().rect(rect, CornerRadius::same(R_CTRL), dim(fill), stroke, StrokeKind::Inside); + focus_ring(ui, rect, &resp, pal); + let x0 = rect.center().x - content_w / 2.0; + if let Some(i) = icon { + icons::paint(ui.painter(), icon_rect(pos2(x0 + 8.0, rect.center().y), 15.0), i, dim(fg)); + } + let pos = pos2(x0 + icon_w, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(pos, galley, dim(fg)); + } + resp } pub fn primary(ui: &mut Ui, pal: &Pal, text: &str, width: f32) -> Response { - let btn = Button::new(RichText::new(text).color(Color32::WHITE).font(FontId::new(14.5, bold()))) - .fill(pal.accent) - .stroke(Stroke::NONE) - .corner_radius(CornerRadius::same(9)) - .min_size(vec2(width, 38.0)); - ui.add(btn) + button(ui, pal, None, text, Tone::Primary, width) } -pub fn secondary(ui: &mut Ui, text: &str, width: f32) -> Response { - ui.add(Button::new(text).corner_radius(CornerRadius::same(9)).min_size(vec2(width, 38.0))) +pub fn secondary(ui: &mut Ui, pal: &Pal, text: &str, width: f32) -> Response { + button(ui, pal, None, text, Tone::Secondary, width) } pub fn danger(ui: &mut Ui, pal: &Pal, text: &str) -> Response { - let btn = Button::new(RichText::new(text).color(Color32::WHITE).strong()) - .fill(pal.danger) - .stroke(Stroke::NONE) - .corner_radius(CornerRadius::same(9)) - .min_size(vec2(110.0, 36.0)); - ui.add(btn) + button(ui, pal, None, text, Tone::Danger, 110.0) } -pub fn icon_btn(ui: &mut Ui, icon: &str, tip: &str) -> Response { - ui.add(Button::new(RichText::new(icon).size(15.0)).frame_when_inactive(false).min_size(vec2(32.0, 30.0))) - .on_hover_text(tip) +/// Квадратная кнопка-иконка без рамки. `hot` — цвет иконки при наведении. +pub fn icon_btn_tone(ui: &mut Ui, pal: &Pal, icon: Icon, tip: &str, on: bool, hot: Color32) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 30.0), Sense::click()); + if ui.is_rect_visible(rect) { + let hovered = resp.hovered(); + let bg = if on { + pal.accent_soft + } else if hovered { + pal.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(R_CTRL), bg); + focus_ring(ui, rect, &resp, pal); + let color = if on { + pal.accent_fg + } else if hovered { + hot + } else { + pal.weak + }; + icons::paint(ui.painter(), icon_rect(rect.center(), 16.0), icon, color); + } + resp.on_hover_text(tip) } -pub fn toggle_btn(ui: &mut Ui, icon: &str, on: bool, tip: &str) -> Response { - ui.add(Button::new(RichText::new(icon).size(15.0)).selected(on).frame_when_inactive(on).min_size(vec2(32.0, 30.0))) - .on_hover_text(tip) +pub fn icon_btn(ui: &mut Ui, pal: &Pal, icon: Icon, tip: &str) -> Response { + icon_btn_tone(ui, pal, icon, tip, false, pal.text) +} + +pub fn toggle_btn(ui: &mut Ui, pal: &Pal, icon: Icon, on: bool, tip: &str) -> Response { + icon_btn_tone(ui, pal, icon, tip, on, pal.text) } pub fn text_field(ui: &mut Ui, value: &mut String, hint: &str) -> Response { ui.add(TextEdit::singleline(value).hint_text(hint).desired_width(f32::INFINITY).margin(Margin::symmetric(10, 7))) } +/// Поле поиска с нарисованной лупой. +pub fn search_field(ui: &mut Ui, pal: &Pal, value: &mut String, hint: &str, width: f32) -> Response { + let r = ui.add(TextEdit::singleline(value).hint_text(hint).desired_width(width).margin(Margin { + left: 32, + right: 10, + top: 7, + bottom: 7, + })); + let center = pos2(r.rect.left() + 17.0, r.rect.center().y); + icons::paint(ui.painter(), icon_rect(center, 14.0), Icon::Search, pal.weak); + r +} + /// Поле пароля с кнопкой показать/скрыть. Дополнительные кнопки — через `extra`. pub fn password_field( ui: &mut Ui, + pal: &Pal, value: &mut String, show: &mut bool, hint: &str, @@ -207,7 +258,8 @@ pub fn password_field( ) -> Response { ui.allocate_ui_with_layout(vec2(ui.available_width(), 34.0), Layout::right_to_left(Align::Center), |ui| { extra(ui); - if toggle_btn(ui, "👁", *show, if *show { "Скрыть" } else { "Показать" }).clicked() { + let icon = if *show { Icon::EyeOff } else { Icon::Eye }; + if icon_btn(ui, pal, icon, if *show { "Скрыть" } else { "Показать" }).clicked() { *show = !*show; } let edit = TextEdit::singleline(value) @@ -227,18 +279,33 @@ pub fn strength_bar(ui: &mut Ui, pal: &Pal, pw: &str) { let color = colors[s.score as usize]; let filled = if pw.is_empty() { 0 } else { s.score as usize + 1 }; ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; + ui.spacing_mut().item_spacing.x = 3.0; for i in 0..5 { - let (rect, _) = ui.allocate_exact_size(vec2(34.0, 5.0), Sense::hover()); - ui.painter().rect_filled(rect, CornerRadius::same(3), if i < filled { color } else { pal.border }); + let (rect, _) = ui.allocate_exact_size(vec2(30.0, 3.0), Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(1), if i < filled { color } else { pal.border }); } - ui.add_space(6.0); + ui.add_space(8.0); if !pw.is_empty() { - ui.label(RichText::new(format!("{} · ~{:.0} бит", s.label(), s.bits)).size(12.0).color(color)); + ui.label(RichText::new(format!("{} · ~{:.0} бит", s.label(), s.bits)).size(12.0).color(pal.weak)); } }); } +/// Маленькая цветная точка-индикатор. +pub fn dot(ui: &mut Ui, color: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(8.0, 14.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), 3.0, color); +} + +/// Иконка + текст с переносом (предупреждения, подсказки). +pub fn note(ui: &mut Ui, icon: Icon, text: &str, color: Color32) { + ui.horizontal_top(|ui| { + let (rect, _) = ui.allocate_exact_size(vec2(16.0, 17.0), Sense::hover()); + icons::paint(ui.painter(), icon_rect(rect.center(), 14.0), icon, color); + ui.add(egui::Label::new(RichText::new(text).size(12.5).color(color)).wrap()); + }); +} + pub fn format_ms(ms: i64) -> String { chrono::DateTime::from_timestamp_millis(ms) .map(|d| d.with_timezone(&chrono::Local).format("%d.%m.%Y %H:%M").to_string())