AspansQulyp/src/widgets.rs
deni.reyton 55c98747e3
Some checks failed
CI / windows (push) Waiting to run
CI / linux (push) Failing after 1m5s
Строгий дизайн: графитовая палитра и векторные иконки
- Одна приглушённая акцентная краска, скругления 4–6 px, плоские панели без теней
- Свой набор векторных иконок вместо эмодзи-глифов (src/icons.rs)
- Кнопки с иконкой, состояниями наведения и кольцом фокуса
- Нейтральные монограммы вместо цветных аватаров

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:24:47 +05:00

325 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Небольшие переиспользуемые виджеты в строгом стиле.
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}") }
}