Строгий дизайн: графитовая палитра и векторные иконки
- Одна приглушённая акцентная краска, скругления 4–6 px, плоские панели без теней - Свой набор векторных иконок вместо эмодзи-глифов (src/icons.rs) - Кнопки с иконкой, состояниями наведения и кольцом фокуса - Нейтральные монограммы вместо цветных аватаров Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
45de8f9f51
commit
55c98747e3
@ -12,7 +12,6 @@
|
|||||||
- Автоблокировка «при сворачивании» зависит от того, сообщает ли WM о minimized
|
- Автоблокировка «при сворачивании» зависит от того, сообщает ли WM о minimized
|
||||||
|
|
||||||
## UX
|
## UX
|
||||||
- Иконки — глифы шрифтов; часть эмодзи отсутствует (проверять dev-шагом `glyphs:`)
|
|
||||||
- Оценка стойкости пароля грубая (без словаря)
|
- Оценка стойкости пароля грубая (без словаря)
|
||||||
- Переключение записи при открытом редакторе молча отбрасывает несохранённые правки
|
- Переключение записи при открытом редакторе молча отбрасывает несохранённые правки
|
||||||
|
|
||||||
|
|||||||
@ -23,3 +23,7 @@
|
|||||||
версии меняют API, а эти широко проверены.
|
версии меняют API, а эти широко проверены.
|
||||||
|
|
||||||
**Язык интерфейса — русский.** Локализация — если понадобится.
|
**Язык интерфейса — русский.** Локализация — если понадобится.
|
||||||
|
|
||||||
|
**Строгий визуальный стиль.** Нейтрали делают работу, акцент (стальной синий) — только главная кнопка,
|
||||||
|
выделение, фокус и ссылки. Иконки рисуются вектором (`src/icons.rs`, сетка 16×16, одна толщина линии) —
|
||||||
|
шрифтовые эмодзи на разных системах выглядят по-разному.
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
<!-- limit: 80 -->
|
<!-- limit: 80 -->
|
||||||
# Журнал (новые сверху)
|
# Журнал (новые сверху)
|
||||||
|
|
||||||
|
## 2026-09-30 (дизайн)
|
||||||
|
- Строгий стиль: графитовая палитра, один приглушённый акцент, скругления 4–6 px, без теней на панелях
|
||||||
|
- Свои векторные иконки (src/icons.rs) вместо эмодзи; нейтральные монограммы вместо цветных аватаров
|
||||||
|
|
||||||
## 2026-09-30
|
## 2026-09-30
|
||||||
- Создан проект: крипто (Argon2id + XChaCha20-Poly1305), модель данных, слияние, атомарная запись
|
- Создан проект: крипто (Argon2id + XChaCha20-Poly1305), модель данных, слияние, атомарная запись
|
||||||
- UI на egui 0.36: экраны приветствия/создания/разблокировки, основной экран, генератор, настройки
|
- UI на egui 0.36: экраны приветствия/создания/разблокировки, основной экран, генератор, настройки
|
||||||
|
|||||||
@ -15,5 +15,4 @@
|
|||||||
- [ ] Импорт .kdbx (KeePass)
|
- [ ] Импорт .kdbx (KeePass)
|
||||||
- [ ] Вложения и произвольные поля
|
- [ ] Вложения и произвольные поля
|
||||||
- [ ] Вложенные папки / теги
|
- [ ] Вложенные папки / теги
|
||||||
- [ ] Свои векторные иконки вместо шрифтовых глифов
|
|
||||||
- [ ] Расширение браузера для автозаполнения
|
- [ ] Расширение браузера для автозаполнения
|
||||||
|
|||||||
207
src/app.rs
207
src/app.rs
@ -16,6 +16,7 @@ use zeroize::Zeroizing;
|
|||||||
use crate::config::{Config, ThemeMode};
|
use crate::config::{Config, ThemeMode};
|
||||||
use crate::crypto::VaultKey;
|
use crate::crypto::VaultKey;
|
||||||
use crate::generator;
|
use crate::generator;
|
||||||
|
use crate::icons::Icon;
|
||||||
use crate::storage::{self, ForeignKey, Session};
|
use crate::storage::{self, ForeignKey, Session};
|
||||||
use crate::theme::{self, Pal, bold};
|
use crate::theme::{self, Pal, bold};
|
||||||
use crate::totp::Totp;
|
use crate::totp::Totp;
|
||||||
@ -89,12 +90,12 @@ impl Toasts {
|
|||||||
Frame::new()
|
Frame::new()
|
||||||
.fill(pal.card)
|
.fill(pal.card)
|
||||||
.stroke(Stroke::new(1.0, pal.border))
|
.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))
|
.inner_margin(Margin::symmetric(14, 9))
|
||||||
.shadow(ui.visuals().popup_shadow)
|
.shadow(ui.visuals().popup_shadow)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.horizontal(|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));
|
ui.label(RichText::new(text).color(pal.text));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -312,7 +313,7 @@ impl MainView {
|
|||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
self.saved_at = Some(Self::now_label());
|
self.saved_at = Some(Self::now_label());
|
||||||
if r.any() {
|
if r.any() {
|
||||||
toasts.info(format!("🔄 Подтянуты изменения с другого устройства: {}", describe(&r)));
|
toasts.info(format!("Подтянуты изменения с другого устройства: {}", describe(&r)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) if e.is::<ForeignKey>() => self.conflict = true,
|
Err(e) if e.is::<ForeignKey>() => self.conflict = true,
|
||||||
@ -333,7 +334,7 @@ impl MainView {
|
|||||||
Ok(Some(r)) => {
|
Ok(Some(r)) => {
|
||||||
self.saved_at = Some(Self::now_label());
|
self.saved_at = Some(Self::now_label());
|
||||||
if r.any() {
|
if r.any() {
|
||||||
toasts.info(format!("🔄 Синхронизировано: {}", describe(&r)));
|
toasts.info(format!("Синхронизировано: {}", describe(&r)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) if e.is::<ForeignKey>() => self.conflict = true,
|
Err(e) if e.is::<ForeignKey>() => 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| {
|
ui.vertical_centered(|ui| {
|
||||||
let r = frame_card(pal)
|
let r = frame_card(pal)
|
||||||
.inner_margin(Margin::same(32))
|
.inner_margin(Margin::same(32))
|
||||||
.corner_radius(CornerRadius::same(18))
|
.corner_radius(CornerRadius::same(theme::R_WIN))
|
||||||
.shadow(ui.visuals().window_shadow)
|
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(width);
|
ui.set_width(width);
|
||||||
add(ui)
|
add(ui)
|
||||||
@ -647,7 +647,7 @@ impl QulypApp {
|
|||||||
centered(ui, pal, 380.0, |ui| {
|
centered(ui, pal, 380.0, |ui| {
|
||||||
logo(ui, 64.0);
|
logo(ui, 64.0);
|
||||||
ui.add_space(10.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(
|
ui.label(
|
||||||
RichText::new("Локальный менеджер паролей.\nОдин зашифрованный файл — данные остаются у вас.")
|
RichText::new("Локальный менеджер паролей.\nОдин зашифрованный файл — данные остаются у вас.")
|
||||||
.color(pal.weak),
|
.color(pal.weak),
|
||||||
@ -657,7 +657,7 @@ impl QulypApp {
|
|||||||
next = Some(Screen::Create(CreateForm::new()));
|
next = Some(Screen::Create(CreateForm::new()));
|
||||||
}
|
}
|
||||||
ui.add_space(2.0);
|
ui.add_space(2.0);
|
||||||
if secondary(ui, "Открыть файл…", 380.0).clicked()
|
if secondary(ui, pal, "Открыть файл…", 380.0).clicked()
|
||||||
&& let Some(p) = pick_vault_file()
|
&& let Some(p) = pick_vault_file()
|
||||||
{
|
{
|
||||||
next = Some(Screen::Unlock(UnlockForm::new(p)));
|
next = Some(Screen::Unlock(UnlockForm::new(p)));
|
||||||
@ -674,7 +674,7 @@ impl QulypApp {
|
|||||||
if r.clicked() {
|
if r.clicked() {
|
||||||
next = Some(Screen::Unlock(UnlockForm::new(p.clone())));
|
next = Some(Screen::Unlock(UnlockForm::new(p.clone())));
|
||||||
}
|
}
|
||||||
if icon_btn(ui, "×", "Убрать из списка").clicked() {
|
if icon_btn(ui, pal, Icon::Close, "Убрать из списка").clicked() {
|
||||||
remove = Some(i);
|
remove = Some(i);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -698,12 +698,12 @@ impl QulypApp {
|
|||||||
centered(ui, pal, 360.0, |ui| {
|
centered(ui, pal, 360.0, |ui| {
|
||||||
logo(ui, 56.0);
|
logo(ui, 56.0);
|
||||||
ui.add_space(8.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(egui::Label::new(RichText::new(f.path.display().to_string()).size(12.0).color(pal.weak)).truncate());
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
left(ui, |ui| {
|
left(ui, |ui| {
|
||||||
ui.add_enabled_ui(!f.busy, |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()) {
|
if !f.busy && ui.memory(|m| m.focused().is_none()) {
|
||||||
r.request_focus();
|
r.request_focus();
|
||||||
}
|
}
|
||||||
@ -751,7 +751,7 @@ impl QulypApp {
|
|||||||
centered(ui, pal, 400.0, |ui| {
|
centered(ui, pal, 400.0, |ui| {
|
||||||
logo(ui, 48.0);
|
logo(ui, 48.0);
|
||||||
ui.add_space(6.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);
|
ui.add_space(12.0);
|
||||||
left(ui, |ui| {
|
left(ui, |ui| {
|
||||||
ui.add_enabled_ui(!f.busy, |ui| {
|
ui.add_enabled_ui(!f.busy, |ui| {
|
||||||
@ -787,20 +787,21 @@ impl QulypApp {
|
|||||||
);
|
);
|
||||||
field_label(ui, pal, "Мастер-пароль");
|
field_label(ui, pal, "Мастер-пароль");
|
||||||
let mut show = f.show;
|
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);
|
strength_bar(ui, pal, &f.pw);
|
||||||
field_label(ui, pal, "Повторите пароль");
|
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;
|
f.show = show;
|
||||||
if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
|
if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||||
submit = true;
|
submit = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.label(
|
note(
|
||||||
RichText::new("⚠ Мастер-пароль нельзя восстановить. Без него данные не расшифровать — запомните или запишите его.")
|
ui,
|
||||||
.size(12.0)
|
Icon::Warning,
|
||||||
.color(pal.warn),
|
"Мастер-пароль нельзя восстановить. Без него данные не расшифровать — запомните или запишите его.",
|
||||||
|
pal.warn,
|
||||||
);
|
);
|
||||||
if let Some(e) = &f.error {
|
if let Some(e) = &f.error {
|
||||||
ui.label(RichText::new(e).color(pal.danger).size(13.0));
|
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 {
|
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());
|
let (rect, resp) = ui.allocate_exact_size(vec2(width, 42.0), egui::Sense::click());
|
||||||
if resp.hovered() {
|
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 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 };
|
let c = if exists { pal.text } else { pal.weak };
|
||||||
clip.text(
|
clip.text(
|
||||||
egui::pos2(x, rect.center().y - 8.0),
|
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.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.label(RichText::new(mv.s.file_name()).size(12.5).color(pal.weak));
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
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));
|
next = Some(self.lock(mv));
|
||||||
}
|
}
|
||||||
if icon_btn(ui, "⚙", "Настройки").clicked() {
|
if icon_btn(ui, pal, Icon::Gear, "Настройки").clicked() {
|
||||||
self.settings_open = !self.settings_open;
|
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;
|
self.gen_open = !self.gen_open;
|
||||||
}
|
}
|
||||||
if icon_btn(ui, "🔄", "Синхронизация с другим файлом").clicked() {
|
if icon_btn(ui, pal, Icon::Sync, "Синхронизация с другим файлом").clicked()
|
||||||
|
{
|
||||||
mv.sync = Some(SyncDialog {
|
mv.sync = Some(SyncDialog {
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
pw: secret(),
|
pw: secret(),
|
||||||
@ -930,7 +937,7 @@ impl QulypApp {
|
|||||||
.map_or("Всё сохранено".into(), |t| format!("Сохранено в {t}")),
|
.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(text).size(12.0).color(pal.weak));
|
||||||
ui.label(RichText::new("·").color(pal.weak));
|
ui.label(RichText::new("·").color(pal.weak));
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||||
@ -1051,17 +1058,17 @@ impl QulypApp {
|
|||||||
let v = &mv.s.vault;
|
let v = &mv.s.vault;
|
||||||
let fav = v.entries.iter().filter(|e| e.favorite).count();
|
let fav = v.entries.iter().filter(|e| e.favorite).count();
|
||||||
section_label(ui, pal, " ХРАНИЛИЩЕ");
|
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;
|
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;
|
mv.filter = Filter::Favorites;
|
||||||
}
|
}
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
section_label(ui, pal, " ПАПКИ");
|
section_label(ui, pal, " ПАПКИ");
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
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()));
|
mv.group_dialog = Some(GroupDialog::New(String::new()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1074,16 +1081,16 @@ impl QulypApp {
|
|||||||
ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
|
ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
|
||||||
for (id, name) in groups {
|
for (id, name) in groups {
|
||||||
let count = mv.s.vault.entries.iter().filter(|e| e.group == Some(id)).count();
|
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() {
|
if r.clicked() {
|
||||||
mv.filter = Filter::Group(id);
|
mv.filter = Filter::Group(id);
|
||||||
}
|
}
|
||||||
r.context_menu(|ui| {
|
r.context_menu(|ui| {
|
||||||
if ui.button("✏ Переименовать").clicked() {
|
if ui.button("Переименовать").clicked() {
|
||||||
mv.group_dialog = Some(GroupDialog::Rename(id, name.clone()));
|
mv.group_dialog = Some(GroupDialog::Rename(id, name.clone()));
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
if ui.button("🗑 Удалить папку").clicked() {
|
if ui.button("Удалить папку").clicked() {
|
||||||
mv.confirm_delete_group = Some(id);
|
mv.confirm_delete_group = Some(id);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
@ -1095,12 +1102,8 @@ impl QulypApp {
|
|||||||
fn entry_list(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) {
|
fn entry_list(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
let r = ui.add(
|
let w = ui.available_width() - 8.0;
|
||||||
TextEdit::singleline(&mut mv.search)
|
let r = search_field(ui, pal, &mut mv.search, "Поиск Ctrl+F", w);
|
||||||
.hint_text("🔍 Поиск Ctrl+F")
|
|
||||||
.desired_width(ui.available_width() - 8.0)
|
|
||||||
.margin(Margin::symmetric(10, 7)),
|
|
||||||
);
|
|
||||||
if mv.focus_search {
|
if mv.focus_search {
|
||||||
r.request_focus();
|
r.request_focus();
|
||||||
mv.focus_search = false;
|
mv.focus_search = false;
|
||||||
@ -1108,7 +1111,9 @@ impl QulypApp {
|
|||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_space(8.0);
|
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);
|
start_new(mv);
|
||||||
}
|
}
|
||||||
@ -1146,25 +1151,25 @@ impl QulypApp {
|
|||||||
copy = Some((Zeroizing::new(e.password.clone()), "Пароль"));
|
copy = Some((Zeroizing::new(e.password.clone()), "Пароль"));
|
||||||
}
|
}
|
||||||
r.context_menu(|ui| {
|
r.context_menu(|ui| {
|
||||||
if ui.button("👤 Копировать логин").clicked() {
|
if ui.button("Копировать логин").clicked() {
|
||||||
copy = Some((Zeroizing::new(e.username.clone()), "Логин"));
|
copy = Some((Zeroizing::new(e.username.clone()), "Логин"));
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
if ui.button("🔑 Копировать пароль").clicked() {
|
if ui.button("Копировать пароль").clicked() {
|
||||||
copy = Some((Zeroizing::new(e.password.clone()), "Пароль"));
|
copy = Some((Zeroizing::new(e.password.clone()), "Пароль"));
|
||||||
ui.close();
|
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.ctx().open_url(egui::OpenUrl::new_tab(full_url(&e.url)));
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui.button("✏ Изменить").clicked() {
|
if ui.button("Изменить").clicked() {
|
||||||
mv.selected = Some(id);
|
mv.selected = Some(id);
|
||||||
mv.editor = Some(Editor { draft: e.clone(), is_new: false, show_pw: false });
|
mv.editor = Some(Editor { draft: e.clone(), is_new: false, show_pw: false });
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
if ui.button("🗑 Удалить").clicked() {
|
if ui.button("Удалить").clicked() {
|
||||||
mv.confirm_delete = Some(id);
|
mv.confirm_delete = Some(id);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
@ -1178,17 +1183,21 @@ impl QulypApp {
|
|||||||
|
|
||||||
fn conflict_banner(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) {
|
fn conflict_banner(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) {
|
||||||
Frame::new()
|
Frame::new()
|
||||||
.fill(pal.warn.gamma_multiply(0.15))
|
.fill(pal.card)
|
||||||
.stroke(Stroke::new(1.0, pal.warn.gamma_multiply(0.6)))
|
.stroke(Stroke::new(1.0, pal.warn.gamma_multiply(0.7)))
|
||||||
.corner_radius(CornerRadius::same(12))
|
.corner_radius(CornerRadius::same(theme::R_CARD))
|
||||||
.inner_margin(Margin::same(14))
|
.inner_margin(Margin::same(14))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.label(
|
ui.horizontal(|ui| {
|
||||||
RichText::new("⚠ Файл изменён с другим мастер-паролем")
|
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover());
|
||||||
.font(egui::FontId::new(15.0, bold()))
|
crate::icons::paint(ui.painter(), r, Icon::Warning, pal.warn);
|
||||||
.color(pal.text),
|
ui.label(
|
||||||
);
|
RichText::new("Файл изменён с другим мастер-паролем")
|
||||||
|
.font(egui::FontId::new(15.0, bold()))
|
||||||
|
.color(pal.text),
|
||||||
|
);
|
||||||
|
});
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(
|
RichText::new(
|
||||||
"Похоже, пароль сменили на другом устройстве. Автосинхронизация приостановлена, \
|
"Похоже, пароль сменили на другом устройстве. Автосинхронизация приостановлена, \
|
||||||
@ -1207,7 +1216,7 @@ impl QulypApp {
|
|||||||
busy: false,
|
busy: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if secondary(ui, "Перезаписать моей версией", 0.0).clicked() {
|
if secondary(ui, pal, "Перезаписать моей версией", 0.0).clicked() {
|
||||||
mv.confirm_overwrite = true;
|
mv.confirm_overwrite = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1221,30 +1230,34 @@ impl QulypApp {
|
|||||||
let mut copy: Option<(Secret, &'static str)> = None;
|
let mut copy: Option<(Secret, &'static str)> = None;
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
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.add_space(6.0);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
RichText::new(if e.title.is_empty() { "Без названия" } else { &e.title })
|
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),
|
.color(pal.text),
|
||||||
)
|
)
|
||||||
.truncate(),
|
.truncate(),
|
||||||
);
|
);
|
||||||
ui.label(
|
ui.horizontal(|ui| {
|
||||||
RichText::new(format!("🗀 {}", group.as_deref().unwrap_or("Без папки"))).size(12.5).color(pal.weak),
|
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| {
|
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);
|
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.s.vault.toggle_favorite(id);
|
||||||
mv.persist(&mut self.toasts);
|
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 });
|
mv.editor = Some(Editor { draft: e.clone(), is_new: false, show_pw: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1258,7 +1271,7 @@ impl QulypApp {
|
|||||||
if !e.username.is_empty() {
|
if !e.username.is_empty() {
|
||||||
any = true;
|
any = true;
|
||||||
value_row(ui, pal, "Логин", |ui| {
|
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()), "Логин"));
|
copy = Some((Zeroizing::new(e.username.clone()), "Логин"));
|
||||||
}
|
}
|
||||||
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
||||||
@ -1269,10 +1282,18 @@ impl QulypApp {
|
|||||||
if !e.password.is_empty() {
|
if !e.password.is_empty() {
|
||||||
any = true;
|
any = true;
|
||||||
value_row(ui, pal, "Пароль", |ui| {
|
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()), "Пароль"));
|
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;
|
mv.reveal = !mv.reveal;
|
||||||
}
|
}
|
||||||
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
||||||
@ -1290,10 +1311,10 @@ impl QulypApp {
|
|||||||
if !e.url.is_empty() {
|
if !e.url.is_empty() {
|
||||||
any = true;
|
any = true;
|
||||||
value_row(ui, pal, "Сайт", |ui| {
|
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()), "Адрес"));
|
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.ctx().open_url(egui::OpenUrl::new_tab(full_url(&e.url)));
|
||||||
}
|
}
|
||||||
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
||||||
@ -1312,7 +1333,7 @@ impl QulypApp {
|
|||||||
any = true;
|
any = true;
|
||||||
let (code, left) = t.now();
|
let (code, left) = t.now();
|
||||||
value_row(ui, pal, "Код 2FA", |ui| {
|
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()), "Код"));
|
copy = Some((Zeroizing::new(code.clone()), "Код"));
|
||||||
}
|
}
|
||||||
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
||||||
@ -1351,7 +1372,7 @@ impl QulypApp {
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for h in &e.history {
|
for h in &e.history {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if icon_btn(ui, "🗐", "Копировать").clicked() {
|
if icon_btn(ui, pal, Icon::Copy, "Копировать").clicked() {
|
||||||
copy = Some((Zeroizing::new(h.password.clone()), "Старый пароль"));
|
copy = Some((Zeroizing::new(h.password.clone()), "Старый пароль"));
|
||||||
}
|
}
|
||||||
let shown = if mv.reveal { h.password.clone() } else { "•".repeat(12) };
|
let shown = if mv.reveal { h.password.clone() } else { "•".repeat(12) };
|
||||||
@ -1376,7 +1397,7 @@ impl QulypApp {
|
|||||||
|
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(if ed.is_new { "Новая запись" } else { "Редактирование" })
|
RichText::new(if ed.is_new { "Новая запись" } else { "Редактирование" })
|
||||||
.font(egui::FontId::new(22.0, bold()))
|
.font(egui::FontId::new(20.0, bold()))
|
||||||
.color(pal.text),
|
.color(pal.text),
|
||||||
);
|
);
|
||||||
ui.add_space(10.0);
|
ui.add_space(10.0);
|
||||||
@ -1393,8 +1414,9 @@ impl QulypApp {
|
|||||||
text_field(ui, &mut d.username, "user@example.com");
|
text_field(ui, &mut d.username, "user@example.com");
|
||||||
field_label(ui, pal, "Пароль");
|
field_label(ui, pal, "Пароль");
|
||||||
let mut generate = false;
|
let mut generate = false;
|
||||||
password_field(ui, &mut d.password, &mut ed.show_pw, "Пароль", |ui| {
|
password_field(ui, pal, &mut d.password, &mut ed.show_pw, "Пароль", |ui| {
|
||||||
if icon_btn(ui, "🎲", "Сгенерировать надёжный пароль").clicked() {
|
if icon_btn(ui, pal, Icon::Dice, "Сгенерировать надёжный пароль").clicked()
|
||||||
|
{
|
||||||
generate = true;
|
generate = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1425,7 +1447,7 @@ impl QulypApp {
|
|||||||
match Totp::parse(&d.totp) {
|
match Totp::parse(&d.totp) {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
ui.label(
|
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 => {
|
None => {
|
||||||
@ -1442,14 +1464,14 @@ impl QulypApp {
|
|||||||
.hint_text("Секретные вопросы, PIN-коды, что угодно — всё шифруется"),
|
.hint_text("Секретные вопросы, PIN-коды, что угодно — всё шифруется"),
|
||||||
);
|
);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.checkbox(&mut d.favorite, "★ В избранном");
|
ui.checkbox(&mut d.favorite, "В избранном");
|
||||||
});
|
});
|
||||||
ui.add_space(14.0);
|
ui.add_space(14.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if primary(ui, pal, "Сохранить", 140.0).on_hover_text("Ctrl+S").clicked() {
|
if primary(ui, pal, "Сохранить", 140.0).on_hover_text("Ctrl+S").clicked() {
|
||||||
save = true;
|
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;
|
cancel = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1503,7 +1525,7 @@ impl QulypApp {
|
|||||||
mv.persist(&mut self.toasts);
|
mv.persist(&mut self.toasts);
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
if secondary(ui, "Отмена", 100.0).clicked() {
|
if secondary(ui, pal, "Отмена", 100.0).clicked() {
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1530,7 +1552,7 @@ impl QulypApp {
|
|||||||
mv.persist(&mut self.toasts);
|
mv.persist(&mut self.toasts);
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
if secondary(ui, "Отмена", 100.0).clicked() {
|
if secondary(ui, pal, "Отмена", 100.0).clicked() {
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1566,7 +1588,7 @@ impl QulypApp {
|
|||||||
}
|
}
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
if secondary(ui, "Отмена", 100.0).clicked() {
|
if secondary(ui, pal, "Отмена", 100.0).clicked() {
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1597,7 +1619,7 @@ impl QulypApp {
|
|||||||
if primary(ui, pal, "Готово", 100.0).clicked() {
|
if primary(ui, pal, "Готово", 100.0).clicked() {
|
||||||
done = true;
|
done = true;
|
||||||
}
|
}
|
||||||
if secondary(ui, "Отмена", 100.0).clicked() {
|
if secondary(ui, pal, "Отмена", 100.0).clicked() {
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1659,7 +1681,7 @@ impl QulypApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
field_label(ui, pal, "Пароль того файла");
|
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)) {
|
if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||||
submit = true;
|
submit = true;
|
||||||
}
|
}
|
||||||
@ -1679,7 +1701,7 @@ impl QulypApp {
|
|||||||
if primary(ui, pal, "Синхронизировать", 170.0).clicked() {
|
if primary(ui, pal, "Синхронизировать", 170.0).clicked() {
|
||||||
submit = true;
|
submit = true;
|
||||||
}
|
}
|
||||||
if secondary(ui, "Отмена", 100.0).clicked() {
|
if secondary(ui, pal, "Отмена", 100.0).clicked() {
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1722,12 +1744,12 @@ impl QulypApp {
|
|||||||
ui.add_enabled_ui(!dlg.busy, |ui| {
|
ui.add_enabled_ui(!dlg.busy, |ui| {
|
||||||
let mut show = dlg.show;
|
let mut show = dlg.show;
|
||||||
field_label(ui, pal, "Текущий пароль");
|
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, "Новый пароль");
|
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);
|
strength_bar(ui, pal, &dlg.new);
|
||||||
field_label(ui, pal, "Повторите новый");
|
field_label(ui, pal, "Повторите новый");
|
||||||
password_field(ui, &mut dlg.confirm, &mut show, "", |_| {});
|
password_field(ui, pal, &mut dlg.confirm, &mut show, "", |_| {});
|
||||||
dlg.show = show;
|
dlg.show = show;
|
||||||
});
|
});
|
||||||
ui.label(
|
ui.label(
|
||||||
@ -1747,7 +1769,7 @@ impl QulypApp {
|
|||||||
if primary(ui, pal, "Сменить", 130.0).clicked() {
|
if primary(ui, pal, "Сменить", 130.0).clicked() {
|
||||||
submit = true;
|
submit = true;
|
||||||
}
|
}
|
||||||
if secondary(ui, "Отмена", 100.0).clicked() {
|
if secondary(ui, pal, "Отмена", 100.0).clicked() {
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1798,9 +1820,11 @@ impl QulypApp {
|
|||||||
.anchor(Align2::RIGHT_TOP, vec2(-20.0, 64.0))
|
.anchor(Align2::RIGHT_TOP, vec2(-20.0, 64.0))
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(380.0);
|
ui.set_width(380.0);
|
||||||
Frame::new().fill(pal.field).corner_radius(CornerRadius::same(10)).inner_margin(Margin::same(12)).show(
|
Frame::new()
|
||||||
ui,
|
.fill(pal.field)
|
||||||
|ui| {
|
.corner_radius(CornerRadius::same(theme::R_CTRL))
|
||||||
|
.inner_margin(Margin::same(12))
|
||||||
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
@ -1809,18 +1833,17 @@ impl QulypApp {
|
|||||||
.wrap()
|
.wrap()
|
||||||
.selectable(true),
|
.selectable(true),
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
strength_bar(ui, pal, &self.gen_value);
|
strength_bar(ui, pal, &self.gen_value);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if primary(ui, pal, "🗐 Копировать", 0.0).clicked() {
|
if button(ui, pal, Some(Icon::Copy), "Копировать", Tone::Primary, 0.0).clicked() {
|
||||||
copy = true;
|
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));
|
self.gen_value = Zeroizing::new(generator::generate(&self.cfg.generator));
|
||||||
}
|
}
|
||||||
if let Some(ed) = mv.editor.as_mut()
|
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.draft.password = self.gen_value.to_string();
|
||||||
ed.show_pw = true;
|
ed.show_pw = true;
|
||||||
@ -1889,7 +1912,7 @@ impl QulypApp {
|
|||||||
});
|
});
|
||||||
if in_main {
|
if in_main {
|
||||||
ui.add_space(8.0);
|
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;
|
change_pw = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
184
src/icons.rs
Normal file
184
src/icons.rs
Normal file
@ -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<Pos2> {
|
||||||
|
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<Pos2> = (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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ mod crypto;
|
|||||||
#[cfg(feature = "dev-shots")]
|
#[cfg(feature = "dev-shots")]
|
||||||
mod devshots;
|
mod devshots;
|
||||||
mod generator;
|
mod generator;
|
||||||
|
mod icons;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod totp;
|
mod totp;
|
||||||
|
|||||||
137
src/theme.rs
137
src/theme.rs
@ -1,4 +1,7 @@
|
|||||||
//! Цвета, стиль виджетов, шрифты и иконка окна.
|
//! Цвета, стиль виджетов, шрифты и иконка окна.
|
||||||
|
//!
|
||||||
|
//! Строгая графитовая палитра: нейтрали делают основную работу, единственный
|
||||||
|
//! приглушённый стальной акцент — только для главного действия, выделения и фокуса.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -7,6 +10,11 @@ use eframe::egui::{
|
|||||||
Style, TextStyle, Theme, Visuals, vec2,
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct Pal {
|
pub struct Pal {
|
||||||
pub bg: Color32,
|
pub bg: Color32,
|
||||||
@ -14,10 +22,16 @@ pub struct Pal {
|
|||||||
pub list: Color32,
|
pub list: Color32,
|
||||||
pub card: Color32,
|
pub card: Color32,
|
||||||
pub field: Color32,
|
pub field: Color32,
|
||||||
|
/// Разделители и рамки панелей.
|
||||||
pub border: Color32,
|
pub border: Color32,
|
||||||
|
/// Рамки интерактивных контролов (контраст ≥ 3:1 к фону).
|
||||||
|
pub control: Color32,
|
||||||
pub text: Color32,
|
pub text: Color32,
|
||||||
pub weak: Color32,
|
pub weak: Color32,
|
||||||
|
/// Заливка главной кнопки (под белый текст).
|
||||||
pub accent: Color32,
|
pub accent: Color32,
|
||||||
|
/// Акцент как цвет текста/иконки на фоне.
|
||||||
|
pub accent_fg: Color32,
|
||||||
pub accent_soft: Color32,
|
pub accent_soft: Color32,
|
||||||
pub hover: Color32,
|
pub hover: Color32,
|
||||||
pub danger: Color32,
|
pub danger: Color32,
|
||||||
@ -25,42 +39,46 @@ pub struct Pal {
|
|||||||
pub warn: Color32,
|
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 {
|
pub fn pal(dark: bool) -> Pal {
|
||||||
if dark {
|
if dark {
|
||||||
Pal {
|
Pal {
|
||||||
bg: Color32::from_rgb(0x12, 0x14, 0x19),
|
bg: Color32::from_rgb(0x11, 0x12, 0x14),
|
||||||
sidebar: Color32::from_rgb(0x0E, 0x10, 0x14),
|
sidebar: Color32::from_rgb(0x0D, 0x0E, 0x10),
|
||||||
list: Color32::from_rgb(0x16, 0x19, 0x1F),
|
list: Color32::from_rgb(0x14, 0x15, 0x18),
|
||||||
card: Color32::from_rgb(0x1C, 0x20, 0x28),
|
card: Color32::from_rgb(0x17, 0x19, 0x1C),
|
||||||
field: Color32::from_rgb(0x23, 0x27, 0x31),
|
field: Color32::from_rgb(0x1B, 0x1D, 0x21),
|
||||||
border: Color32::from_rgb(0x2A, 0x2F, 0x3A),
|
border: Color32::from_rgb(0x25, 0x28, 0x2D),
|
||||||
text: Color32::from_rgb(0xE8, 0xEA, 0xF0),
|
control: Color32::from_rgb(0x3A, 0x3E, 0x45),
|
||||||
weak: Color32::from_rgb(0x8A, 0x91, 0xA2),
|
text: Color32::from_rgb(0xE4, 0xE6, 0xE9),
|
||||||
|
weak: Color32::from_rgb(0x8C, 0x92, 0x9A),
|
||||||
accent: ACCENT,
|
accent: ACCENT,
|
||||||
accent_soft: Color32::from_rgba_unmultiplied(0x5B, 0x8C, 0xFF, 40),
|
accent_fg: Color32::from_rgb(0x8F, 0xAE, 0xDB),
|
||||||
hover: Color32::from_rgb(0x20, 0x24, 0x2D),
|
accent_soft: Color32::from_rgba_unmultiplied(0x5B, 0x84, 0xC4, 34),
|
||||||
danger: Color32::from_rgb(0xFF, 0x5C, 0x6C),
|
hover: Color32::from_rgb(0x1D, 0x1F, 0x23),
|
||||||
success: Color32::from_rgb(0x3C, 0xCB, 0x7F),
|
danger: Color32::from_rgb(0xD2, 0x6A, 0x6F),
|
||||||
warn: Color32::from_rgb(0xF5, 0xB9, 0x42),
|
success: Color32::from_rgb(0x6B, 0xB0, 0x8A),
|
||||||
|
warn: Color32::from_rgb(0xC8, 0xA5, 0x5A),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Pal {
|
Pal {
|
||||||
bg: Color32::from_rgb(0xF6, 0xF7, 0xF9),
|
bg: Color32::from_rgb(0xF4, 0xF5, 0xF6),
|
||||||
sidebar: Color32::from_rgb(0xEC, 0xEE, 0xF2),
|
sidebar: Color32::from_rgb(0xEB, 0xEC, 0xEE),
|
||||||
list: Color32::from_rgb(0xFB, 0xFB, 0xFC),
|
list: Color32::from_rgb(0xF9, 0xF9, 0xFA),
|
||||||
card: Color32::WHITE,
|
card: Color32::WHITE,
|
||||||
field: Color32::from_rgb(0xF0, 0xF2, 0xF5),
|
field: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||||
border: Color32::from_rgb(0xDD, 0xE1, 0xE7),
|
border: Color32::from_rgb(0xDD, 0xE0, 0xE4),
|
||||||
text: Color32::from_rgb(0x1C, 0x1F, 0x26),
|
control: Color32::from_rgb(0xB9, 0xBE, 0xC6),
|
||||||
weak: Color32::from_rgb(0x6B, 0x72, 0x80),
|
text: Color32::from_rgb(0x16, 0x18, 0x1B),
|
||||||
accent: Color32::from_rgb(0x3B, 0x6E, 0xF0),
|
weak: Color32::from_rgb(0x5C, 0x62, 0x6A),
|
||||||
accent_soft: Color32::from_rgba_unmultiplied(0x3B, 0x6E, 0xF0, 30),
|
accent: Color32::from_rgb(0x2F, 0x5C, 0xA0),
|
||||||
hover: Color32::from_rgb(0xE9, 0xEC, 0xF1),
|
accent_fg: Color32::from_rgb(0x2F, 0x5C, 0xA0),
|
||||||
danger: Color32::from_rgb(0xE0, 0x3A, 0x4B),
|
accent_soft: Color32::from_rgba_unmultiplied(0x2F, 0x5C, 0xA0, 22),
|
||||||
success: Color32::from_rgb(0x1F, 0xA8, 0x62),
|
hover: Color32::from_rgb(0xE6, 0xE8, 0xEB),
|
||||||
warn: Color32::from_rgb(0xC9, 0x8A, 0x10),
|
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.faint_bg_color = p.list;
|
||||||
v.extreme_bg_color = p.field;
|
v.extreme_bg_color = p.field;
|
||||||
v.text_edit_bg_color = Some(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_stroke = Stroke::new(1.0, p.border);
|
||||||
v.window_corner_radius = CornerRadius::same(14);
|
v.window_corner_radius = CornerRadius::same(R_WIN);
|
||||||
v.menu_corner_radius = CornerRadius::same(10);
|
v.menu_corner_radius = CornerRadius::same(R_CARD);
|
||||||
v.window_shadow =
|
let a = if dark { 90 } else { 26 };
|
||||||
Shadow { offset: [0, 10], blur: 30, spread: 0, color: Color32::from_black_alpha(if dark { 110 } else { 40 }) };
|
v.window_shadow = Shadow { offset: [0, 6], blur: 20, spread: 0, color: Color32::from_black_alpha(a) };
|
||||||
v.popup_shadow =
|
v.popup_shadow = Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(a) };
|
||||||
Shadow { offset: [0, 6], blur: 18, spread: 0, color: Color32::from_black_alpha(if dark { 90 } else { 30 }) };
|
v.hyperlink_color = p.accent_fg;
|
||||||
v.hyperlink_color = p.accent;
|
v.selection.bg_fill = p.accent.gamma_multiply(if dark { 0.55 } else { 0.25 });
|
||||||
v.selection.bg_fill = p.accent.gamma_multiply(0.45);
|
v.selection.stroke = Stroke::new(1.0, p.accent_fg);
|
||||||
v.selection.stroke = Stroke::new(1.0, p.accent);
|
|
||||||
v.error_fg_color = p.danger;
|
v.error_fg_color = p.danger;
|
||||||
v.warn_fg_color = p.warn;
|
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;
|
let w = &mut v.widgets;
|
||||||
for s in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
|
for s in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
|
||||||
s.corner_radius = r;
|
s.corner_radius = r;
|
||||||
@ -100,37 +121,37 @@ fn visuals(dark: bool) -> Visuals {
|
|||||||
w.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
|
w.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
w.inactive.weak_bg_fill = p.field;
|
w.inactive.weak_bg_fill = p.field;
|
||||||
w.inactive.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.inactive.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
w.hovered.weak_bg_fill = p.hover;
|
w.hovered.weak_bg_fill = p.hover;
|
||||||
w.hovered.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.bg_stroke = Stroke::new(1.0, p.weak);
|
||||||
w.hovered.fg_stroke = Stroke::new(1.5, p.text);
|
w.hovered.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
w.active.weak_bg_fill = p.accent_soft;
|
w.active.weak_bg_fill = p.hover;
|
||||||
w.active.bg_fill = p.accent_soft;
|
w.active.bg_fill = p.hover;
|
||||||
w.active.bg_stroke = Stroke::new(1.0, p.accent);
|
w.active.bg_stroke = Stroke::new(1.0, p.accent_fg);
|
||||||
w.active.fg_stroke = Stroke::new(1.5, p.text);
|
w.active.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
w.open.weak_bg_fill = p.hover;
|
w.open.weak_bg_fill = p.hover;
|
||||||
|
w.open.bg_stroke = Stroke::new(1.0, p.control);
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install(ctx: &egui::Context) {
|
pub fn install(ctx: &egui::Context) {
|
||||||
install_fonts(ctx);
|
install_fonts(ctx);
|
||||||
for (theme, dark) in [(Theme::Dark, true), (Theme::Light, false)] {
|
for (theme, dark) in [(Theme::Dark, true), (Theme::Light, false)] {
|
||||||
let mut style = Style::default();
|
let mut style = Style { visuals: visuals(dark), ..Default::default() };
|
||||||
style.visuals = visuals(dark);
|
|
||||||
style.spacing.item_spacing = vec2(8.0, 8.0);
|
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.interact_size.y = 30.0;
|
||||||
style.spacing.window_margin = Margin::same(18);
|
style.spacing.window_margin = Margin::same(20);
|
||||||
style.spacing.menu_margin = Margin::same(8);
|
style.spacing.menu_margin = Margin::same(6);
|
||||||
style.spacing.scroll.bar_width = 8.0;
|
style.spacing.scroll.bar_width = 6.0;
|
||||||
style.spacing.scroll.floating = true;
|
style.spacing.scroll.floating = true;
|
||||||
style.text_styles = [
|
style.text_styles = [
|
||||||
(TextStyle::Small, FontId::proportional(12.0)),
|
(TextStyle::Small, FontId::proportional(12.0)),
|
||||||
(TextStyle::Body, FontId::proportional(14.5)),
|
(TextStyle::Body, FontId::proportional(14.0)),
|
||||||
(TextStyle::Button, FontId::proportional(14.5)),
|
(TextStyle::Button, FontId::proportional(14.0)),
|
||||||
(TextStyle::Heading, FontId::new(22.0, bold())),
|
(TextStyle::Heading, FontId::new(20.0, bold())),
|
||||||
(TextStyle::Monospace, FontId::monospace(14.0)),
|
(TextStyle::Monospace, FontId::monospace(14.0)),
|
||||||
]
|
]
|
||||||
.into();
|
.into();
|
||||||
@ -202,16 +223,10 @@ pub fn app_icon() -> IconData {
|
|||||||
for px in 0..N {
|
for px in 0..N {
|
||||||
let (x, y) = (px as f32 + 0.5, py as f32 + 0.5);
|
let (x, y) = (px as f32 + 0.5, py as f32 + 0.5);
|
||||||
let i = (py * N + px) * 4;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
// Градиент фона
|
let [r, g, b, _] = ACCENT.to_array();
|
||||||
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 mut c = [r, g, b, 255];
|
let mut c = [r, g, b, 255];
|
||||||
// Дужка замка
|
// Дужка замка
|
||||||
let d = ((x - 32.0).powi(2) + (y - 27.0).powi(2)).sqrt();
|
let d = ((x - 32.0).powi(2) + (y - 27.0).powi(2)).sqrt();
|
||||||
|
|||||||
279
src/widgets.rs
279
src/widgets.rs
@ -1,69 +1,60 @@
|
|||||||
//! Небольшие переиспользуемые виджеты.
|
//! Небольшие переиспользуемые виджеты в строгом стиле.
|
||||||
|
|
||||||
use std::f32::consts::PI;
|
|
||||||
|
|
||||||
use eframe::egui::{
|
use eframe::egui::{
|
||||||
self, Align, Align2, Button, Color32, CornerRadius, FontId, Frame, Layout, Margin, Response, RichText, Sense,
|
self, Align, Align2, Color32, CornerRadius, FontFamily, FontId, Frame, Layout, Margin, Rect, Response, RichText,
|
||||||
Shape, Stroke, TextEdit, Ui, Vec2, pos2, vec2,
|
Sense, Stroke, StrokeKind, TextEdit, Ui, Vec2, pos2, vec2,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::generator;
|
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 {
|
pub fn frame_card(pal: &Pal) -> Frame {
|
||||||
Frame::new()
|
Frame::new()
|
||||||
.fill(pal.card)
|
.fill(pal.card)
|
||||||
.stroke(Stroke::new(1.0, pal.border))
|
.stroke(Stroke::new(1.0, pal.border))
|
||||||
.corner_radius(CornerRadius::same(14))
|
.corner_radius(CornerRadius::same(R_CARD))
|
||||||
.inner_margin(Margin::same(18))
|
.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) {
|
pub fn logo(ui: &mut Ui, size: f32) {
|
||||||
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
||||||
let p = ui.painter();
|
let p = ui.painter();
|
||||||
let s = size / 64.0;
|
p.rect_filled(rect, CornerRadius::same((size * 0.16) as u8), ACCENT);
|
||||||
p.rect_filled(rect, CornerRadius::same((14.0 * s) as u8), crate::theme::ACCENT);
|
icons::paint(p, icon_rect(rect.center(), size * 0.58), Icon::Lock, Color32::WHITE);
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn initial(title: &str) -> String {
|
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());
|
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();
|
let p = ui.painter();
|
||||||
p.rect_filled(
|
let rect = icon_rect(center, size);
|
||||||
egui::Rect::from_center_size(center, Vec2::splat(size)),
|
p.rect(rect, CornerRadius::same(R_CTRL), pal.field, Stroke::new(1.0, pal.border), StrokeKind::Inside);
|
||||||
CornerRadius::same((size * 0.28) as u8),
|
p.text(center, Align2::CENTER_CENTER, initial(title), FontId::new(size * 0.42, bold()), pal.weak);
|
||||||
avatar_color(title),
|
|
||||||
);
|
|
||||||
p.text(center, Align2::CENTER_CENTER, initial(title), FontId::new(size * 0.46, bold()), Color32::WHITE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate(s: &str, max: usize) -> String {
|
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 {
|
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) {
|
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 {
|
let bg = if selected {
|
||||||
pal.accent_soft
|
pal.accent_soft
|
||||||
} else if resp.hovered() {
|
} else if resp.hovered() {
|
||||||
@ -82,45 +73,36 @@ pub fn entry_row(ui: &mut Ui, pal: &Pal, title: &str, subtitle: &str, favorite:
|
|||||||
} else {
|
} else {
|
||||||
Color32::TRANSPARENT
|
Color32::TRANSPARENT
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(inner, CornerRadius::same(10), bg);
|
ui.painter().rect_filled(inner, CornerRadius::same(R_CTRL), bg);
|
||||||
let av = pos2(inner.left() + 26.0, inner.center().y);
|
focus_ring(ui, inner, &resp, pal);
|
||||||
paint_avatar(ui, av, 34.0, if title.is_empty() { "?" } else { title });
|
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 right = inner.right() - if favorite { 28.0 } else { 10.0 };
|
||||||
let text_rect = egui::Rect::from_min_max(pos2(inner.left() + 52.0, inner.top()), pos2(right, inner.bottom()));
|
let text_rect = Rect::from_min_max(pos2(inner.left() + 46.0, inner.top()), pos2(right, inner.bottom()));
|
||||||
let chars = ((text_rect.width() / 7.6) as usize).max(4);
|
let chars = ((text_rect.width() / 7.4) as usize).max(4);
|
||||||
let painter = ui.painter().with_clip_rect(text_rect);
|
let painter = ui.painter().with_clip_rect(text_rect);
|
||||||
let x = text_rect.left();
|
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(
|
painter.text(
|
||||||
pos2(x, inner.center().y - 9.0),
|
pos2(x, 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),
|
|
||||||
Align2::LEFT_CENTER,
|
Align2::LEFT_CENTER,
|
||||||
truncate(subtitle, chars + 4),
|
truncate(subtitle, chars + 4),
|
||||||
FontId::proportional(12.5),
|
FontId::proportional(12.5),
|
||||||
pal.weak,
|
pal.weak,
|
||||||
);
|
);
|
||||||
if favorite {
|
if favorite {
|
||||||
ui.painter().text(
|
icons::paint(ui.painter(), icon_rect(pos2(inner.right() - 16.0, y), 12.0), Icon::StarFilled, pal.warn);
|
||||||
pos2(inner.right() - 16.0, inner.center().y),
|
|
||||||
Align2::CENTER_CENTER,
|
|
||||||
"★",
|
|
||||||
FontId::proportional(14.0),
|
|
||||||
pal.warn,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Пункт бокового меню.
|
/// Пункт бокового меню.
|
||||||
pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: &str, label: &str, count: usize, selected: bool) -> Response {
|
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(), 34.0), Sense::click());
|
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let bg = if selected {
|
let bg = if selected {
|
||||||
pal.accent_soft
|
pal.accent_soft
|
||||||
@ -129,18 +111,14 @@ pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: &str, label: &str, count: usize, s
|
|||||||
} else {
|
} else {
|
||||||
Color32::TRANSPARENT
|
Color32::TRANSPARENT
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(rect, CornerRadius::same(8), bg);
|
ui.painter().rect_filled(rect, CornerRadius::same(R_CTRL), bg);
|
||||||
let color = if selected { pal.accent } else { pal.weak };
|
focus_ring(ui, rect, &resp, pal);
|
||||||
let y = rect.center().y;
|
let y = rect.center().y;
|
||||||
ui.painter().text(pos2(rect.left() + 18.0, y), Align2::CENTER_CENTER, icon, FontId::proportional(14.0), color);
|
let icon_color = if selected { pal.accent_fg } else { pal.weak };
|
||||||
let clip = egui::Rect::from_min_max(rect.min, pos2(rect.right() - 34.0, rect.bottom()));
|
icons::paint(ui.painter(), icon_rect(pos2(rect.left() + 18.0, y), 15.0), icon, icon_color);
|
||||||
ui.painter().with_clip_rect(clip).text(
|
let clip = Rect::from_min_max(rect.min, pos2(rect.right() - 32.0, rect.bottom()));
|
||||||
pos2(rect.left() + 36.0, y),
|
let font = if selected { FontId::new(14.0, bold()) } else { FontId::proportional(14.0) };
|
||||||
Align2::LEFT_CENTER,
|
ui.painter().with_clip_rect(clip).text(pos2(rect.left() + 36.0, y), Align2::LEFT_CENTER, label, font, pal.text);
|
||||||
label,
|
|
||||||
FontId::proportional(14.0),
|
|
||||||
if selected { pal.text } else { pal.text.gamma_multiply(0.9) },
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
pos2(rect.right() - 10.0, y),
|
pos2(rect.right() - 10.0, y),
|
||||||
Align2::RIGHT_CENTER,
|
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) {
|
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) {
|
pub fn field_label(ui: &mut Ui, pal: &Pal, text: &str) {
|
||||||
ui.add_space(2.0);
|
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<Icon>, 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 {
|
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())))
|
button(ui, pal, None, text, Tone::Primary, width)
|
||||||
.fill(pal.accent)
|
|
||||||
.stroke(Stroke::NONE)
|
|
||||||
.corner_radius(CornerRadius::same(9))
|
|
||||||
.min_size(vec2(width, 38.0));
|
|
||||||
ui.add(btn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn secondary(ui: &mut Ui, text: &str, width: f32) -> Response {
|
pub fn secondary(ui: &mut Ui, pal: &Pal, text: &str, width: f32) -> Response {
|
||||||
ui.add(Button::new(text).corner_radius(CornerRadius::same(9)).min_size(vec2(width, 38.0)))
|
button(ui, pal, None, text, Tone::Secondary, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn danger(ui: &mut Ui, pal: &Pal, text: &str) -> Response {
|
pub fn danger(ui: &mut Ui, pal: &Pal, text: &str) -> Response {
|
||||||
let btn = Button::new(RichText::new(text).color(Color32::WHITE).strong())
|
button(ui, pal, None, text, Tone::Danger, 110.0)
|
||||||
.fill(pal.danger)
|
|
||||||
.stroke(Stroke::NONE)
|
|
||||||
.corner_radius(CornerRadius::same(9))
|
|
||||||
.min_size(vec2(110.0, 36.0));
|
|
||||||
ui.add(btn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn icon_btn(ui: &mut Ui, icon: &str, tip: &str) -> Response {
|
/// Квадратная кнопка-иконка без рамки. `hot` — цвет иконки при наведении.
|
||||||
ui.add(Button::new(RichText::new(icon).size(15.0)).frame_when_inactive(false).min_size(vec2(32.0, 30.0)))
|
pub fn icon_btn_tone(ui: &mut Ui, pal: &Pal, icon: Icon, tip: &str, on: bool, hot: Color32) -> Response {
|
||||||
.on_hover_text(tip)
|
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 {
|
pub fn icon_btn(ui: &mut Ui, pal: &Pal, icon: Icon, 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)))
|
icon_btn_tone(ui, pal, icon, tip, false, pal.text)
|
||||||
.on_hover_text(tip)
|
}
|
||||||
|
|
||||||
|
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 {
|
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)))
|
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`.
|
/// Поле пароля с кнопкой показать/скрыть. Дополнительные кнопки — через `extra`.
|
||||||
pub fn password_field(
|
pub fn password_field(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
|
pal: &Pal,
|
||||||
value: &mut String,
|
value: &mut String,
|
||||||
show: &mut bool,
|
show: &mut bool,
|
||||||
hint: &str,
|
hint: &str,
|
||||||
@ -207,7 +258,8 @@ pub fn password_field(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 34.0), Layout::right_to_left(Align::Center), |ui| {
|
ui.allocate_ui_with_layout(vec2(ui.available_width(), 34.0), Layout::right_to_left(Align::Center), |ui| {
|
||||||
extra(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;
|
*show = !*show;
|
||||||
}
|
}
|
||||||
let edit = TextEdit::singleline(value)
|
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 color = colors[s.score as usize];
|
||||||
let filled = if pw.is_empty() { 0 } else { s.score as usize + 1 };
|
let filled = if pw.is_empty() { 0 } else { s.score as usize + 1 };
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 4.0;
|
ui.spacing_mut().item_spacing.x = 3.0;
|
||||||
for i in 0..5 {
|
for i in 0..5 {
|
||||||
let (rect, _) = ui.allocate_exact_size(vec2(34.0, 5.0), Sense::hover());
|
let (rect, _) = ui.allocate_exact_size(vec2(30.0, 3.0), Sense::hover());
|
||||||
ui.painter().rect_filled(rect, CornerRadius::same(3), if i < filled { color } else { pal.border });
|
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() {
|
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 {
|
pub fn format_ms(ms: i64) -> String {
|
||||||
chrono::DateTime::from_timestamp_millis(ms)
|
chrono::DateTime::from_timestamp_millis(ms)
|
||||||
.map(|d| d.with_timezone(&chrono::Local).format("%d.%m.%Y %H:%M").to_string())
|
.map(|d| d.with_timezone(&chrono::Local).format("%d.%m.%Y %H:%M").to_string())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user