//! Небольшие переиспользуемые виджеты в строгом стиле. use eframe::egui::{ 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::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(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(); 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()) } /// Нейтральная монограмма записи. 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, pal, rect.center(), size, title); } fn paint_avatar(ui: &Ui, pal: &Pal, center: egui::Pos2, size: f32, title: &str) { let p = ui.painter(); 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 { if s.chars().count() <= max { s.to_owned() } else { s.chars().take(max - 1).chain(['…']).collect() } } /// Строка в списке записей. 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(), 50.0), Sense::click()); if ui.is_rect_visible(rect) { let inner = rect.shrink2(vec2(8.0, 1.0)); let bg = if selected { pal.accent_soft } else if resp.hovered() { pal.hover } else { Color32::TRANSPARENT }; 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 { 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, y + 9.0), Align2::LEFT_CENTER, truncate(subtitle, chars + 4), FontId::proportional(12.5), pal.weak, ); if favorite { 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: 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 } else if resp.hovered() { pal.hover } else { Color32::TRANSPARENT }; ui.painter().rect_filled(rect, CornerRadius::same(R_CTRL), bg); focus_ring(ui, rect, &resp, pal); let y = rect.center().y; 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, count.to_string(), FontId::proportional(12.0), pal.weak, ); } resp } pub fn section_label(ui: &mut Ui, pal: &Pal, text: &str) { 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.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 { button(ui, pal, None, text, Tone::Primary, width) } 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 { button(ui, pal, None, text, Tone::Danger, 110.0) } /// Квадратная кнопка-иконка без рамки. `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 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, extra: impl FnOnce(&mut Ui), ) -> Response { ui.allocate_ui_with_layout(vec2(ui.available_width(), 34.0), Layout::right_to_left(Align::Center), |ui| { extra(ui); 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) .password(!*show) .hint_text(hint) .desired_width(f32::INFINITY) .margin(Margin::symmetric(10, 7)); let edit = if *show { edit.font(egui::TextStyle::Monospace) } else { edit }; ui.add(edit) }) .inner } pub fn strength_bar(ui: &mut Ui, pal: &Pal, pw: &str) { let s = generator::strength(pw); let colors = [pal.danger, pal.danger, pal.warn, pal.success, pal.success]; 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 = 3.0; for i in 0..5 { 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(8.0); if !pw.is_empty() { 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()) .unwrap_or_default() } pub fn display_url(url: &str) -> &str { let u = url.trim(); let u = u.strip_prefix("https://").or_else(|| u.strip_prefix("http://")).unwrap_or(u); u.strip_suffix('/').unwrap_or(u) } pub fn full_url(url: &str) -> String { let u = url.trim(); if u.contains("://") { u.to_owned() } else { format!("https://{u}") } }