Wherry/src/ui/app/settings/mod.rs
2026-07-06 01:06:43 +03:00

219 lines
9.4 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.

//! Диалог Settings — popup с меню разделов слева и содержимым справа
//! (General/Appearance/Connections/History/Security/Transfers/About).
use egui::{Align2, Context, RichText, Ui};
use super::FileManagerApp;
use crate::ui::state::SettingsSection;
use crate::ui::theme::*;
use crate::ui::widgets::{button, overlay};
mod about;
mod connections;
mod general;
mod nav;
pub(super) mod prefs;
mod transfers;
const NAV_HEIGHT: f32 = 420.0;
impl FileManagerApp {
pub(super) fn render_settings_dialog(&mut self, ctx: &Context, t: f32) {
if overlay::dim(ctx, "settings_overlay", t) {
self.state.show_settings_dialog = false;
}
egui::Window::new("settings_dialog")
.collapsible(false)
.title_bar(false)
.resizable(false)
.movable(false)
.anchor(Align2::CENTER_CENTER, overlay::slide_offset(t))
.interactable(t >= 1.0)
.frame(overlay::panel_frame())
.show(ctx, |ui| {
ui.set_opacity(overlay::ease_out(t));
ui.set_width(600.0);
header(ui, &mut self.state.show_settings_dialog);
ui.add_space(12.0);
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
ui.add_space(14.0);
ui.horizontal_top(|ui| {
// ScrollArea наследует layout родителя, а здесь он
// горизонтальный — без ui.vertical() содержимое скроллов
// растекается в одну строку и окно вылезает за экран.
ui.vertical(|ui| {
egui::ScrollArea::vertical()
.id_salt("settings_nav_scroll")
.max_height(NAV_HEIGHT)
.show(ui, |ui| {
nav::render(ui, &mut self.state.settings_section);
});
});
let (_, vsep) = ui.allocate_space(egui::vec2(1.0, NAV_HEIGHT));
ui.painter().rect_filled(vsep, 0.0, BORDER);
ui.add_space(18.0);
ui.vertical(|ui| {
// Кросс-фейд содержимого при переключении раздела:
// фиксируем момент смены и плавно поднимаем прозрачность.
if self.state.settings_section != self.state.settings_prev_section {
self.state.settings_prev_section = self.state.settings_section;
self.state.settings_section_changed_at = ui.input(|i| i.time);
}
let fade = ((ui.input(|i| i.time) - self.state.settings_section_changed_at)
/ 0.18)
.clamp(0.0, 1.0) as f32;
if fade < 1.0 {
ui.ctx().request_repaint();
}
ui.multiply_opacity(overlay::ease_out(fade));
egui::ScrollArea::vertical()
.id_salt("settings_content_scroll")
.max_height(NAV_HEIGHT)
.show(ui, |ui| {
ui.set_width(370.0);
match self.state.settings_section {
SettingsSection::General => self.render_general(ui),
SettingsSection::Appearance => self.render_appearance(ui),
SettingsSection::Connections => self.render_connections(ui),
SettingsSection::History => self.render_history_section(ui),
SettingsSection::Security => self.render_security(ui),
SettingsSection::Transfers => self.render_transfers(ui),
SettingsSection::About => self.render_about(ui),
}
});
});
});
});
}
}
fn header(ui: &mut Ui, show: &mut bool) {
ui.horizontal(|ui| {
crate::ui::icons::icon(ui, crate::ui::icons::Icon::Settings, 16.0, ACCENT);
ui.add_space(8.0);
ui.label(
RichText::new(crate::i18n::t("settings.title"))
.color(TEXT_PRIMARY)
.size(15.0)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let close = egui::Button::new(RichText::new("✕").color(TEXT_DIM).size(12.0))
.rounding(RADIUS_SM)
.min_size(egui::vec2(24.0, 24.0));
if button::ghost(ui, close).clicked() {
*show = false;
}
});
});
}
/// Заголовок раздела ("General", "Transfers"...), одинаковый во всех секциях.
pub(super) fn section_title(ui: &mut Ui, text: &str) {
ui.label(RichText::new(text).color(TEXT_PRIMARY).size(15.0).strong());
ui.add_space(12.0);
}
/// Подсказка серым текстом под заголовком/контролом.
pub(super) fn hint(ui: &mut Ui, text: &str) {
ui.label(RichText::new(text).color(TEXT_HINT).size(11.0));
}
/// Строка "метка — значение" только для чтения (версия, путь к БД и т.п.).
/// Длинные значения (пути) усекаются с «…», полный текст — по наведению.
pub(super) fn value_row(ui: &mut Ui, label: &str, value: &str) {
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(TEXT_DIM).size(11.5));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let text = RichText::new(value).color(TEXT_HINT).monospace().size(10.5);
let resp = ui.add(egui::Label::new(text).truncate());
if resp.hovered() && value.len() > 32 {
resp.on_hover_text(value);
}
});
});
ui.add_space(6.0);
}
/// Пустое состояние списка ("No saved sites yet." и т.п.).
pub(super) fn empty_state(ui: &mut Ui, text: &str) {
ui.label(RichText::new(text).color(TEXT_HINT).size(11.5));
}
/// Строка элемента списка с кнопкой-иконкой справа (удалить/забыть и т.п.).
/// Возвращает `true`, если по кнопке кликнули.
pub(super) fn list_row_with_action(
ui: &mut Ui,
label: &str,
sublabel: &str,
action_icon: crate::ui::icons::Icon,
action_hover: &str,
) -> bool {
let mut clicked = false;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(RichText::new(label).color(TEXT_PRIMARY).size(12.5));
if !sublabel.is_empty() {
ui.label(RichText::new(sublabel).color(TEXT_HINT).size(10.5));
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let btn = egui::Button::image(crate::ui::icons::image(action_icon, 13.0, TEXT_DIM))
.rounding(RADIUS_SM)
.min_size(egui::vec2(26.0, 26.0));
if button::ghost(ui, btn).on_hover_text(action_hover).clicked() {
clicked = true;
}
});
});
ui.add_space(4.0);
clicked
}
/// Сегментированный ряд из равных по ширине кнопок (используется в Transfers
/// для выбора числа/интервала) — тот же паттерн, что и выбор протокола в
/// диалоге New Connection.
pub(super) fn segmented_row<T: Copy + PartialEq>(
ui: &mut Ui,
options: &[(&str, T)],
current: T,
) -> Option<T> {
let mut picked = None;
let gap = 4.0;
let n = options.len().max(1) as f32;
// На первом кадре свежего egui::Window ширина ещё не устоялась и может
// быть меньше суммарных отступов — clamp защищает от negative desired_size.
let seg_w = ((ui.available_width() - gap * (n - 1.0) - 6.0) / n).max(0.0);
egui::Frame::none()
.fill(BG_CONTENT)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(RADIUS_MD)
.inner_margin(egui::Margin::same(3.0))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = gap;
ui.horizontal(|ui| {
for (label, value) in options {
let active = *value == current;
let tc = if active { ON_ACCENT } else { TEXT_DIM };
let btn = egui::Button::new(RichText::new(*label).color(tc).size(11.5))
.rounding(RADIUS_SM)
.min_size(egui::vec2(seg_w, 26.0));
let resp = if active {
button::accent(ui, btn)
} else {
button::ghost(ui, btn)
};
if resp.clicked() {
picked = Some(*value);
}
}
});
});
picked
}