- Одна приглушённая акцентная краска, скругления 4–6 px, плоские панели без теней - Свой набор векторных иконок вместо эмодзи-глифов (src/icons.rs) - Кнопки с иконкой, состояниями наведения и кольцом фокуса - Нейтральные монограммы вместо цветных аватаров Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
13 KiB
Rust
325 lines
13 KiB
Rust
//! Небольшие переиспользуемые виджеты в строгом стиле.
|
||
|
||
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<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 {
|
||
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}") }
|
||
}
|