This commit is contained in:
loki5512344 2026-07-04 13:38:39 +03:00
parent e0edd14fa5
commit e895fe3237
65 changed files with 4118 additions and 1427 deletions

1734
Cargo.lock generated

File diff suppressed because it is too large Load diff

179
LoFlum - Standalone.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -14,9 +14,9 @@ name = "loflum"
path = "main.rs" path = "main.rs"
[dependencies] [dependencies]
eframe = { version = "0.28", features = ["default"] } eframe = { version = "0.29", features = ["default"] }
egui = "0.28" egui = "0.29"
egui_extras = "0.28" egui_extras = { version = "0.29", features = ["svg", "image"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@ -34,4 +34,5 @@ tracing-subscriber = "0.3"
chrono = "0.4" chrono = "0.4"
futures-lite = "2" futures-lite = "2"
image = "0.25" image = "0.25"
muda = "0.19.3"

View file

@ -58,3 +58,46 @@ pub fn home_dir() -> String {
.to_string_lossy() .to_string_lossy()
.to_string() .to_string()
} }
pub fn rename(from: &str, to: &str) -> Result<()> {
fs::rename(from, to)?;
Ok(())
}
pub fn delete(path: &str) -> Result<()> {
let meta = fs::symlink_metadata(path)?;
if meta.is_dir() {
fs::remove_dir_all(path)?;
} else {
fs::remove_file(path)?;
}
Ok(())
}
pub fn mkdir(path: &str) -> Result<()> {
fs::create_dir(path)?;
Ok(())
}
/// Открывает файл/папку в системном приложении по умолчанию.
pub fn open(path: &str) -> Result<()> {
#[cfg(target_os = "macos")]
std::process::Command::new("open").arg(path).spawn()?;
#[cfg(target_os = "linux")]
std::process::Command::new("xdg-open").arg(path).spawn()?;
#[cfg(target_os = "windows")]
std::process::Command::new("cmd")
.args(["/C", "start", "", path])
.spawn()?;
Ok(())
}
/// Перемещает файл/папку в целевую директорию (для drag & drop).
pub fn move_into(src_path: &str, dest_dir: &str) -> Result<()> {
let name = std::path::Path::new(src_path)
.file_name()
.ok_or_else(|| anyhow::anyhow!("invalid source path"))?;
let dest = std::path::Path::new(dest_dir).join(name);
fs::rename(src_path, dest)?;
Ok(())
}

View file

@ -39,14 +39,37 @@ pub fn run() {
let app = FileManagerApp::new(registry, queue, rt, db, sites); let app = FileManagerApp::new(registry, queue, rt, db, sites);
let icon = load_app_icon();
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_title("LoFlum") .with_title("LoFlum")
.with_inner_size([1280.0, 800.0]) .with_inner_size([1280.0, 800.0])
.with_min_inner_size([900.0, 600.0]), .with_min_inner_size([900.0, 600.0])
.with_icon(icon),
..Default::default() ..Default::default()
}; };
eframe::run_native("LoFlum", native_options, Box::new(|_cc| Ok(Box::new(app)))) eframe::run_native(
"LoFlum",
native_options,
Box::new(|_cc| {
crate::ui::menu::setup_native_menu();
Ok(Box::new(app))
}),
)
.expect("failed to start LoFlum"); .expect("failed to start LoFlum");
} }
fn load_app_icon() -> egui::IconData {
let bytes = include_bytes!("ui/icons/app_icon.png");
let image = image::load_from_memory(bytes)
.expect("failed to decode app icon")
.into_rgba8();
let (width, height) = image.dimensions();
egui::IconData {
rgba: image.into_raw(),
width,
height,
}
}

View file

@ -115,7 +115,11 @@ impl RemoteFs for SftpClient {
let session = self.session.clone(); let session = self.session.clone();
let path = path.to_string(); let path = path.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; let sftp = session
.lock()
.unwrap()
.sftp()
.context("SFTP subsystem failed")?;
let entries = sftp.readdir(Path::new(&path)).context("readdir failed")?; let entries = sftp.readdir(Path::new(&path)).context("readdir failed")?;
let result = entries let result = entries
@ -159,7 +163,11 @@ impl RemoteFs for SftpClient {
let local = local.to_string(); let local = local.to_string();
let remote = remote.to_string(); let remote = remote.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; let sftp = session
.lock()
.unwrap()
.sftp()
.context("SFTP subsystem failed")?;
let mut local_file = std::fs::File::open(&local).context("open local file failed")?; let mut local_file = std::fs::File::open(&local).context("open local file failed")?;
let mut remote_file = sftp let mut remote_file = sftp
.create(Path::new(&remote)) .create(Path::new(&remote))
@ -168,7 +176,9 @@ impl RemoteFs for SftpClient {
let mut buf = vec![0u8; CHUNK_SIZE]; let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64; let mut total = 0u64;
loop { loop {
let n = local_file.read(&mut buf).context("read local file failed")?; let n = local_file
.read(&mut buf)
.context("read local file failed")?;
if n == 0 { if n == 0 {
break; break;
} }
@ -204,15 +214,23 @@ impl RemoteFs for SftpClient {
let remote = remote.to_string(); let remote = remote.to_string();
let local = local.to_string(); let local = local.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; let sftp = session
let mut remote_file = sftp.open(Path::new(&remote)).context("open remote file failed")?; .lock()
.unwrap()
.sftp()
.context("SFTP subsystem failed")?;
let mut remote_file = sftp
.open(Path::new(&remote))
.context("open remote file failed")?;
let mut local_file = let mut local_file =
std::fs::File::create(&local).context("create local file failed")?; std::fs::File::create(&local).context("create local file failed")?;
let mut buf = vec![0u8; CHUNK_SIZE]; let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64; let mut total = 0u64;
loop { loop {
let n = remote_file.read(&mut buf).context("read remote file failed")?; let n = remote_file
.read(&mut buf)
.context("read remote file failed")?;
if n == 0 { if n == 0 {
break; break;
} }
@ -242,8 +260,13 @@ impl RemoteFs for SftpClient {
let session = self.session.clone(); let session = self.session.clone();
let path = path.to_string(); let path = path.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; let sftp = session
sftp.mkdir(Path::new(&path), 0o755).context("mkdir failed")?; .lock()
.unwrap()
.sftp()
.context("SFTP subsystem failed")?;
sftp.mkdir(Path::new(&path), 0o755)
.context("mkdir failed")?;
Ok(()) Ok(())
}) })
.await .await
@ -255,7 +278,11 @@ impl RemoteFs for SftpClient {
let from = from.to_string(); let from = from.to_string();
let to = to.to_string(); let to = to.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; let sftp = session
.lock()
.unwrap()
.sftp()
.context("SFTP subsystem failed")?;
sftp.rename(Path::new(&from), Path::new(&to), None) sftp.rename(Path::new(&from), Path::new(&to), None)
.context("rename failed")?; .context("rename failed")?;
Ok(()) Ok(())
@ -268,7 +295,11 @@ impl RemoteFs for SftpClient {
let session = self.session.clone(); let session = self.session.clone();
let path = path.to_string(); let path = path.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; let sftp = session
.lock()
.unwrap()
.sftp()
.context("SFTP subsystem failed")?;
// пробуем как файл, потом как директорию // пробуем как файл, потом как директорию
if sftp.unlink(Path::new(&path)).is_err() { if sftp.unlink(Path::new(&path)).is_err() {
sftp.rmdir(Path::new(&path)).context("delete failed")?; sftp.rmdir(Path::new(&path)).context("delete failed")?;
@ -283,7 +314,11 @@ impl RemoteFs for SftpClient {
let session = self.session.clone(); let session = self.session.clone();
let path = path.to_string(); let path = path.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; let sftp = session
.lock()
.unwrap()
.sftp()
.context("SFTP subsystem failed")?;
let stat = sftp.stat(Path::new(&path)).context("stat failed")?; let stat = sftp.stat(Path::new(&path)).context("stat failed")?;
Ok(FileEntry { Ok(FileEntry {
name: Path::new(&path) name: Path::new(&path)

View file

@ -3,6 +3,22 @@ use crate::domain::site::Site;
use anyhow::Result; use anyhow::Result;
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
fn protocol_to_str(protocol: &Protocol) -> &'static str {
match protocol {
Protocol::Sftp => "sftp",
Protocol::Ftp => "ftp",
Protocol::Ftps => "ftps",
}
}
fn protocol_from_str(s: &str) -> Protocol {
match s {
"sftp" => Protocol::Sftp,
"ftps" => Protocol::Ftps,
_ => Protocol::Ftp,
}
}
pub fn init_tables(conn: &Connection) -> Result<()> { pub fn init_tables(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
" "
@ -19,11 +35,159 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
folder TEXT, folder TEXT,
note TEXT note TEXT
); );
CREATE TABLE IF NOT EXISTS connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
connected_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL
);
", ",
)?; )?;
// Миграция: старые базы созданы без conn_id/protocol/key_path — добавляем,
// если их ещё нет (ошибку "duplicate column" просто игнорируем).
let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []);
let _ = conn.execute(
"ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'",
[],
);
let _ = conn.execute(
"ALTER TABLE connection_history ADD COLUMN key_path TEXT",
[],
);
// На случай старых записей без уникальности (host,port,username) — оставляем
// только самую свежую строку на каждую цель перед созданием индекса.
conn.execute(
"DELETE FROM connection_history WHERE id NOT IN (
SELECT MAX(id) FROM connection_history GROUP BY host, port, username
)",
[],
)?;
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_target
ON connection_history(host, port, username)",
[],
)?;
Ok(()) Ok(())
} }
pub fn get_bookmarks(conn: &Connection) -> Result<Vec<(i64, String, String)>> {
let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?;
let rows = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
pub fn add_bookmark(conn: &Connection, name: &str, path: &str) -> Result<i64> {
conn.execute(
"INSERT INTO bookmarks (name, path) VALUES (?1, ?2)",
params![name, path],
)?;
Ok(conn.last_insert_rowid())
}
pub fn remove_bookmark(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
Ok(())
}
/// Одна запись истории подключений: host, port, username, время, conn_id
/// (стабильный id — под ним же лежит пароль в keychain), протокол, key_path.
pub type HistoryRow = (
String,
u16,
String,
String,
String,
Protocol,
Option<String>,
);
/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы
/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый.
pub fn find_history_conn_id(
conn: &Connection,
host: &str,
port: u16,
username: &str,
) -> Result<Option<String>> {
let id: Option<String> = conn
.query_row(
"SELECT conn_id FROM connection_history WHERE host = ?1 AND port = ?2 AND username = ?3",
params![host, port, username],
|row| row.get(0),
)
.ok();
Ok(id)
}
#[allow(clippy::too_many_arguments)]
pub fn add_history_entry(
conn: &Connection,
host: &str,
port: u16,
username: &str,
conn_id: &str,
protocol: &Protocol,
key_path: Option<&str>,
) -> Result<()> {
use chrono::Local;
let connected_at = Local::now().format("%H:%M %d.%m").to_string();
let proto = protocol_to_str(protocol);
conn.execute(
"INSERT INTO connection_history (host, port, username, connected_at, conn_id, protocol, key_path)
VALUES (?1,?2,?3,?4,?5,?6,?7)
ON CONFLICT(host, port, username) DO UPDATE SET
connected_at = excluded.connected_at,
protocol = excluded.protocol,
key_path = excluded.key_path,
conn_id = COALESCE(connection_history.conn_id, excluded.conn_id)",
params![host, port, username, connected_at, conn_id, proto, key_path],
)?;
// Храним только последние 20 записей
conn.execute(
"DELETE FROM connection_history WHERE id NOT IN (
SELECT id FROM connection_history ORDER BY id DESC LIMIT 20
)",
[],
)?;
Ok(())
}
pub fn get_history(conn: &Connection) -> Result<Vec<HistoryRow>> {
let mut stmt = conn.prepare(
"SELECT host, port, username, connected_at, conn_id, protocol, key_path
FROM connection_history ORDER BY id DESC LIMIT 20",
)?;
let rows = stmt
.query_map([], |row| {
let proto_str: String = row.get(5)?;
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
protocol_from_str(&proto_str),
row.get(6)?,
))
})?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> { pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name", "SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name",
@ -31,11 +195,7 @@ pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
let sites = stmt let sites = stmt
.query_map([], |row| { .query_map([], |row| {
let proto_str: String = row.get(2)?; let proto_str: String = row.get(2)?;
let protocol = match proto_str.as_str() { let protocol = protocol_from_str(&proto_str);
"sftp" => Protocol::Sftp,
"ftps" => Protocol::Ftps,
_ => Protocol::Ftp,
};
Ok(Site { Ok(Site {
id: row.get(0)?, id: row.get(0)?,
name: row.get(1)?, name: row.get(1)?,
@ -54,11 +214,7 @@ pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
} }
pub fn save_site(conn: &Connection, site: &Site) -> Result<()> { pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
let proto = match site.protocol { let proto = protocol_to_str(&site.protocol);
Protocol::Sftp => "sftp",
Protocol::Ftp => "ftp",
Protocol::Ftps => "ftps",
};
conn.execute( conn.execute(
"INSERT OR REPLACE INTO sites "INSERT OR REPLACE INTO sites
(id, name, protocol, host, port, username, key_path, folder, note) (id, name, protocol, host, port, username, key_path, folder, note)

View file

@ -17,7 +17,12 @@ impl TransferQueue {
} }
pub fn get(&self, id: &str) -> Option<TransferTask> { pub fn get(&self, id: &str) -> Option<TransferTask> {
self.inner.lock().unwrap().iter().find(|t| t.id == id).cloned() self.inner
.lock()
.unwrap()
.iter()
.find(|t| t.id == id)
.cloned()
} }
pub fn all(&self) -> Vec<TransferTask> { pub fn all(&self) -> Vec<TransferTask> {

View file

@ -94,8 +94,8 @@ pub fn spawn_worker(
let task_id_for_progress = task.id.clone(); let task_id_for_progress = task.id.clone();
let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default())); let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default()));
let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64))); let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64)));
let on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>> = Some(Box::new( let on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>> =
move |transferred: u64| { Some(Box::new(move |transferred: u64| {
// Check for user-initiated cancel/pause first. // Check for user-initiated cancel/pause first.
if let Some(t) = queue_for_progress.get(&task_id_for_progress) { if let Some(t) = queue_for_progress.get(&task_id_for_progress) {
match t.state { match t.state {
@ -119,18 +119,23 @@ pub fn spawn_worker(
speed speed
}; };
if throttle.lock().unwrap().should_emit() { if throttle.lock().unwrap().should_emit() {
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed); queue_for_progress.update_progress(
&task_id_for_progress,
transferred,
speed,
);
} }
ProgressAction::Continue ProgressAction::Continue
}, }));
));
let result = match task.kind { let result = match task.kind {
TransferKind::Upload => { TransferKind::Upload => {
fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress).await fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress)
.await
} }
TransferKind::Download => { TransferKind::Download => {
fs.download_with_progress(&task.remote_path, &task.local_path, on_progress).await fs.download_with_progress(&task.remote_path, &task.local_path, on_progress)
.await
} }
}; };

View file

@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use egui::{CentralPanel, Color32, RichText, SidePanel, TopBottomPanel, Visuals}; use egui::{CentralPanel, RichText, SidePanel, TopBottomPanel, Visuals};
use crate::domain::connection::ConnectionStatus; use crate::domain::connection::ConnectionStatus;
use crate::domain::file_entry::{EntryKind, FileEntry}; use crate::domain::file_entry::{EntryKind, FileEntry};
@ -35,6 +35,15 @@ impl FileManagerApp {
}; };
local_pane::refresh_local(&mut state); local_pane::refresh_local(&mut state);
state.reload_history(&db);
if let Ok(rows) = crate::storage::db::get_bookmarks(&db.lock().unwrap()) {
state.bookmarks = rows
.into_iter()
.map(|(id, name, path)| crate::ui::state::Bookmark { id, name, path })
.collect();
}
Self { Self {
state, state,
registry, registry,
@ -81,8 +90,39 @@ impl FileManagerApp {
self.state.status_message = format!("Connected to {}", params.host); self.state.status_message = format!("Connected to {}", params.host);
self.state.connect_loading = false; self.state.connect_loading = false;
self.state.show_connect_dialog = false; self.state.show_connect_dialog = false;
self.state
.add_history(&params.host, params.port, &params.username); // Один и тот же host/port/username всегда должен использовать
// один conn_id — под ним лежит пароль в keychain, иначе повторное
// подключение через "New Connection" развело бы историю и keychain.
if let Ok(conn) = self.db.lock() {
let canonical_id = crate::storage::db::find_history_conn_id(
&conn,
&params.host,
params.port,
&params.username,
)
.ok()
.flatten()
.unwrap_or_else(|| params.id.clone());
if let Some(password) = &params.password {
let _ = crate::storage::keychain::store_password(
&canonical_id,
password,
);
}
let _ = crate::storage::db::add_history_entry(
&conn,
&params.host,
params.port,
&params.username,
&canonical_id,
&params.protocol,
params.key_path.as_deref(),
);
}
self.state.reload_history(&self.db);
} }
Err(e) => { Err(e) => {
self.state.status_message = format!("Connection failed: {}", e); self.state.status_message = format!("Connection failed: {}", e);
@ -149,6 +189,24 @@ impl FileManagerApp {
} }
} }
// --- История: переподключение по клику ---
if let Some(entry) = self.state.pending_history_reconnect.take() {
connection::reconnect_from_history(
&mut self.state,
&self.registry,
self.rt.handle(),
&entry,
);
}
// --- История: "Save" → постоянный Site ---
if let Some(entry) = self.state.pending_history_save.take() {
match connection::save_history_as_site(&self.db, &mut self.sites, &entry) {
Ok(()) => self.state.status_message = "Saved to sites".into(),
Err(e) => self.state.status_message = format!("Save failed: {}", e),
}
}
// --- Mkdir result --- // --- Mkdir result ---
let opt = self.state.pending_mkdir_result.take(); let opt = self.state.pending_mkdir_result.take();
if let Some(res) = opt.as_ref() { if let Some(res) = opt.as_ref() {
@ -346,6 +404,55 @@ impl FileManagerApp {
self.state.pending_rename_result = Some(result); self.state.pending_rename_result = Some(result);
} }
fn start_local_mkdir(&mut self, name: String) {
let path = format!("{}/{}", self.state.local_path.trim_end_matches('/'), name);
match crate::fs::local::mkdir(&path) {
Ok(()) => {
self.state.status_message = "Folder created".into();
local_pane::refresh_local(&mut self.state);
}
Err(e) => self.state.status_message = format!("Create folder failed: {}", e),
}
}
fn start_local_delete(&mut self, name: String) {
if name == ".." {
self.state.status_message = "Cannot delete parent entry".into();
return;
}
let Some(entry) = self.state.local_entries.iter().find(|e| e.name == name) else {
self.state.status_message = "Selected entry not found".into();
return;
};
match crate::fs::local::delete(&entry.path) {
Ok(()) => {
self.state.status_message = "Deleted".into();
local_pane::refresh_local(&mut self.state);
}
Err(e) => self.state.status_message = format!("Delete failed: {}", e),
}
}
fn start_local_rename(&mut self, old_name: String, new_name: String) {
let Some(entry) = self.state.local_entries.iter().find(|e| e.name == old_name) else {
self.state.status_message = "Selected entry not found".into();
return;
};
let from = entry.path.clone();
let parent_dir = std::path::Path::new(&from)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| self.state.local_path.clone());
let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name);
match crate::fs::local::rename(&from, &to) {
Ok(()) => {
self.state.status_message = "Renamed".into();
local_pane::refresh_local(&mut self.state);
}
Err(e) => self.state.status_message = format!("Rename failed: {}", e),
}
}
fn apply_visuals(&self, ctx: &egui::Context) { fn apply_visuals(&self, ctx: &egui::Context) {
let mut vis = Visuals::dark(); let mut vis = Visuals::dark();
vis.window_fill = BG_PANEL; vis.window_fill = BG_PANEL;
@ -354,7 +461,7 @@ impl FileManagerApp {
vis.code_bg_color = BG_BASE; vis.code_bg_color = BG_BASE;
vis.override_text_color = Some(TEXT_PRIMARY); vis.override_text_color = Some(TEXT_PRIMARY);
vis.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER); vis.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER);
vis.widgets.inactive.bg_fill = Color32::from_rgb(36, 36, 40); vis.widgets.inactive.bg_fill = BG_CONTENT;
vis.widgets.hovered.bg_fill = BG_ROW_HOVER; vis.widgets.hovered.bg_fill = BG_ROW_HOVER;
vis.widgets.active.bg_fill = ACCENT_DIM; vis.widgets.active.bg_fill = ACCENT_DIM;
vis.selection.bg_fill = BG_ROW_SEL; vis.selection.bg_fill = BG_ROW_SEL;
@ -362,8 +469,7 @@ impl FileManagerApp {
vis.window_rounding = egui::Rounding::same(8.0); vis.window_rounding = egui::Rounding::same(8.0);
ctx.set_visuals(vis); ctx.set_visuals(vis);
let fonts = egui::FontDefinitions::default(); ctx.set_fonts(system_fonts());
ctx.set_fonts(fonts);
let mut style = ctx.style().as_ref().clone(); let mut style = ctx.style().as_ref().clone();
style.spacing.item_spacing = egui::vec2(4.0, 2.0); style.spacing.item_spacing = egui::vec2(4.0, 2.0);
@ -376,11 +482,13 @@ impl eframe::App for FileManagerApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.first_frame { if self.first_frame {
self.first_frame = false; self.first_frame = false;
egui_extras::install_image_loaders(ctx);
self.apply_visuals(ctx); self.apply_visuals(ctx);
ctx.request_repaint(); ctx.request_repaint();
} }
self.poll_pending(); self.poll_pending();
crate::ui::menu::poll_menu_events(&mut self.state);
self.state.queue_tasks = self.queue.all(); self.state.queue_tasks = self.queue.all();
self.state.connected_count = self self.state.connected_count = self
.state .state
@ -389,11 +497,168 @@ impl eframe::App for FileManagerApp {
.filter(|t| t.status == ConnectionStatus::Connected) .filter(|t| t.status == ConnectionStatus::Connected)
.count(); .count();
if self.state.tabs.is_empty() {
self.render_welcome_screen(ctx);
} else {
self.render_main_ui(ctx);
}
// ── Connection dialog ────────────────────────────────────────────────
if self.state.show_connect_dialog {
connection::render(ctx, &mut self.state, &self.registry, self.rt.handle());
}
// ── Remote operation dialogs ─────────────────────────────────────────
self.render_remote_op_dialogs(ctx);
// ── Settings ─────────────────────────────────────────────────────────
if self.state.show_settings_dialog {
self.render_settings_dialog(ctx);
}
// 200ms repaint для обновления прогресса
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
impl FileManagerApp {
/// Стартовый экран — показывается, пока нет ни одного открытого соединения.
/// Только фон, логотип, кнопка нового подключения и список недавних серверов.
fn render_welcome_screen(&mut self, ctx: &egui::Context) {
CentralPanel::default()
.frame(egui::Frame::none().fill(BG_BASE))
.show(ctx, |ui| {
ui.vertical_centered(|ui| {
let avail_h = ui.available_height();
ui.add_space((avail_h * 0.16).max(24.0));
let icon_bytes: &[u8] = include_bytes!("icons/app_icon.png");
let icon_img = egui::Image::from_bytes("bytes://welcome_app_icon", icon_bytes)
.rounding(RADIUS_LG)
.fit_to_exact_size(egui::vec2(72.0, 72.0));
ui.add(icon_img);
ui.add_space(14.0);
ui.label(
RichText::new("LoFlum")
.color(TEXT_PRIMARY)
.size(20.0)
.strong(),
);
ui.add_space(4.0);
ui.label(RichText::new("FTP/SFTP client").color(TEXT_HINT).size(12.5));
ui.add_space(20.0);
let btn = egui::Button::image_and_text(
crate::ui::icons::image(
crate::ui::icons::Icon::AddCircleBold,
15.0,
ON_ACCENT,
),
RichText::new("New Connection")
.color(ON_ACCENT)
.size(12.5)
.strong(),
)
.fill(ACCENT)
.rounding(RADIUS_MD)
.min_size(egui::vec2(170.0, 36.0));
if ui.add(btn).clicked() {
self.state.show_connect_dialog = true;
}
ui.add_space(28.0);
let list_w = 320.0_f32.min(ui.available_width() - 40.0);
if !self.state.history.is_empty() {
ui.label(
RichText::new("RECENT CONNECTIONS")
.color(TEXT_HINT)
.size(10.5)
.strong(),
);
ui.add_space(8.0);
const ROW_H: f32 = 34.0;
egui::Frame::none().show(ui, |ui| {
ui.set_width(list_w);
let history: Vec<_> =
self.state.history.iter().take(8).cloned().collect();
for entry in &history {
let row_rect = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(list_w, ROW_H),
);
let is_hovered = ui.rect_contains_pointer(row_rect);
let bg = if is_hovered {
BG_ROW_HOVER
} else {
egui::Color32::TRANSPARENT
};
let label = format!("{}@{}:{}", entry.user, entry.host, entry.port);
let row = egui::Frame::none()
.fill(bg)
.rounding(RADIUS_MD)
.inner_margin(egui::Margin::symmetric(10.0, 7.0))
.show(ui, |ui| {
ui.set_width(list_w - 20.0);
ui.horizontal(|ui| {
crate::ui::icons::icon(
ui,
crate::ui::icons::Icon::ServerSquare,
13.0,
TEXT_DIM,
);
ui.add_space(8.0);
ui.label(
RichText::new(&label)
.color(TEXT_PRIMARY)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(&entry.time)
.color(TEXT_HINT)
.size(10.5),
);
},
);
});
});
let resp = row.response.interact(egui::Sense::click());
if is_hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if resp.clicked() {
self.state.pending_history_reconnect = Some(entry.clone());
}
resp.context_menu(|ui| {
if ui.button("Edit").clicked() {
connection::edit_history_entry(&mut self.state, entry);
ui.close_menu();
}
if ui.button("Save").clicked() {
self.state.pending_history_save = Some(entry.clone());
ui.close_menu();
}
});
}
});
}
});
});
}
fn render_main_ui(&mut self, ctx: &egui::Context) {
// ── Toolbar ────────────────────────────────────────────────────────── // ── Toolbar ──────────────────────────────────────────────────────────
TopBottomPanel::top("toolbar") TopBottomPanel::top("toolbar")
.frame(egui::Frame::none().fill(BG_TOOLBAR)) .frame(egui::Frame::none().fill(BG_TOOLBAR))
.show(ctx, |ui| { .show(ctx, |ui| {
toolbar::render(ui, &mut self.state); toolbar::render(ui, &mut self.state, &self.queue);
}); });
// ── Tab bar ────────────────────────────────────────────────────────── // ── Tab bar ──────────────────────────────────────────────────────────
@ -441,7 +706,7 @@ impl eframe::App for FileManagerApp {
.default_width(SIDEBAR_W) .default_width(SIDEBAR_W)
.width_range(120.0..=260.0) .width_range(120.0..=260.0)
.show(ctx, |ui| { .show(ctx, |ui| {
sidebar::render(ui, &mut self.state); sidebar::render(ui, &mut self.state, &self.db);
}); });
// ── Main content: Local | Remote ───────────────────────────────────── // ── Main content: Local | Remote ─────────────────────────────────────
@ -460,15 +725,13 @@ impl eframe::App for FileManagerApp {
egui::vec2(half, total_h), egui::vec2(half, total_h),
egui::Layout::top_down(egui::Align::LEFT), egui::Layout::top_down(egui::Align::LEFT),
|ui| { |ui| {
// Хедер панели
pane_header(ui, "LOCAL", half);
local_pane::render( local_pane::render(
ui, ui,
&mut self.state, &mut self.state,
&self.queue, &self.queue,
&self.registry, &self.registry,
self.rt.handle(), self.rt.handle(),
&self.db,
); );
}, },
); );
@ -485,10 +748,6 @@ impl eframe::App for FileManagerApp {
if has_connection { if has_connection {
let idx = self.state.active_tab.min(self.state.tabs.len() - 1); let idx = self.state.active_tab.min(self.state.tabs.len() - 1);
// Хедер панели
let label = self.state.tabs[idx].params.host.clone();
pane_header(ui, &format!("REMOTE · {}", label), half);
remote_pane::render( remote_pane::render(
ui, ui,
&mut self.state, &mut self.state,
@ -504,10 +763,35 @@ impl eframe::App for FileManagerApp {
egui::Layout::centered_and_justified(egui::Direction::TopDown), egui::Layout::centered_and_justified(egui::Direction::TopDown),
|ui| { |ui| {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() * 0.3); ui.add_space(ui.available_height() * 0.28);
let circle_d = 56.0;
let (rect, _) = ui.allocate_exact_size(
egui::vec2(circle_d, circle_d),
egui::Sense::hover(),
);
ui.painter().circle_filled(
rect.center(),
circle_d / 2.0,
BG_TAB_ACTIVE,
);
let icon_img = crate::ui::icons::image(
crate::ui::icons::Icon::ServerSquare,
26.0,
TEXT_HINT,
);
icon_img.paint_at(
ui,
egui::Rect::from_center_size(
rect.center(),
egui::vec2(26.0, 26.0),
),
);
ui.add_space(10.0);
ui.label( ui.label(
RichText::new("Remote") RichText::new("Remote")
.color(TEXT_HINT) .color(TEXT_DIM)
.size(14.0) .size(14.0)
.strong(), .strong(),
); );
@ -520,13 +804,19 @@ impl eframe::App for FileManagerApp {
.size(12.0), .size(12.0),
); );
ui.add_space(16.0); ui.add_space(16.0);
let btn = egui::Button::new( let btn = egui::Button::image_and_text(
RichText::new("+ New Connection") crate::ui::icons::image(
.color(Color32::WHITE) crate::ui::icons::Icon::AddCircleBold,
.size(12.0), 15.0,
ON_ACCENT,
),
RichText::new("New Connection")
.color(ON_ACCENT)
.size(12.5)
.strong(),
) )
.fill(ACCENT) .fill(ACCENT)
.rounding(6.0) .rounding(RADIUS_MD)
.min_size(egui::vec2(150.0, 34.0)); .min_size(egui::vec2(150.0, 34.0));
if ui.add(btn).clicked() { if ui.add(btn).clicked() {
self.state.show_connect_dialog = true; self.state.show_connect_dialog = true;
@ -539,17 +829,6 @@ impl eframe::App for FileManagerApp {
); );
}); });
}); });
// ── Connection dialog ────────────────────────────────────────────────
if self.state.show_connect_dialog {
connection::render(ctx, &mut self.state, &self.registry, self.rt.handle());
}
// ── Remote operation dialogs ─────────────────────────────────────────
self.render_remote_op_dialogs(ctx);
// 200ms repaint для обновления прогресса
ctx.request_repaint_after(std::time::Duration::from_millis(200));
} }
} }
@ -588,7 +867,7 @@ impl FileManagerApp {
egui::Frame::none() egui::Frame::none()
.fill(BG_PANEL) .fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER)) .stroke(egui::Stroke::new(1.0, BORDER))
.rounding(8.0) .rounding(RADIUS_LG)
.inner_margin(egui::Margin::same(16.0)), .inner_margin(egui::Margin::same(16.0)),
) )
.open(&mut open) .open(&mut open)
@ -600,19 +879,20 @@ impl FileManagerApp {
ui.add_space(16.0); ui.add_space(16.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = egui::Button::new( let ok =
RichText::new("OK").color(Color32::WHITE).size(12.0), egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5))
)
.fill(ACCENT) .fill(ACCENT)
.rounding(4.0); .rounding(RADIUS_MD)
.min_size(egui::vec2(70.0, 30.0));
if ui.add(ok).clicked() { if ui.add(ok).clicked() {
clicked_ok = true; clicked_ok = true;
} }
ui.add_space(8.0); ui.add_space(8.0);
let cancel = egui::Button::new( let cancel = egui::Button::new(
RichText::new("Cancel").color(TEXT_DIM).size(12.0), RichText::new("Cancel").color(TEXT_DIM).size(12.5),
) )
.fill(egui::Color32::TRANSPARENT); .fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(70.0, 30.0));
if ui.add(cancel).clicked() { if ui.add(cancel).clicked() {
self.state.show_mkdir_dialog = false; self.state.show_mkdir_dialog = false;
self.state.mkdir_name.clear(); self.state.mkdir_name.clear();
@ -622,10 +902,15 @@ impl FileManagerApp {
}); });
if clicked_ok && !self.state.mkdir_name.is_empty() { if clicked_ok && !self.state.mkdir_name.is_empty() {
if let Some(idx) = self.active_tab_idx() {
let name = self.state.mkdir_name.clone(); let name = self.state.mkdir_name.clone();
match self.state.op_target {
crate::ui::state::Pane::Local => self.start_local_mkdir(name),
crate::ui::state::Pane::Remote => {
if let Some(idx) = self.active_tab_idx() {
self.start_mkdir(idx, name); self.start_mkdir(idx, name);
} }
}
}
self.state.show_mkdir_dialog = false; self.state.show_mkdir_dialog = false;
} }
if !open { if !open {
@ -649,33 +934,50 @@ impl FileManagerApp {
egui::Frame::none() egui::Frame::none()
.fill(BG_PANEL) .fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER)) .stroke(egui::Stroke::new(1.0, BORDER))
.rounding(8.0) .rounding(RADIUS_LG)
.inner_margin(egui::Margin::same(16.0)), .inner_margin(egui::Margin::same(16.0)),
) )
.open(&mut open) .open(&mut open)
.show(ctx, |ui| { .show(ctx, |ui| {
ui.set_width(260.0); ui.set_width(260.0);
ui.horizontal(|ui| {
crate::ui::icons::icon(
ui,
crate::ui::icons::Icon::DangerTriangle,
18.0,
RED,
);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label( ui.label(
RichText::new(format!("Delete '{}' ?", name)) RichText::new(format!("Delete \"{}\"?", name))
.color(TEXT_PRIMARY) .color(TEXT_PRIMARY)
.strong(), .strong(),
); );
ui.label(
RichText::new("This action cannot be undone.")
.color(TEXT_DIM)
.size(11.0),
);
});
});
ui.add_space(16.0); ui.add_space(16.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = egui::Button::new( let ok =
RichText::new("OK").color(Color32::WHITE).size(12.0), egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5))
)
.fill(RED) .fill(RED)
.rounding(4.0); .rounding(RADIUS_MD)
.min_size(egui::vec2(70.0, 30.0));
if ui.add(ok).clicked() { if ui.add(ok).clicked() {
clicked_ok = true; clicked_ok = true;
} }
ui.add_space(8.0); ui.add_space(8.0);
let cancel = egui::Button::new( let cancel = egui::Button::new(
RichText::new("Cancel").color(TEXT_DIM).size(12.0), RichText::new("Cancel").color(TEXT_DIM).size(12.5),
) )
.fill(egui::Color32::TRANSPARENT); .fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(70.0, 30.0));
if ui.add(cancel).clicked() { if ui.add(cancel).clicked() {
self.state.show_delete_dialog = false; self.state.show_delete_dialog = false;
self.state.delete_name.clear(); self.state.delete_name.clear();
@ -685,9 +987,14 @@ impl FileManagerApp {
}); });
if clicked_ok { if clicked_ok {
match self.state.op_target {
crate::ui::state::Pane::Local => self.start_local_delete(name),
crate::ui::state::Pane::Remote => {
if let Some(idx) = self.active_tab_idx() { if let Some(idx) = self.active_tab_idx() {
self.start_delete(idx, name); self.start_delete(idx, name);
} }
}
}
self.state.show_delete_dialog = false; self.state.show_delete_dialog = false;
} }
if !open { if !open {
@ -710,7 +1017,7 @@ impl FileManagerApp {
egui::Frame::none() egui::Frame::none()
.fill(BG_PANEL) .fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER)) .stroke(egui::Stroke::new(1.0, BORDER))
.rounding(8.0) .rounding(RADIUS_LG)
.inner_margin(egui::Margin::same(16.0)), .inner_margin(egui::Margin::same(16.0)),
) )
.open(&mut open) .open(&mut open)
@ -722,19 +1029,20 @@ impl FileManagerApp {
ui.add_space(16.0); ui.add_space(16.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = egui::Button::new( let ok =
RichText::new("OK").color(Color32::WHITE).size(12.0), egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5))
)
.fill(ACCENT) .fill(ACCENT)
.rounding(4.0); .rounding(RADIUS_MD)
.min_size(egui::vec2(70.0, 30.0));
if ui.add(ok).clicked() { if ui.add(ok).clicked() {
clicked_ok = true; clicked_ok = true;
} }
ui.add_space(8.0); ui.add_space(8.0);
let cancel = egui::Button::new( let cancel = egui::Button::new(
RichText::new("Cancel").color(TEXT_DIM).size(12.0), RichText::new("Cancel").color(TEXT_DIM).size(12.5),
) )
.fill(egui::Color32::TRANSPARENT); .fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(70.0, 30.0));
if ui.add(cancel).clicked() { if ui.add(cancel).clicked() {
self.state.show_rename_dialog = false; self.state.show_rename_dialog = false;
self.state.rename_new_name.clear(); self.state.rename_new_name.clear();
@ -745,11 +1053,16 @@ impl FileManagerApp {
}); });
if clicked_ok && !self.state.rename_new_name.is_empty() { if clicked_ok && !self.state.rename_new_name.is_empty() {
if let Some(idx) = self.active_tab_idx() {
let old_name = self.state.rename_old_name.clone(); let old_name = self.state.rename_old_name.clone();
let new_name = self.state.rename_new_name.clone(); let new_name = self.state.rename_new_name.clone();
match self.state.op_target {
crate::ui::state::Pane::Local => self.start_local_rename(old_name, new_name),
crate::ui::state::Pane::Remote => {
if let Some(idx) = self.active_tab_idx() {
self.start_rename(idx, old_name, new_name); self.start_rename(idx, old_name, new_name);
} }
}
}
self.state.show_rename_dialog = false; self.state.show_rename_dialog = false;
} }
if !open { if !open {
@ -759,22 +1072,142 @@ impl FileManagerApp {
} }
} }
} }
fn render_settings_dialog(&mut self, ctx: &egui::Context) {
let screen = ctx.screen_rect();
let center = screen.center();
egui::Area::new(egui::Id::new("settings_overlay"))
.fixed_pos(egui::Pos2::ZERO)
.order(egui::Order::Background)
.show(ctx, |ui| {
let r = ui.allocate_rect(screen, egui::Sense::click());
ui.painter()
.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160));
if r.clicked() {
self.state.show_settings_dialog = false;
}
});
let mut open = true;
egui::Window::new("settings_dialog")
.collapsible(false)
.title_bar(false)
.resizable(false)
.default_pos(center)
.pivot(egui::Align2::CENTER_CENTER)
.frame(
egui::Frame::none()
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(RADIUS_LG)
.inner_margin(egui::Margin::same(20.0)),
)
.open(&mut open)
.show(ctx, |ui| {
ui.set_width(340.0);
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("Settings")
.color(TEXT_PRIMARY)
.size(15.0)
.strong(),
);
});
ui.add_space(16.0);
settings_row(ui, "Version", env!("CARGO_PKG_VERSION"));
let db_path = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("loflum")
.join("loflum.db");
settings_row(ui, "Sites database", &db_path.to_string_lossy());
ui.add_space(16.0);
ui.label(
RichText::new("More settings are on the way.")
.color(TEXT_HINT)
.size(11.0),
);
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let close =
egui::Button::new(RichText::new("Close").color(ON_ACCENT).size(12.5))
.fill(ACCENT)
.rounding(RADIUS_MD)
.min_size(egui::vec2(80.0, 30.0));
if ui.add(close).clicked() {
self.state.show_settings_dialog = false;
}
});
});
if !open {
self.state.show_settings_dialog = false;
}
}
}
fn settings_row(ui: &mut egui::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| {
ui.label(RichText::new(value).color(TEXT_HINT).monospace().size(10.5));
});
});
ui.add_space(6.0);
}
/// Загружает системный шрифт (SF Pro / SF Mono на macOS) вместо egui-дефолта,
/// чтобы интерфейс выглядел нативно, как в исходном макете. На других ОС
/// файлы просто отсутствуют — тогда остаются встроенные шрифты egui.
fn system_fonts() -> egui::FontDefinitions {
let mut fonts = egui::FontDefinitions::default();
let candidates: &[(&str, &str, egui::FontFamily)] = &[
(
"system-sans",
"/System/Library/Fonts/SFNS.ttf",
egui::FontFamily::Proportional,
),
(
"system-mono",
"/System/Library/Fonts/SFNSMono.ttf",
egui::FontFamily::Monospace,
),
];
for (name, path, family) in candidates {
if let Ok(bytes) = std::fs::read(path) {
fonts
.font_data
.insert((*name).to_owned(), egui::FontData::from_owned(bytes));
fonts
.families
.entry(family.clone())
.or_default()
.insert(0, (*name).to_owned());
}
}
fonts
} }
fn pane_header(ui: &mut egui::Ui, label: &str, width: f32) { fn pane_header(ui: &mut egui::Ui, label: &str, width: f32) {
egui::Frame::none() egui::Frame::none()
.fill(BG_BASE) .fill(BG_BASE)
.inner_margin(egui::Margin { .inner_margin(egui::Margin::symmetric(12.0, 0.0))
left: 10.0,
right: 6.0,
top: 4.0,
bottom: 4.0,
})
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(width); ui.allocate_ui_with_layout(
ui.horizontal(|ui| { egui::vec2(width - 24.0, 30.0),
ui.label(RichText::new(label).color(TEXT_DIM).size(10.0).strong()); egui::Layout::left_to_right(egui::Align::Center),
}); |ui| {
ui.label(RichText::new(label).color(TEXT_DIM).size(10.5).strong());
},
);
}); });
let (_, sep) = ui.allocate_space(egui::vec2(width, 1.0)); let (_, sep) = ui.allocate_space(egui::vec2(width, 1.0));

View file

@ -20,7 +20,7 @@ pub fn render(
// Центрируем окно // Центрируем окно
let screen = ctx.screen_rect(); let screen = ctx.screen_rect();
let win_size = egui::vec2(420.0, 340.0); let win_size = egui::vec2(380.0, 340.0);
let win_pos = egui::pos2( let win_pos = egui::pos2(
(screen.width() - win_size.x) * 0.5, (screen.width() - win_size.x) * 0.5,
(screen.height() - win_size.y) * 0.4, (screen.height() - win_size.y) * 0.4,
@ -43,9 +43,9 @@ pub fn render(
.order(egui::Order::Foreground) .order(egui::Order::Foreground)
.show(ctx, |ui| { .show(ctx, |ui| {
egui::Frame::none() egui::Frame::none()
.fill(Color32::from_rgb(30, 30, 34)) .fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER)) .stroke(egui::Stroke::new(1.0, BORDER))
.rounding(10.0) .rounding(RADIUS_LG)
.shadow(egui::Shadow { .shadow(egui::Shadow {
offset: egui::vec2(0.0, 8.0), offset: egui::vec2(0.0, 8.0),
blur: 24.0, blur: 24.0,
@ -80,15 +80,11 @@ pub fn render(
ui.horizontal(|ui| { ui.horizontal(|ui| {
for (i, p) in protocols.iter().enumerate() { for (i, p) in protocols.iter().enumerate() {
let active = state.connect_protocol == i; let active = state.connect_protocol == i;
let bg = if active { let bg = if active { ACCENT_DIM } else { BG_CONTENT };
ACCENT let tc = if active { TEXT_PRIMARY } else { TEXT_DIM };
} else { let btn = egui::Button::new(RichText::new(*p).color(tc).size(11.5))
Color32::from_rgb(40, 40, 44)
};
let tc = if active { Color32::WHITE } else { TEXT_DIM };
let btn = egui::Button::new(RichText::new(*p).color(tc).size(12.0))
.fill(bg) .fill(bg)
.rounding(4.0) .rounding(RADIUS_MD)
.min_size(egui::vec2(64.0, 28.0)); .min_size(egui::vec2(64.0, 28.0));
if ui.add(btn).clicked() { if ui.add(btn).clicked() {
state.connect_protocol = i; state.connect_protocol = i;
@ -99,27 +95,20 @@ pub fn render(
ui.add_space(14.0); ui.add_space(14.0);
// Поля ввода // Поля ввода — Host / Username+Port / Password, как в макете
let field_w = ui.available_width(); let field_w = ui.available_width();
field(
ui, field(ui, "Host", &mut state.connect_host, field_w, false);
"Label (optional)",
&mut state.connect_label,
field_w,
false,
);
ui.add_space(8.0); ui.add_space(8.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let host_w = field_w - 80.0 - 8.0; let user_w = field_w - 80.0 - 8.0;
field(ui, "Host", &mut state.connect_host, host_w, false); field(ui, "Username", &mut state.connect_user, user_w, false);
ui.add_space(8.0); ui.add_space(8.0);
field(ui, "Port", &mut state.connect_port, 80.0, false); field(ui, "Port", &mut state.connect_port, 80.0, false);
}); });
ui.add_space(8.0); ui.add_space(8.0);
field(ui, "Username", &mut state.connect_user, field_w, false);
ui.add_space(8.0);
field(ui, "Password", &mut state.connect_pass, field_w, true); field(ui, "Password", &mut state.connect_pass, field_w, true);
if state.connect_protocol == 0 { if state.connect_protocol == 0 {
@ -127,7 +116,7 @@ pub fn render(
ui.horizontal(|ui| { ui.horizontal(|ui| {
field( field(
ui, ui,
"Key path", "Key path (optional)",
&mut state.connect_key_path, &mut state.connect_key_path,
field_w - 80.0 - 8.0, field_w - 80.0 - 8.0,
false, false,
@ -137,22 +126,43 @@ pub fn render(
let browse = egui::Button::new( let browse = egui::Button::new(
RichText::new("Browse").color(TEXT_DIM).size(11.0), RichText::new("Browse").color(TEXT_DIM).size(11.0),
) )
.fill(Color32::from_rgb(40, 40, 44)) .fill(BG_CONTENT)
.min_size(egui::vec2(72.0, 32.0)) .min_size(egui::vec2(72.0, 32.0))
.rounding(4.0); .rounding(RADIUS_MD);
ui.add_enabled(false, browse); ui.add_enabled(false, browse);
}); });
} }
ui.add_space(8.0);
field(
ui,
"Label (optional)",
&mut state.connect_label,
field_w,
false,
);
// Ошибка // Ошибка
if !state.connect_error.is_empty() { if !state.connect_error.is_empty() {
ui.add_space(10.0); ui.add_space(10.0);
egui::Frame::none() egui::Frame::none()
.fill(Color32::from_rgb(60, 25, 25)) .fill(Color32::from_rgb(42, 27, 22))
.rounding(4.0) .stroke(egui::Stroke::new(1.0, Color32::from_rgb(70, 40, 34)))
.inner_margin(egui::Margin::symmetric(8.0, 6.0)) .rounding(RADIUS_MD)
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| { .show(ui, |ui| {
ui.label(RichText::new(&state.connect_error).color(RED).size(11.0)); ui.horizontal(|ui| {
crate::ui::icons::icon(
ui,
crate::ui::icons::Icon::DangerTriangle,
14.0,
RED,
);
ui.add_space(6.0);
ui.label(
RichText::new(&state.connect_error).color(RED).size(11.0),
);
});
}); });
} }
@ -161,10 +171,10 @@ pub fn render(
// Кнопки // Кнопки
ui.horizontal(|ui| { ui.horizontal(|ui| {
let cancel = let cancel =
egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.0)) egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.5))
.fill(Color32::from_rgb(40, 40, 44)) .fill(Color32::TRANSPARENT)
.rounding(6.0) .rounding(RADIUS_MD)
.min_size(egui::vec2(90.0, 34.0)); .min_size(egui::vec2(90.0, 30.0));
if ui.add(cancel).clicked() && !state.connect_loading { if ui.add(cancel).clicked() && !state.connect_loading {
state.show_connect_dialog = false; state.show_connect_dialog = false;
} }
@ -178,13 +188,13 @@ pub fn render(
}; };
let connect_btn = egui::Button::new( let connect_btn = egui::Button::new(
RichText::new(connect_label) RichText::new(connect_label)
.color(Color32::WHITE) .color(ON_ACCENT)
.size(12.0) .size(12.5)
.strong(), .strong(),
) )
.fill(ACCENT) .fill(ACCENT)
.rounding(6.0) .rounding(RADIUS_MD)
.min_size(egui::vec2(110.0, 34.0)); .min_size(egui::vec2(110.0, 30.0));
if ui.add(connect_btn).clicked() { if ui.add(connect_btn).clicked() {
do_connect(state, registry, rt_handle); do_connect(state, registry, rt_handle);
@ -218,6 +228,19 @@ fn do_connect(
) { ) {
state.connect_error.clear(); state.connect_error.clear();
if state.connect_host.trim().is_empty() {
state.connect_error = "Host is required.".into();
return;
}
if state.connect_user.trim().is_empty() {
state.connect_error = "Username is required.".into();
return;
}
if !state.connect_port.trim().is_empty() && state.connect_port.trim().parse::<u16>().is_err() {
state.connect_error = "Port must be a number between 1 and 65535.".into();
return;
}
let protocol = match state.connect_protocol { let protocol = match state.connect_protocol {
0 => Protocol::Sftp, 0 => Protocol::Sftp,
1 => Protocol::Ftp, 1 => Protocol::Ftp,
@ -261,6 +284,17 @@ fn do_connect(
}, },
}; };
spawn_connect(state, registry, rt_handle, params);
}
/// Общий код запуска подключения — используется и диалогом New Connection,
/// и переподключением из истории (см. `reconnect_from_history`).
pub fn spawn_connect(
state: &mut AppState,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
params: ConnectionParams,
) {
let registry = registry.clone(); let registry = registry.clone();
let params_clone = params.clone(); let params_clone = params.clone();
let result = Arc::new(std::sync::Mutex::new(None)); let result = Arc::new(std::sync::Mutex::new(None));
@ -275,6 +309,80 @@ fn do_connect(
state.pending_connect = Some(PendingConnect { result }); state.pending_connect = Some(PendingConnect { result });
} }
/// Переподключение по клику на запись истории — пароль (если есть) уже лежит
/// в keychain под `entry.conn_id`, поэтому диалог не нужен.
pub fn reconnect_from_history(
state: &mut AppState,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
entry: &crate::ui::state::HistoryEntry,
) {
state.connect_error.clear();
let params = ConnectionParams {
id: entry.conn_id.clone(),
label: format!("{}@{}", entry.user, entry.host),
protocol: entry.protocol.clone(),
host: entry.host.clone(),
port: entry.port,
username: entry.user.clone(),
password: None,
key_path: entry.key_path.clone(),
};
spawn_connect(state, registry, rt_handle, params);
}
fn protocol_index(protocol: &Protocol) -> usize {
match protocol {
Protocol::Sftp => 0,
Protocol::Ftp => 1,
Protocol::Ftps => 2,
}
}
/// "Изменить" в меню истории — открывает диалог New Connection, предзаполненный
/// этой записью (включая пароль из keychain, если он там есть).
pub fn edit_history_entry(state: &mut AppState, entry: &crate::ui::state::HistoryEntry) {
state.connect_error.clear();
state.connect_host = entry.host.clone();
state.connect_user = entry.user.clone();
state.connect_port = entry.port.to_string();
state.connect_protocol = protocol_index(&entry.protocol);
state.connect_key_path = entry.key_path.clone().unwrap_or_default();
state.connect_pass = keychain::get_password(&entry.conn_id).unwrap_or_default();
state.connect_label.clear();
state.show_connect_dialog = true;
}
/// "Сохранить" в меню истории — превращает разовое подключение в постоянный Site.
pub fn save_history_as_site(
db: &Arc<std::sync::Mutex<rusqlite::Connection>>,
sites: &mut Vec<crate::domain::site::Site>,
entry: &crate::ui::state::HistoryEntry,
) -> Result<(), String> {
let site = crate::domain::site::Site {
id: entry.conn_id.clone(),
name: format!("{}@{}", entry.user, entry.host),
protocol: entry.protocol.clone(),
host: entry.host.clone(),
port: entry.port,
username: entry.user.clone(),
key_path: entry.key_path.clone(),
folder: None,
note: None,
};
let conn = db
.lock()
.map_err(|_| "database lock poisoned".to_string())?;
crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())?;
drop(conn);
if let Some(existing) = sites.iter_mut().find(|s| s.id == site.id) {
*existing = site;
} else {
sites.push(site);
}
Ok(())
}
async fn connect_async( async fn connect_async(
registry: &RemoteRegistry, registry: &RemoteRegistry,
params: &ConnectionParams, params: &ConnectionParams,

98
src/ui/icons.rs Normal file
View file

@ -0,0 +1,98 @@
//! Solar icon set (https://solar-icons.vercel.app) — встроенные SVG, красятся через tint()
use egui::{Color32, Ui};
macro_rules! icon_set {
($($key:ident => $file:literal),* $(,)?) => {
pub enum Icon { $($key),* }
fn bytes(icon: &Icon) -> &'static [u8] {
match icon {
$(Icon::$key => include_bytes!(concat!("icons/svg/", $file, ".svg"))),*
}
}
fn uri(icon: &Icon) -> &'static str {
match icon {
$(Icon::$key => concat!("bytes://solar/", $file, ".svg")),*
}
}
};
}
icon_set! {
Folder => "folder-bold",
FolderWithFiles => "folder-with-files-linear",
AddCircleLinear => "add-circle-linear",
AddCircleBold => "add-circle-bold",
AddSquare => "add-square-linear",
Upload => "upload-linear",
Download => "download-linear",
DownloadSquare => "download-square-bold",
Clock => "clock-circle-linear",
Refresh => "refresh-linear",
Trash => "trash-bin-minimalistic-linear",
Pen => "pen-linear",
History => "history-linear",
ServerSquare => "server-square-linear",
Home => "home-2-bold",
Monitor => "monitor-bold",
Document => "document-bold",
DocumentText => "document-text-bold",
Ssd => "ssd-square-linear",
Database => "database-linear",
DangerTriangle => "danger-triangle-bold",
LockPassword => "lock-password-bold",
ArrowDown => "alt-arrow-down-linear",
ArrowUp => "alt-arrow-up-linear",
CheckCircle => "check-circle-bold",
PlayCircle => "play-circle-linear",
PauseCircle => "pause-circle-linear",
FileText => "file-text-bold",
Gallery => "gallery-bold",
Code => "code-bold",
Archive => "archive-down-minimlistic-bold",
ShieldKeyhole => "shield-keyhole-bold",
File => "file-bold",
Star => "star-bold",
Videocamera => "videocamera-record-bold",
MusicNote => "music-note-3-bold",
Link => "link-bold",
CloseCircle => "close-circle-linear",
Settings => "settings-linear",
}
/// Рисует иконку заданного размера и цвета (tint) в текущей позиции ui.
pub fn icon(ui: &mut Ui, icon: Icon, size: f32, color: Color32) -> egui::Response {
let image = egui::Image::from_bytes(uri(&icon), bytes(&icon))
.tint(color)
.fit_to_exact_size(egui::vec2(size, size));
ui.add(image)
}
/// Тот же рендер, но для использования внутри builder-паттернов (кнопки и т.д.) — возвращает Image.
pub fn image(icon: Icon, size: f32, color: Color32) -> egui::Image<'static> {
egui::Image::from_bytes(uri(&icon), bytes(&icon))
.tint(color)
.fit_to_exact_size(egui::vec2(size, size))
}
/// Цвет + иконка по расширению файла — палитра Clay
pub fn file_icon_for(ext: &str) -> (Icon, Color32) {
use crate::ui::theme::*;
match ext {
"pdf" => (Icon::FileText, RED),
"xlsx" | "xls" | "doc" | "docx" => (Icon::Document, GREEN),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => (Icon::Gallery, ACCENT),
"mp4" | "mkv" | "avi" | "mov" => (Icon::Videocamera, ACCENT),
"mp3" | "ogg" | "flac" | "wav" => (Icon::MusicNote, GREEN),
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => (Icon::Archive, TEXT_DIM),
"yaml" | "yml" | "toml" | "json" => (Icon::Code, YELLOW),
"php" | "java" | "class" | "jar" => (Icon::Code, ACCENT_DIM),
"rs" | "py" | "sh" | "bash" | "fish" | "zsh" => (Icon::Code, GREEN),
"html" | "css" | "js" | "ts" => (Icon::Code, ACCENT),
"log" => (Icon::DocumentText, TEXT_HINT),
"htaccess" => (Icon::ShieldKeyhole, TEXT_DIM),
"txt" | "md" => (Icon::DocumentText, TEXT_DIM),
_ => (Icon::File, TEXT_DIM),
}
}

BIN
src/ui/icons/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m.75-13a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V15a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 343 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>

After

Width:  |  Height:  |  Size: 248 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path d="M2 12c0-4.714 0-7.071 1.464-8.536C4.93 2 7.286 2 12 2s7.071 0 8.535 1.464C22 4.93 22 7.286 22 12s0 7.071-1.465 8.535C19.072 22 16.714 22 12 22s-7.071 0-8.536-1.465C2 19.072 2 16.714 2 12Z"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>

After

Width:  |  Height:  |  Size: 415 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 9l-7 6l-7-6"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 15l-7-6l-7 6"/></svg>

After

Width:  |  Height:  |  Size: 222 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect x="90" y="90" width="844" height="844" rx="188" fill="#191611"/>
<path fill="#C1673C" fill-rule="evenodd" transform="translate(262,262) scale(23)" d="M2.07 5.258C2 5.626 2 6.068 2 6.95V14c0 3.771 0 5.657 1.172 6.828S6.229 22 10 22h4c3.771 0 5.657 0 6.828-1.172S22 17.771 22 14v-2.202c0-2.632 0-3.949-.77-4.804a3 3 0 0 0-.224-.225C20.151 6 18.834 6 16.202 6h-.374c-1.153 0-1.73 0-2.268-.153a4 4 0 0 1-.848-.352C12.224 5.224 11.816 4.815 11 4l-.55-.55c-.274-.274-.41-.41-.554-.53a4 4 0 0 0-2.18-.903C7.53 2 7.336 2 6.95 2c-.883 0-1.324 0-1.692.07A4 4 0 0 0 2.07 5.257M12.25 10a.75.75 0 0 1 .75-.75h5a.75.75 0 0 1 0 1.5h-5a.75.75 0 0 1-.75-.75" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 769 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M20.535 3.464C19.07 2 16.713 2 11.999 2S4.93 2 3.464 3.464c-.758.758-1.123 1.754-1.3 3.192a6.5 6.5 0 0 1 1.884-1.448c.782-.398 1.619-.56 2.545-.635C7.488 4.5 8.59 4.5 9.936 4.5h4.126c1.347 0 2.448 0 3.343.073c.927.076 1.764.237 2.545.635a6.5 6.5 0 0 1 1.884 1.448c-.176-1.438-.542-2.434-1.3-3.192"/><path fill="#ffffff" fill-rule="evenodd" d="M2 14c0-2.8 0-4.2.545-5.27A5 5 0 0 1 4.73 6.545C5.8 6 7.2 6 10 6h4c2.8 0 4.2 0 5.27.545a5 5 0 0 1 2.185 2.185C22 9.8 22 11.2 22 14s0 4.2-.545 5.27a5 5 0 0 1-2.185 2.185C18.2 22 16.8 22 14 22h-4c-2.8 0-4.2 0-5.27-.545a5 5 0 0 1-2.185-2.185C2 18.2 2 16.8 2 14m10.53 3.53a.75.75 0 0 1-1.06 0l-2.5-2.5a.75.75 0 1 1 1.06-1.06l1.22 1.22V11a.75.75 0 0 1 1.5 0v4.19l1.22-1.22a.75.75 0 1 1 1.06 1.06z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 872 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10m-5.97-3.03a.75.75 0 0 1 0 1.06l-5 5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06l1.47 1.47l2.235-2.235L14.97 8.97a.75.75 0 0 1 1.06 0" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l2.5 2.5"/></g></svg>

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="m14.5 9.5l-5 5m0-5l5 5"/></g></svg>

After

Width:  |  Height:  |  Size: 243 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M14.18 4.276a.75.75 0 0 1 .531.918l-3.973 14.83a.75.75 0 0 1-1.45-.389l3.974-14.83a.75.75 0 0 1 .919-.53m2.262 3.053a.75.75 0 0 1 1.059-.056l1.737 1.564c.737.662 1.347 1.212 1.767 1.71c.44.525.754 1.088.754 1.784c0 .695-.313 1.258-.754 1.782c-.42.499-1.03 1.049-1.767 1.711l-1.737 1.564a.75.75 0 0 1-1.004-1.115l1.697-1.527c.788-.709 1.319-1.19 1.663-1.598c.33-.393.402-.622.402-.818s-.072-.424-.402-.817c-.344-.409-.875-.89-1.663-1.598l-1.697-1.527a.75.75 0 0 1-.056-1.06m-8.94 1.06a.75.75 0 1 0-1.004-1.115L4.761 8.836c-.737.662-1.347 1.212-1.767 1.71c-.44.525-.754 1.088-.754 1.784c0 .695.313 1.258.754 1.782c.42.499 1.03 1.049 1.767 1.711l1.737 1.564a.75.75 0 0 0 1.004-1.115l-1.697-1.527c-.788-.709-1.319-1.19-1.663-1.598c-.33-.393-.402-.622-.402-.818s.072-.424.402-.817c.344-.409.875-.89 1.663-1.598z"/></svg>

After

Width:  |  Height:  |  Size: 924 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M5.312 10.762C8.23 5.587 9.689 3 12 3s3.77 2.587 6.688 7.762l.364.644c2.425 4.3 3.638 6.45 2.542 8.022S17.786 21 12.364 21h-.728c-5.422 0-8.134 0-9.23-1.572s.117-3.722 2.542-8.022zM12 7.25a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V8a.75.75 0 0 1 .75-.75M12 17a1 1 0 1 0 0-2a1 1 0 0 0 0 2" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 444 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" d="M4 18V6m16 0v12"/><path d="M12 10c4.418 0 8-1.79 8-4s-3.582-4-8-4s-8 1.79-8 4s3.582 4 8 4Zm8 2c0 2.21-3.582 4-8 4s-8-1.79-8-4m16 6c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></g></svg>

After

Width:  |  Height:  |  Size: 350 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M4.172 3.172C3 4.343 3 6.229 3 10v4c0 3.771 0 5.657 1.172 6.828S7.229 22 11 22h2c3.771 0 5.657 0 6.828-1.172S21 17.771 21 14v-4c0-3.771 0-5.657-1.172-6.828S16.771 2 13 2h-2C7.229 2 5.343 2 4.172 3.172M8 9.25a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5zm0 4a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 451 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M4.172 3.172C3 4.343 3 6.229 3 10v4c0 3.771 0 5.657 1.172 6.828S7.229 22 11 22h2c3.771 0 5.657 0 6.828-1.172S21 17.771 21 14v-4c0-3.771 0-5.657-1.172-6.828S16.771 2 13 2h-2C7.229 2 5.343 2 4.172 3.172M7.25 8A.75.75 0 0 1 8 7.25h8a.75.75 0 0 1 0 1.5H8A.75.75 0 0 1 7.25 8m0 4a.75.75 0 0 1 .75-.75h8a.75.75 0 0 1 0 1.5H8a.75.75 0 0 1-.75-.75M8 15.25a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 546 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 2v13m0 0l-3-3.5m3 3.5l3-3.5"/></g></svg>

After

Width:  |  Height:  |  Size: 490 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2 12c0-4.714 0-7.071 1.464-8.536C4.93 2 7.286 2 12 2s7.071 0 8.535 1.464C22 4.93 22 7.286 22 12s0 7.071-1.465 8.535C19.072 22 16.714 22 12 22s-7.071 0-8.536-1.465C2 19.072 2 16.714 2 12m10-5.75a.75.75 0 0 1 .75.75v5.19l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V7a.75.75 0 0 1 .75-.75m-4 10a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 543 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14v-4c0-3.771 0-5.657 1.172-6.828S6.239 2 10.03 2c.606 0 1.091 0 1.5.017q-.02.12-.02.244l-.01 2.834c0 1.097 0 2.067.105 2.848c.114.847.375 1.694 1.067 2.386c.69.69 1.538.952 2.385 1.066c.781.105 1.751.105 2.848.105h4.052c.043.534.043 1.19.043 2.063V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22" clip-rule="evenodd"/><path fill="#ffffff" d="m19.352 7.617l-3.96-3.563c-1.127-1.015-1.69-1.523-2.383-1.788L13 5c0 2.357 0 3.536.732 4.268S15.643 10 18 10h3.58c-.362-.704-1.012-1.288-2.228-2.383"/></svg>

After

Width:  |  Height:  |  Size: 678 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14v-4c0-3.771 0-5.657 1.172-6.828S6.239 2 10.03 2c.606 0 1.091 0 1.5.017q-.02.12-.02.244l-.01 2.834c0 1.097 0 2.067.105 2.848c.114.847.375 1.694 1.067 2.386c.69.69 1.538.952 2.385 1.066c.781.105 1.751.105 2.848.105h4.052c.043.534.043 1.19.043 2.063V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22m-8.75-7.5a.75.75 0 0 1 .75-.75h8a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75m0 3.5a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75" clip-rule="evenodd"/><path fill="#ffffff" d="m19.352 7.617l-3.96-3.563c-1.127-1.015-1.69-1.523-2.383-1.788L13 5c0 2.357 0 3.536.732 4.268S15.643 10 18 10h3.58c-.362-.704-1.012-1.288-2.228-2.383"/></svg>

After

Width:  |  Height:  |  Size: 826 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2.07 5.258C2 5.626 2 6.068 2 6.95V14c0 3.771 0 5.657 1.172 6.828S6.229 22 10 22h4c3.771 0 5.657 0 6.828-1.172S22 17.771 22 14v-2.202c0-2.632 0-3.949-.77-4.804a3 3 0 0 0-.224-.225C20.151 6 18.834 6 16.202 6h-.374c-1.153 0-1.73 0-2.268-.153a4 4 0 0 1-.848-.352C12.224 5.224 11.816 4.815 11 4l-.55-.55c-.274-.274-.41-.41-.554-.53a4 4 0 0 0-2.18-.903C7.53 2 7.336 2 6.95 2c-.883 0-1.324 0-1.692.07A4 4 0 0 0 2.07 5.257M12.25 10a.75.75 0 0 1 .75-.75h5a.75.75 0 0 1 0 1.5h-5a.75.75 0 0 1-.75-.75" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 648 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" d="M18 10h-5"/><path d="M10 3h6.5c.464 0 .697 0 .892.026a3 3 0 0 1 2.582 2.582c.026.195.026.428.026.892"/><path d="M2 6.95c0-.883 0-1.324.07-1.692A4 4 0 0 1 5.257 2.07C5.626 2 6.068 2 6.95 2c.386 0 .58 0 .766.017a4 4 0 0 1 2.18.904c.144.119.28.255.554.529L11 4c.816.816 1.224 1.224 1.712 1.495a4 4 0 0 0 .848.352C14.098 6 14.675 6 15.828 6h.374c2.632 0 3.949 0 4.804.77q.119.105.224.224c.77.855.77 2.172.77 4.804V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14z"/></g></svg>

After

Width:  |  Height:  |  Size: 692 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M18 8a2 2 0 1 1-4 0a2 2 0 0 1 4 0"/><path fill="#ffffff" fill-rule="evenodd" d="M11.943 1.25h.114c2.309 0 4.118 0 5.53.19c1.444.194 2.584.6 3.479 1.494c.895.895 1.3 2.035 1.494 3.48c.19 1.411.19 3.22.19 5.529v.088c0 1.909 0 3.471-.104 4.743c-.104 1.28-.317 2.347-.795 3.235q-.314.586-.785 1.057c-.895.895-2.035 1.3-3.48 1.494c-1.411.19-3.22.19-5.529.19h-.114c-2.309 0-4.118 0-5.53-.19c-1.444-.194-2.584-.6-3.479-1.494c-.793-.793-1.203-1.78-1.42-3.006c-.215-1.203-.254-2.7-.262-4.558Q1.25 12.792 1.25 12v-.058c0-2.309 0-4.118.19-5.53c.194-1.444.6-2.584 1.494-3.479c.895-.895 2.035-1.3 3.48-1.494c1.411-.19 3.22-.19 5.529-.19m-5.33 1.676c-1.278.172-2.049.5-2.618 1.069c-.57.57-.897 1.34-1.069 2.619c-.174 1.3-.176 3.008-.176 5.386v.844l1.001-.876a2.3 2.3 0 0 1 3.141.104l4.29 4.29a2 2 0 0 0 2.564.222l.298-.21a3 3 0 0 1 3.732.225l2.83 2.547c.286-.598.455-1.384.545-2.493c.098-1.205.099-2.707.099-4.653c0-2.378-.002-4.086-.176-5.386c-.172-1.279-.5-2.05-1.069-2.62c-.57-.569-1.34-.896-2.619-1.068c-1.3-.174-3.008-.176-5.386-.176s-4.086.002-5.386.176" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l2.5 2.5"/><path fill="#ffffff" d="m5.604 5.604l-.53-.53zM4.338 6.871l-.75.003a.75.75 0 0 0 .746.747zm2.542.762a.75.75 0 1 0 .007-1.5zM5.075 4.321a.75.75 0 1 0-1.5.008zm-1.248 6.464a.75.75 0 1 0-1.486-.204zm15.035-5.647c-3.82-3.82-9.993-3.86-13.788-.064l1.06 1.06c3.2-3.199 8.423-3.18 11.668.064zM5.138 18.862c3.82 3.82 9.993 3.86 13.788.064l-1.06-1.06c-3.2 3.199-8.423 3.18-11.668-.064zm13.788.064c3.795-3.795 3.756-9.968-.064-13.788l-1.06 1.06c3.244 3.245 3.263 8.468.064 11.668zM5.074 5.074L3.807 6.34L4.868 7.4l1.266-1.266zm-.74 2.547l2.546.012l.007-1.5l-2.545-.012zm.754-.754L5.075 4.32l-1.5.008l.013 2.545zM2.34 10.58a9.81 9.81 0 0 0 2.797 8.281l1.06-1.06a8.31 8.31 0 0 1-2.371-7.017z"/></g></svg>

After

Width:  |  Height:  |  Size: 910 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2.52 7.823C2 8.77 2 9.915 2 12.203v1.522c0 3.9 0 5.851 1.172 7.063S6.229 22 10 22h4c3.771 0 5.657 0 6.828-1.212S22 17.626 22 13.725v-1.521c0-2.289 0-3.433-.52-4.381c-.518-.949-1.467-1.537-3.364-2.715l-2-1.241C14.111 2.622 13.108 2 12 2s-2.11.622-4.116 1.867l-2 1.241C3.987 6.286 3.038 6.874 2.519 7.823M11.25 18a.75.75 0 0 0 1.5 0v-3a.75.75 0 0 0-1.5 0z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 512 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M15.729 3.884c1.434-1.44 3.532-1.47 4.693-.304c1.164 1.168 1.133 3.28-.303 4.72l-2.423 2.433a.75.75 0 0 0 1.062 1.059l2.424-2.433c1.911-1.919 2.151-4.982.303-6.838c-1.85-1.857-4.907-1.615-6.82.304L9.819 7.692c-1.911 1.919-2.151 4.982-.303 6.837a.75.75 0 1 0 1.063-1.058c-1.164-1.168-1.132-3.28.303-4.72z"/><path fill="#ffffff" d="M14.485 9.47a.75.75 0 0 0-1.063 1.06c1.164 1.168 1.133 3.279-.303 4.72l-4.847 4.866c-1.435 1.44-3.533 1.47-4.694.304c-1.164-1.168-1.132-3.28.303-4.72l2.424-2.433a.75.75 0 0 0-1.063-1.059l-2.424 2.433c-1.911 1.92-2.151 4.982-.303 6.838c1.85 1.858 4.907 1.615 6.82-.304l4.847-4.867c1.911-1.918 2.151-4.982.303-6.837"/></svg>

After

Width:  |  Height:  |  Size: 761 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M5.25 10.055V8a6.75 6.75 0 0 1 13.5 0v2.055c1.115.083 1.84.293 2.371.824C22 11.757 22 13.172 22 16s0 4.243-.879 5.121C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.879C2 20.243 2 18.828 2 16s0-4.243.879-5.121c.53-.531 1.256-.741 2.371-.824M6.75 8a5.25 5.25 0 0 1 10.5 0v2.004Q16.676 9.999 16 10H8q-.677-.001-1.25.004zM8 17a1 1 0 1 0 0-2a1 1 0 0 0 0 2m4 0a1 1 0 1 0 0-2a1 1 0 0 0 0 2m5-1a1 1 0 1 1-2 0a1 1 0 0 1 2 0" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 578 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M8 17c-2.828 0-4.243 0-5.121-.879c-.57-.569-.77-1.363-.84-2.621h19.923c-.07 1.258-.271 2.052-.84 2.621C20.241 17 18.827 17 16 17h-3.25v4H16a.75.75 0 0 1 0 1.5H8A.75.75 0 0 1 8 21h3.25v-4zm2-15h4c3.771 0 5.657 0 6.828 1.172S22 6.229 22 10v1q.002.827-.006 1.5H2.007Q1.998 11.827 2 11v-1c0-3.771 0-5.657 1.172-6.828S6.229 2 10 2"/></svg>

After

Width:  |  Height:  |  Size: 443 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M14.319 2.505A2.75 2.75 0 0 0 11.414 4.3c-.098.27-.132.563-.148.869A17 17 0 0 0 11.25 6v8.536A4.75 4.75 0 1 0 12.75 18V9.21q.156.083.343.175L15.8 10.74c.418.21.759.38 1.038.5c.281.123.558.223.843.257A2.75 2.75 0 0 0 20.586 9.7c.098-.27.132-.563.148-.87c.016-.303.016-.683.016-1.151v-.083c0-.348 0-.62-.049-.878a2.75 2.75 0 0 0-1.03-1.667c-.21-.16-.453-.281-.764-.436L16.2 3.262a22 22 0 0 0-1.038-.501c-.28-.123-.558-.223-.843-.256"/></svg>

After

Width:  |  Height:  |  Size: 548 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M8 9.5c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C8.801 8 9.034 8 9.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54C10.199 16 9.966 16 9.5 16s-.699 0-.883-.076a1 1 0 0 1-.54-.541C8 15.199 8 14.966 8 14.5zm5 0c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C13.801 8 14.034 8 14.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54c-.184.077-.417.077-.883.077s-.699 0-.883-.076a1 1 0 0 1-.54-.541C13 15.199 13 14.966 13 14.5z"/></g></svg>

After

Width:  |  Height:  |  Size: 726 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-width="1.5" d="m14.36 4.079l.927-.927a3.932 3.932 0 0 1 5.561 5.561l-.927.927m-5.56-5.561s.115 1.97 1.853 3.707C17.952 9.524 19.92 9.64 19.92 9.64m-5.56-5.561l-8.522 8.52c-.577.578-.866.867-1.114 1.185a6.6 6.6 0 0 0-.749 1.211c-.173.364-.302.752-.56 1.526l-1.094 3.281m17.6-10.162L11.4 18.16c-.577.577-.866.866-1.184 1.114a6.6 6.6 0 0 1-1.211.749c-.364.173-.751.302-1.526.56l-3.281 1.094m0 0l-.802.268a1.06 1.06 0 0 1-1.342-1.342l.268-.802m1.876 1.876l-1.876-1.876"/></svg>

After

Width:  |  Height:  |  Size: 608 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M15.414 10.941c.781.462.781 1.656 0 2.118l-4.72 2.787C9.934 16.294 9 15.71 9 14.786V9.214c0-.924.934-1.507 1.694-1.059z"/></g></svg>

After

Width:  |  Height:  |  Size: 317 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M3.68 11.333h-.75zm0 1.667l-.528.532a.75.75 0 0 0 1.056 0zm2.208-1.134A.75.75 0 1 0 4.83 10.8zM2.528 10.8a.75.75 0 0 0-1.056 1.065zm16.088-3.408a.75.75 0 1 0 1.277-.786zM12.079 2.25c-5.047 0-9.15 4.061-9.15 9.083h1.5c0-4.182 3.42-7.583 7.65-7.583zm-9.15 9.083V13h1.5v-1.667zm1.28 2.2l1.679-1.667L4.83 10.8l-1.68 1.667zm0-1.065L2.528 10.8l-1.057 1.065l1.68 1.666zm15.684-5.86A9.16 9.16 0 0 0 12.08 2.25v1.5a7.66 7.66 0 0 1 6.537 3.643zM20.314 11l.527-.533a.75.75 0 0 0-1.054 0zM18.1 12.133a.75.75 0 0 0 1.055 1.067zm3.373 1.067a.75.75 0 1 0 1.054-1.067zM5.318 16.606a.75.75 0 1 0-1.277.788zm6.565 5.144c5.062 0 9.18-4.058 9.18-9.083h-1.5c0 4.18-3.43 7.583-7.68 7.583zm9.18-9.083V11h-1.5v1.667zm-1.276-2.2L18.1 12.133l1.055 1.067l1.686-1.667zm0 1.066l1.686 1.667l1.054-1.067l-1.686-1.666zM4.04 17.393a9.2 9.2 0 0 0 7.842 4.357v-1.5a7.7 7.7 0 0 1-6.565-3.644z"/></svg>

After

Width:  |  Height:  |  Size: 974 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path d="M2 11c0-3.771 0-5.657 1.172-6.828S6.229 3 10 3h4c3.771 0 5.657 0 6.828 1.172S22 7.229 22 11v2c0 3.771 0 5.657-1.172 6.828S17.771 21 14 21h-4c-3.771 0-5.657 0-6.828-1.172S2 16.771 2 13zm0 1h20"/><path stroke-linecap="round" d="M13.5 16.5H18m-4.5-9H18m-12 10v-2m0-7v-2m3 11v-2m0-7v-2"/></g></svg>

After

Width:  |  Height:  |  Size: 447 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M3.378 5.082C3 5.62 3 7.22 3 10.417v1.574c0 5.638 4.239 8.375 6.899 9.536c.721.315 1.082.473 2.101.473c1.02 0 1.38-.158 2.101-.473C16.761 20.365 21 17.63 21 11.991v-1.574c0-3.198 0-4.797-.378-5.335c-.377-.537-1.88-1.052-4.887-2.081l-.573-.196C13.595 2.268 12.812 2 12 2s-1.595.268-3.162.805L8.265 3c-3.007 1.03-4.51 1.545-4.887 2.082M13.5 15a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1.401A2.999 2.999 0 0 1 12 8a3 3 0 0 1 1.5 5.599z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 581 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M19 17v1m-2.5-1v1M14 17v1m-2.5-1v1"/><path fill="#ffffff" d="m19 5.118l.72-.21l-.002-.007l-.003-.009zm-14 0l-.715-.226l-.003.009l-.002.008zM2.889 20.643l.433-.612zm-.552-.584l.634-.4zm19.326 0l-.634-.4zm-.552.584l-.433-.612zm0-6.698l-.433.612zm.552.584l-.634.4zm-18.774-.584l.433.612zm-.552.584l.634.4zM7.5 3.75h9v-1.5h-9zm9 0c.428 0 .753.1 1.016.305c.269.21.549.59.769 1.288l1.43-.45c-.28-.89-.698-1.57-1.277-2.021c-.587-.457-1.261-.622-1.938-.622zm-9-1.5c-.676 0-1.351.165-1.938.622c-.58.451-.997 1.13-1.277 2.02l1.43.45c.22-.697.5-1.077.77-1.287c.262-.205.587-.305 1.015-.305zm10.78 3.076l2.851 9.839l1.44-.418l-2.85-9.838zm-14-.417l-2.852 9.838l1.44.418L5.72 5.326zm1.22 9.43h13v-1.5h-13zm13 5.911h-13v1.5h13zm-13 0c-.719 0-1.198-.001-1.563-.04c-.35-.038-.509-.104-.615-.18l-.866 1.225c.398.282.842.395 1.32.446c.464.05 1.038.049 1.724.049zm-4.25-2.956c0 .729 0 1.329.046 1.812c.047.493.15.945.407 1.353l1.268-.8c-.08-.126-.145-.313-.182-.697c-.038-.394-.039-.91-.039-1.668zm2.072 2.737a1.3 1.3 0 0 1-.35-.372l-1.27.8c.198.313.453.584.754.796zm17.928-2.737c0 .758 0 1.274-.039 1.668c-.037.384-.103.57-.182.697l1.268.8c.258-.408.36-.86.407-1.353c.047-.483.046-1.083.046-1.812zM18.5 21.75c.686 0 1.26.001 1.723-.049c.479-.052.923-.164 1.321-.446l-.866-1.224c-.106.075-.265.141-.615.179c-.365.039-.844.04-1.563.04zm2.529-2.091a1.3 1.3 0 0 1-.351.372l.866 1.224c.3-.212.556-.483.753-.796zm-2.529-5.32c.719 0 1.198 0 1.563.04c.35.037.509.103.615.178l.866-1.224c-.398-.282-.842-.394-1.32-.446c-.464-.05-1.038-.049-1.724-.049zm4.25 2.955c0-.729 0-1.329-.046-1.811c-.047-.494-.15-.946-.407-1.354l-1.268.8c.08.127.145.313.182.697c.038.394.039.91.039 1.668zm-2.072-2.737c.136.097.256.223.35.373l1.27-.8a2.8 2.8 0 0 0-.754-.797zM5.5 12.838c-.686 0-1.26 0-1.723.05c-.479.05-.923.163-1.321.445l.866 1.224c.106-.075.265-.14.615-.178c.365-.04.844-.04 1.563-.04zm-2.75 4.456c0-.758 0-1.274.039-1.668c.037-.384.103-.57.182-.696l-1.268-.8c-.258.407-.36.86-.407 1.353c-.047.482-.046 1.082-.046 1.811zm-.294-3.961c-.3.212-.556.484-.753.796l1.268.8a1.3 1.3 0 0 1 .351-.372z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M9.153 5.408C10.42 3.136 11.053 2 12 2s1.58 1.136 2.847 3.408l.328.588c.36.646.54.969.82 1.182s.63.292 1.33.45l.636.144c2.46.557 3.689.835 3.982 1.776c.292.94-.546 1.921-2.223 3.882l-.434.507c-.476.557-.715.836-.822 1.18c-.107.345-.071.717.001 1.46l.066.677c.253 2.617.38 3.925-.386 4.506s-1.918.051-4.22-1.009l-.597-.274c-.654-.302-.981-.452-1.328-.452s-.674.15-1.328.452l-.596.274c-2.303 1.06-3.455 1.59-4.22 1.01c-.767-.582-.64-1.89-.387-4.507l.066-.676c.072-.744.108-1.116 0-1.46c-.106-.345-.345-.624-.821-1.18l-.434-.508c-1.677-1.96-2.515-2.941-2.223-3.882S3.58 8.328 6.04 7.772l.636-.144c.699-.158 1.048-.237 1.329-.45s.46-.536.82-1.182z"/></svg>

After

Width:  |  Height:  |  Size: 761 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M9.17 4a3.001 3.001 0 0 1 5.66 0m5.67 2h-17m15.333 2.5l-.46 6.9c-.177 2.654-.265 3.981-1.13 4.79s-2.196.81-4.856.81h-.774c-2.66 0-3.991 0-4.856-.81c-.865-.809-.954-2.136-1.13-4.79l-.46-6.9M9.5 11l.5 5m4.5-5l-.5 5"/></svg>

After

Width:  |  Height:  |  Size: 394 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 15V2m0 0l3 3.5M12 2L9 5.5"/></g></svg>

After

Width:  |  Height:  |  Size: 488 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2 12.5v-1c0-3.287 0-4.931.908-6.038a4 4 0 0 1 .554-.554C4.57 4 6.212 4 9.5 4c3.287 0 4.931 0 6.038.908a4 4 0 0 1 .554.554c.702.855.861 2.031.897 4.038l.67-.33c1.945-.972 2.918-1.459 3.63-1.019S22 9.68 22 11.854v.292c0 2.175 0 3.263-.711 3.703c-.712.44-1.685-.047-3.63-1.02l-.67-.329c-.036 2.007-.195 3.183-.897 4.038a4 4 0 0 1-.554.554C14.43 20 12.788 20 9.5 20c-3.287 0-4.931 0-6.038-.908a4 4 0 0 1-.554-.554C2 17.43 2 15.788 2 12.5m11.56-2.94a1.5 1.5 0 1 0-2.121-2.12a1.5 1.5 0 0 0 2.122 2.12" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 653 B

97
src/ui/menu.rs Normal file
View file

@ -0,0 +1,97 @@
//! Нативное меню в верхней панели экрана (только macOS).
//! На остальных платформах кнопка настроек живёт в тулбаре — см. panels/toolbar.rs.
use crate::ui::state::AppState;
#[cfg(target_os = "macos")]
mod ids {
pub const SETTINGS: &str = "loflum-settings";
pub const NEW_CONNECTION: &str = "loflum-new-connection";
}
/// Строит нативное меню macOS: App-меню (с "Settings…"), File, Edit, Window.
/// Вызывать один раз при старте, на главном потоке, уже после инициализации NSApplication.
#[cfg(target_os = "macos")]
pub fn setup_native_menu() {
use muda::{
Menu, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, CMD_OR_CTRL, Code},
};
let menu = Menu::new();
let app_menu = Submenu::new("LoFlum", true);
let _ = app_menu.append_items(&[
&PredefinedMenuItem::about(Some("About LoFlum"), None),
&PredefinedMenuItem::separator(),
&muda::MenuItem::with_id(
ids::SETTINGS,
"Settings…",
true,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)),
),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
]);
let file_menu = Submenu::new("File", true);
let _ = file_menu.append_items(&[
&muda::MenuItem::with_id(
ids::NEW_CONNECTION,
"New Connection…",
true,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)),
),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None),
]);
let edit_menu = Submenu::new("Edit", true);
let _ = edit_menu.append_items(&[
&PredefinedMenuItem::undo(None),
&PredefinedMenuItem::redo(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::cut(None),
&PredefinedMenuItem::copy(None),
&PredefinedMenuItem::paste(None),
&PredefinedMenuItem::select_all(None),
]);
let window_menu = Submenu::new("Window", true);
let _ = window_menu.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
]);
let _ = menu.append_items(&[&app_menu, &file_menu, &edit_menu, &window_menu]);
menu.init_for_nsapp();
// Держим меню живым на весь процесс — NSApp хранит на него ссылку,
// но Rust-сторона (Rc внутри muda) не должна быть уничтожена раньше.
Box::leak(Box::new(menu));
}
#[cfg(not(target_os = "macos"))]
pub fn setup_native_menu() {}
/// Забирает события из нативного меню (клики по кастомным пунктам) и применяет к состоянию.
/// На платформах без нативного меню — no-op.
#[cfg(target_os = "macos")]
pub fn poll_menu_events(state: &mut AppState) {
while let Ok(event) = muda::MenuEvent::receiver().try_recv() {
let id = event.id().0.as_str();
if id == ids::SETTINGS {
state.show_settings_dialog = true;
} else if id == ids::NEW_CONNECTION {
state.show_connect_dialog = true;
}
}
}
#[cfg(not(target_os = "macos"))]
pub fn poll_menu_events(_state: &mut AppState) {}

View file

@ -1,6 +1,8 @@
pub mod app; pub mod app;
pub mod dialogs; pub mod dialogs;
pub mod drag; pub mod drag;
pub mod icons;
pub mod menu;
pub mod panels; pub mod panels;
pub mod state; pub mod state;
pub mod theme; pub mod theme;

View file

@ -1,6 +1,7 @@
//! Переиспользуемая таблица файлов с нормальными колонками //! Переиспользуемая таблица файлов с нормальными колонками
use crate::domain::file_entry::{EntryKind, FileEntry}; use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::ui::drag::DragPayload; use crate::ui::drag::DragPayload;
use crate::ui::icons::{self, Icon, file_icon_for};
use crate::ui::theme::*; use crate::ui::theme::*;
use egui::{Color32, Id, RichText, Ui}; use egui::{Color32, Id, RichText, Ui};
@ -35,20 +36,56 @@ pub enum SortDir {
Desc, Desc,
} }
/// Действие, запрошенное через контекстное меню (ПКМ) на строке.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RowAction {
Open,
Rename,
Delete,
Bookmark,
CopyPath,
/// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона.
Transfer,
}
pub struct FileTableResponse { pub struct FileTableResponse {
pub double_clicked: Option<String>, pub double_clicked: Option<String>,
pub clicked: Option<String>, pub clicked: Option<String>,
/// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили).
pub dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)>,
/// Действие из контекстного меню (ПКМ) — (строка, действие).
pub context_action: Option<(FileEntry, RowAction)>,
} }
pub fn file_table<F>( pub fn file_table<F, C>(
ui: &mut Ui, ui: &mut Ui,
id: &str, id: &str,
entries: &[FileEntry], entries: &[FileEntry],
selected: &mut Option<String>, selected: &mut Option<String>,
make_drag_payload: F, make_drag_payload: F,
context_menu: C,
) -> FileTableResponse ) -> FileTableResponse
where where
F: Fn(&FileEntry) -> Option<DragPayload>, F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
ui.push_id(id, |ui| {
file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu)
})
.inner
}
fn file_table_inner<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
mut context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{ {
// Сортировка — читаем из egui memory // Сортировка — читаем из egui memory
let sort_id = Id::new((id, "sort")); let sort_id = Id::new((id, "sort"));
@ -92,20 +129,20 @@ where
let mut double_clicked: Option<String> = None; let mut double_clicked: Option<String> = None;
let mut clicked: Option<String> = None; let mut clicked: Option<String> = None;
let mut new_sort: Option<(SortCol, SortDir)> = None; let mut new_sort: Option<(SortCol, SortDir)> = None;
let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)> = None;
let mut context_action: Option<(FileEntry, RowAction)> = None;
// Заголовки колонок // Заголовки колонок
let header_frame = egui::Frame::none() let header_frame = egui::Frame::none()
.fill(BG_HEADER) .fill(BG_HEADER)
.inner_margin(egui::Margin { .inner_margin(egui::Margin::symmetric(12.0, 0.0));
left: 6.0,
right: 6.0,
top: 0.0,
bottom: 0.0,
});
header_frame.show(ui, |ui| { header_frame.show(ui, |ui| {
ui.set_min_height(22.0); let width = ui.available_width();
ui.horizontal(|ui| { ui.allocate_ui_with_layout(
egui::vec2(width, 24.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir); let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir);
if name_r { if name_r {
new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir)); new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir));
@ -133,7 +170,8 @@ where
new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir)); new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir));
} }
}); });
}); },
);
}); });
if let Some(s) = new_sort { if let Some(s) = new_sort {
@ -148,65 +186,93 @@ where
// Список файлов // Список файлов
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt((id, "scroll"))
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
for entry in &sorted { for entry in &sorted {
let is_selected = selected.as_deref() == Some(&entry.name); let is_selected = selected.as_deref() == Some(&entry.name);
let row_id = Id::new((id, "row", &entry.name)); let row_id = Id::new((id, "row", &entry.name));
// Строка, которую сейчас тащат — её содержимое рисуется в
// плавающем слое поверх курсора (см. dnd_drag_source), поэтому
// сама она не может быть целью для дропа, а на исходном месте
// не должно оставаться подсветки выбора/ховера — там пусто.
let is_being_dragged = ui.ctx().is_being_dragged(row_id);
let row_rect = egui::Rect::from_min_size( let row_rect = egui::Rect::from_min_size(
ui.cursor().min, ui.cursor().min,
egui::vec2(ui.available_width(), ROW_H), egui::vec2(ui.available_width(), ROW_H),
); );
let is_hovered = ui.rect_contains_pointer(row_rect); let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect);
let is_drop_target = !is_being_dragged
&& entry.kind == EntryKind::Dir
&& egui::DragAndDrop::has_payload_of_type::<DragPayload>(ui.ctx());
let is_drop_hover = is_drop_target && is_hovered;
let bg = if is_selected { let bg = if is_being_dragged {
Color32::TRANSPARENT
} else if is_drop_hover {
ACCENT_DIM
} else if is_selected {
BG_ROW_SEL BG_ROW_SEL
} else if is_hovered { } else if is_hovered {
BG_ROW_HOVER BG_ROW_HOVER
} else { } else {
Color32::TRANSPARENT Color32::TRANSPARENT
}; };
let stroke = if is_drop_hover {
egui::Stroke::new(1.5, ACCENT)
} else {
egui::Stroke::NONE
};
let row_width = ui.available_width();
let response = egui::Frame::none() let response = egui::Frame::none()
.fill(bg) .fill(bg)
.inner_margin(egui::Margin { .stroke(stroke)
left: 6.0, .rounding(RADIUS_SM)
right: 6.0, .inner_margin(egui::Margin::symmetric(12.0, 0.0))
top: 0.0,
bottom: 0.0,
})
.show(ui, |ui| { .show(ui, |ui| {
ui.set_min_height(ROW_H); ui.allocate_ui_with_layout(
ui.horizontal_centered(|ui| { egui::vec2(row_width - 24.0, ROW_H),
// иконка + имя egui::Layout::left_to_right(egui::Align::Center),
let name_text = if entry.name == ".." { |ui| {
"⬆ ..".to_string() // иконка (цвет по типу) + имя (единый цвет, как в спеке)
let (icon, icon_col) = if entry.name == ".." {
(Icon::ArrowUp, TEXT_HINT)
} else { } else {
match entry.kind { match entry.kind {
EntryKind::Dir => format!("📁 {}", entry.name), EntryKind::Dir => (Icon::Folder, YELLOW),
EntryKind::Symlink => format!("🔗 {}", entry.name), EntryKind::Symlink => (Icon::Link, YELLOW),
EntryKind::File => { EntryKind::File => {
let icon = file_icon(&entry.name); let ext = entry
format!("{} {}", icon, entry.name) .name
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
file_icon_for(&ext)
} }
} }
}; };
let name_col = if entry.name == ".." { let name_col = if entry.name == ".." {
TEXT_HINT TEXT_HINT
} else if entry.kind == EntryKind::Dir {
ACCENT
} else { } else {
TEXT_PRIMARY TEXT_PRIMARY
}; };
let draw_row = |ui: &mut Ui| {
icons::icon(ui, icon, 14.0, icon_col);
ui.add_space(6.0);
ui.label(RichText::new(&entry.name).color(name_col).size(12.5));
};
if let Some(payload) = make_drag_payload(entry) { if let Some(payload) = make_drag_payload(entry) {
ui.dnd_drag_source(row_id, payload, |ui| { ui.dnd_drag_source(row_id, payload, |ui| {
ui.label(RichText::new(&name_text).color(name_col).size(12.0)); ui.horizontal(draw_row);
}); });
} else { } else {
ui.label(RichText::new(&name_text).color(name_col).size(12.0)); draw_row(ui);
} }
// правые колонки // правые колонки
@ -215,8 +281,9 @@ where
|ui| { |ui| {
ui.label( ui.label(
RichText::new(format_time(entry.modified)) RichText::new(format_time(entry.modified))
.color(TEXT_DIM) .color(TEXT_HINT)
.size(11.0), .monospace()
.size(10.5),
); );
ui.add_space(6.0); ui.add_space(6.0);
@ -234,15 +301,26 @@ where
ui.label( ui.label(
RichText::new(format_size(entry.size)) RichText::new(format_size(entry.size))
.color(TEXT_DIM) .color(TEXT_DIM)
.monospace()
.size(11.0), .size(11.0),
); );
ui.add_space(4.0); ui.add_space(4.0);
}, },
); );
}); },
);
}) })
.response; .response;
// Frame::show() only senses hover, not clicks — add a real click
// sense on the full row rect (separate id from the drag source,
// which only senses drag over the icon+name sub-area). Without
// this, every press was interpreted as a drag start and plain
// clicks/double-clicks never fired.
let click_response =
ui.interact(response.rect, row_id.with("click"), egui::Sense::click());
let response = response | click_response;
if response.clicked() { if response.clicked() {
*selected = Some(entry.name.clone()); *selected = Some(entry.name.clone());
clicked = Some(entry.name.clone()); clicked = Some(entry.name.clone());
@ -250,15 +328,48 @@ where
if response.double_clicked() { if response.double_clicked() {
double_clicked = Some(entry.name.clone()); double_clicked = Some(entry.name.clone());
} }
if is_drop_target
&& let Some(payload) = response.dnd_release_payload::<DragPayload>()
{
dropped_on_dir = Some((entry.clone(), payload));
}
if entry.name != ".." {
// Правый клик выбирает строку — иначе меню открывается
// над предыдущим выделением, что сбивает с толку.
if response.secondary_clicked() {
*selected = Some(entry.name.clone());
}
response.context_menu(|ui| {
if let Some(action) = context_menu(ui, entry) {
context_action = Some((entry.clone(), action));
ui.close_menu();
}
});
}
} }
}); });
FileTableResponse { FileTableResponse {
double_clicked, double_clicked,
clicked, clicked,
dropped_on_dir,
context_action,
} }
} }
/// Единообразная кнопка-пункт для контекстных меню (ПКМ на строке).
pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool {
let color = if danger { RED } else { TEXT_PRIMARY };
let btn = egui::Button::image_and_text(
icons::image(icon, 13.0, color),
RichText::new(label).size(12.0).color(color),
)
.fill(Color32::TRANSPARENT)
.min_size(egui::vec2(170.0, 26.0));
ui.add(btn).clicked()
}
fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir) -> bool { fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir) -> bool {
let arrow = if active { let arrow = if active {
if dir == SortDir::Asc { " ↑" } else { " ↓" } if dir == SortDir::Asc { " ↑" } else { " ↓" }
@ -267,7 +378,7 @@ fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir)
}; };
let full = format!("{}{}", label, arrow); let full = format!("{}{}", label, arrow);
let col = if active { ACCENT } else { TEXT_DIM }; let col = if active { ACCENT } else { TEXT_DIM };
let btn = egui::Button::new(RichText::new(full).color(col).size(11.0).strong()) let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong())
.fill(Color32::TRANSPARENT) .fill(Color32::TRANSPARENT)
.min_size(egui::vec2(min_w, 20.0)); .min_size(egui::vec2(min_w, 20.0));
ui.add(btn).clicked() ui.add(btn).clicked()
@ -285,22 +396,3 @@ fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, So
(col, SortDir::Asc) (col, SortDir::Asc)
} }
} }
fn file_icon(name: &str) -> &'static str {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"rs" => "🦀",
"toml" | "yaml" | "yml" | "json" => "⚙",
"md" | "txt" | "log" => "📝",
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "🖼",
"mp4" | "mkv" | "avi" | "mov" => "🎬",
"mp3" | "ogg" | "flac" | "wav" => "🎵",
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => "📦",
"pdf" => "📕",
"sh" | "bash" | "fish" | "zsh" => "⚡",
"html" | "css" | "js" | "ts" => "🌐",
"py" => "🐍",
"java" | "class" | "jar" => "☕",
_ => "📄",
}
}

View file

@ -5,11 +5,14 @@ use crate::fs::local;
use crate::fs::remote::RemoteRegistry; use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue; use crate::transfer::queue::TransferQueue;
use crate::ui::drag::{DragPayload, make_local_payload}; use crate::ui::drag::{DragPayload, make_local_payload};
use crate::ui::panels::file_pane::{FileTableResponse, file_table, format_size}; use crate::ui::icons::{self, Icon};
use crate::ui::state::AppState; use crate::ui::panels::file_pane::{
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
};
use crate::ui::state::{AppState, Pane};
use crate::ui::theme::*; use crate::ui::theme::*;
use egui::{RichText, Ui}; use egui::{RichText, Ui};
use std::sync::Arc; use std::sync::{Arc, Mutex};
fn parent_path(path: &str) -> String { fn parent_path(path: &str) -> String {
std::path::Path::new(path) std::path::Path::new(path)
@ -35,21 +38,35 @@ pub fn render(
queue: &TransferQueue, queue: &TransferQueue,
registry: &Arc<RemoteRegistry>, registry: &Arc<RemoteRegistry>,
_rt_handle: &tokio::runtime::Handle, _rt_handle: &tokio::runtime::Handle,
db: &Arc<Mutex<rusqlite::Connection>>,
) { ) {
let frame = egui::Frame::none() let frame = egui::Frame::none()
.fill(BG_CONTENT) .fill(BG_CONTENT)
.inner_margin(egui::Margin::same(0.0)); .inner_margin(egui::Margin::same(0.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| { let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
render_inner(ui, state); render_inner(ui, state, queue, registry, db);
}); });
if let Some(payload_arc) = dropped { if let Some(payload_arc) = dropped {
match &*payload_arc { let dest_dir = state.local_path.clone();
handle_drop(state, queue, registry, &payload_arc, &dest_dir);
}
}
/// Обрабатывает бросок файла в текущую директорию (dest_dir) — общий код
/// для дропа на весь пейн и дропа на конкретную папку в списке.
fn handle_drop(
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
payload: &DragPayload,
dest_dir: &str,
) {
match payload {
DragPayload::RemoteFile(remote_path, file_name, conn_id) => { DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
if registry.get(conn_id).is_some() { if registry.get(conn_id).is_some() {
let local_path_str = let local_path_str = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
format!("{}/{}", state.local_path.trim_end_matches('/'), file_name);
let task = TransferTask::new( let task = TransferTask::new(
TransferKind::Download, TransferKind::Download,
conn_id.clone(), conn_id.clone(),
@ -64,14 +81,32 @@ pub fn render(
state.status_message = "Connection no longer active".into(); state.status_message = "Connection no longer active".into();
} }
} }
DragPayload::LocalFile(_, _) => { DragPayload::LocalFile(src_path, file_name) => {
state.status_message = "Cannot drop local onto local".into(); if std::path::Path::new(dest_dir)
== std::path::Path::new(src_path)
.parent()
.unwrap_or(std::path::Path::new(""))
{
return; // бросили туда же, откуда взяли
}
match local::move_into(src_path, dest_dir) {
Ok(()) => {
state.status_message = format!("Moved {}", file_name);
refresh_local(state);
}
Err(e) => state.status_message = format!("Move failed: {}", e),
} }
} }
} }
} }
fn render_inner(ui: &mut Ui, state: &mut AppState) { fn render_inner(
ui: &mut Ui,
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
// Хлебные крошки + путь // Хлебные крошки + путь
render_path_bar(ui, state); render_path_bar(ui, state);
@ -88,57 +123,194 @@ fn render_inner(ui: &mut Ui, state: &mut AppState) {
.map(|t| t.status == ConnectionStatus::Connected) .map(|t| t.status == ConnectionStatus::Connected)
.unwrap_or(false); .unwrap_or(false);
let FileTableResponse { double_clicked, .. } = let FileTableResponse {
file_table(ui, "local_table", &entries, &mut selected, |entry| { double_clicked,
clicked,
dropped_on_dir,
context_action,
} = file_table(
ui,
"local_table",
&entries,
&mut selected,
|entry| {
if has_connection { if has_connection {
make_local_payload(entry) make_local_payload(entry)
} else { } else {
None None
} }
}); },
|ui, entry| build_context_menu(ui, entry, has_connection),
);
state.local_selected = selected; state.local_selected = selected;
if clicked.is_some() {
state.active_pane = Pane::Local;
}
if let Some((target_entry, payload)) = dropped_on_dir {
handle_drop(state, queue, registry, &payload, &target_entry.path);
}
if let Some(name) = double_clicked { if let Some(name) = double_clicked {
if name == ".." { open_entry(state, &name);
state.local_path = parent_path(&state.local_path);
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name)
&& entry.kind == EntryKind::Dir
{
state.local_path = entry.path.clone();
} }
refresh_local(state);
if let Some((entry, action)) = context_action {
handle_row_action(ui.ctx(), state, queue, db, &entry, action);
} }
// Нижняя полоска // Нижняя полоска
render_footer(ui, state); render_footer(ui, state, db);
}
fn build_context_menu(ui: &mut Ui, entry: &FileEntry, has_connection: bool) -> Option<RowAction> {
if context_menu_item(ui, Icon::Folder, "Open", false) {
return Some(RowAction::Open);
}
if entry.name != ".." {
if context_menu_item(ui, Icon::Pen, "Rename", false) {
return Some(RowAction::Rename);
}
if context_menu_item(ui, Icon::Trash, "Delete", true) {
return Some(RowAction::Delete);
}
ui.separator();
if entry.kind == EntryKind::Dir
&& context_menu_item(ui, Icon::Star, "Add to Bookmarks", false)
{
return Some(RowAction::Bookmark);
}
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
return Some(RowAction::CopyPath);
}
if entry.kind == EntryKind::File && has_connection {
ui.separator();
if context_menu_item(ui, Icon::Upload, "Upload to Server", false) {
return Some(RowAction::Transfer);
}
}
}
None
}
fn open_entry(state: &mut AppState, name: &str) {
if name == ".." {
state.local_path = parent_path(&state.local_path);
refresh_local(state);
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) {
if entry.kind == EntryKind::Dir {
state.local_path = entry.path.clone();
refresh_local(state);
} else {
match local::open(&entry.path) {
Ok(()) => state.status_message = format!("Opening {}", entry.name),
Err(e) => state.status_message = format!("Open failed: {}", e),
}
}
}
}
fn handle_row_action(
ctx: &egui::Context,
state: &mut AppState,
queue: &TransferQueue,
db: &Arc<Mutex<rusqlite::Connection>>,
entry: &FileEntry,
action: RowAction,
) {
match action {
RowAction::Open => open_entry(state, &entry.name),
RowAction::Rename => {
state.op_target = Pane::Local;
state.show_rename_dialog = true;
state.rename_old_name = entry.name.clone();
state.rename_new_name = entry.name.clone();
}
RowAction::Delete => {
state.op_target = Pane::Local;
state.show_delete_dialog = true;
state.delete_name = entry.name.clone();
}
RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path),
RowAction::CopyPath => {
ctx.copy_text(entry.path.clone());
state.status_message = "Path copied".into();
}
RowAction::Transfer => {
if let Some(tab) = state.active_tab_ref() {
let connection_id = tab.id.clone();
let remote_path =
format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id,
entry.path.clone(),
remote_path,
entry.name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Upload queued: {}", entry.name);
}
}
}
}
fn add_bookmark(
state: &mut AppState,
db: &Arc<Mutex<rusqlite::Connection>>,
name: &str,
path: &str,
) {
if state.bookmarks.iter().any(|b| b.path == path) {
state.status_message = "Already bookmarked".into();
return;
}
let Ok(conn) = db.lock() else {
return;
};
match crate::storage::db::add_bookmark(&conn, name, path) {
Ok(id) => {
state.bookmarks.push(crate::ui::state::Bookmark {
id,
name: name.to_string(),
path: path.to_string(),
});
state.status_message = "Bookmark added".into();
}
Err(e) => state.status_message = format!("Bookmark failed: {}", e),
}
} }
fn render_path_bar(ui: &mut Ui, state: &mut AppState) { fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
let frame = egui::Frame::none() let frame = egui::Frame::none()
.fill(BG_PANEL) .fill(BG_BASE)
.inner_margin(egui::Margin { .inner_margin(egui::Margin::symmetric(12.0, 0.0));
left: 8.0,
right: 8.0,
top: 4.0,
bottom: 4.0,
});
frame.show(ui, |ui| { frame.show(ui, |ui| {
ui.horizontal(|ui| { let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, 30.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.label(RichText::new("LOCAL").color(TEXT_DIM).size(10.5).strong());
ui.add_space(10.0);
// иконка home // иконка home
let home = dirs::home_dir() let home = dirs::home_dir()
.unwrap_or_default() .unwrap_or_default()
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let is_home = state.local_path == home; let is_home = state.local_path == home;
let home_btn = egui::Button::new(RichText::new("🏠").size(13.0)) let home_col = if is_home { ACCENT } else { TEXT_DIM };
let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col))
.fill(if is_home { .fill(if is_home {
BG_ROW_SEL BG_ROW_SEL
} else { } else {
egui::Color32::TRANSPARENT egui::Color32::TRANSPARENT
}) })
.rounding(4.0) .rounding(RADIUS_SM)
.min_size(egui::vec2(22.0, 22.0)); .min_size(egui::vec2(22.0, 22.0));
if ui.add(home_btn).clicked() { if ui.add(home_btn).clicked() {
state.local_path = home; state.local_path = home;
@ -161,7 +333,8 @@ fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
} else { } else {
let path_snap = acc.clone(); let path_snap = acc.clone();
let link = egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) let link =
egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
.sense(egui::Sense::click()); .sense(egui::Sense::click());
if ui.add(link).clicked() { if ui.add(link).clicked() {
state.local_path = path_snap; state.local_path = path_snap;
@ -170,11 +343,12 @@ fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
} }
} }
}); },
);
}); });
} }
fn render_footer(ui: &mut Ui, state: &mut AppState) { fn render_footer(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connection>>) {
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER); ui.painter().rect_filled(sep, 0.0, BORDER);
@ -207,7 +381,7 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// кнопка добавить закладку // кнопка добавить закладку
let star = egui::Button::new(RichText::new("★").color(YELLOW).size(12.0)) let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW))
.fill(egui::Color32::TRANSPARENT) .fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(20.0, 20.0)); .min_size(egui::vec2(20.0, 20.0));
if ui.add(star).on_hover_text("Bookmark this folder").clicked() { if ui.add(star).on_hover_text("Bookmark this folder").clicked() {
@ -215,11 +389,8 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) {
.file_name() .file_name()
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Bookmark".into()); .unwrap_or_else(|| "Bookmark".into());
state.bookmarks.push(crate::ui::state::Bookmark { let path = state.local_path.clone();
name, add_bookmark(state, db, &name, &path);
path: state.local_path.clone(),
});
state.status_message = "Bookmark added".into();
} }
}); });
}); });

View file

@ -1,5 +1,6 @@
use crate::domain::transfer::TransferTask; use crate::domain::transfer::TransferTask;
use crate::domain::transfer::{TaskState, TransferKind}; use crate::domain::transfer::{TaskState, TransferKind};
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::format_size; use crate::ui::panels::file_pane::format_size;
use crate::ui::state::AppState; use crate::ui::state::AppState;
use crate::ui::theme::*; use crate::ui::theme::*;
@ -9,27 +10,35 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
// Заголовок очереди (всегда виден) // Заголовок очереди (всегда виден)
let header_frame = egui::Frame::none() let header_frame = egui::Frame::none()
.fill(BG_QUEUE) .fill(BG_QUEUE)
.inner_margin(egui::Margin::symmetric(8.0, 0.0)); .inner_margin(egui::Margin::symmetric(12.0, 0.0));
header_frame.show(ui, |ui| { header_frame.show(ui, |ui| {
ui.set_min_height(QUEUE_COLLAPSED_H); let width = ui.available_width();
ui.horizontal_centered(|ui| { ui.allocate_ui_with_layout(
let arrow = if state.show_queue { "▼" } else { "▲" }; egui::vec2(width, QUEUE_COLLAPSED_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let chevron = if state.show_queue {
Icon::ArrowDown
} else {
Icon::ArrowUp
};
let n_active = tasks let n_active = tasks
.iter() .iter()
.filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued)) .filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued))
.count(); .count();
let title = if n_active > 0 { let title = if n_active > 0 {
format!("{} Transfer Queue ({})", arrow, n_active) format!("Transfer Queue ({})", n_active)
} else { } else {
format!("{} Transfer Queue ({})", arrow, tasks.len()) format!("Transfer Queue ({})", tasks.len())
}; };
let btn = egui::Button::new( let btn = egui::Button::image_and_text(
icons::image(chevron, 13.0, TEXT_DIM),
RichText::new(&title) RichText::new(&title)
.color(TEXT_PRIMARY) .color(TEXT_PRIMARY)
.size(12.0) .size(11.5)
.strong(), .strong(),
) )
.fill(Color32::TRANSPARENT) .fill(Color32::TRANSPARENT)
@ -77,7 +86,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
} }
} }
}); });
}); },
);
}); });
if !state.show_queue || tasks.is_empty() { if !state.show_queue || tasks.is_empty() {
@ -91,113 +101,81 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.max_height(QUEUE_EXPANDED_H - QUEUE_COLLAPSED_H - 4.0) .max_height(QUEUE_EXPANDED_H - QUEUE_COLLAPSED_H - 4.0)
.show(ui, |ui| { .show(ui, |ui| {
for task in tasks { ui.add_space(2.0);
render_task(ui, task); for (i, task) in tasks.iter().enumerate() {
if i > 0 {
ui.add_space(6.0);
}
egui::Frame::none()
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
.show(ui, |ui| render_task(ui, task));
} }
}); });
} }
fn render_task(ui: &mut Ui, task: &TransferTask) { fn render_task(ui: &mut Ui, task: &TransferTask) {
let bg = match &task.state { // Плоская однострочная карточка, как в макете — без цветной подложки
TaskState::Completed => Color32::from_rgb(22, 38, 24),
TaskState::Failed(_) => Color32::from_rgb(38, 22, 22),
TaskState::Running => Color32::from_rgb(22, 28, 42),
TaskState::Queued => BG_QUEUE,
_ => BG_QUEUE,
};
egui::Frame::none()
.fill(bg)
.inner_margin(egui::Margin {
left: 10.0,
right: 10.0,
top: 4.0,
bottom: 4.0,
})
.show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
// стрелка вида ui.set_min_height(34.0);
let (arrow, arrow_col) = match task.kind {
TransferKind::Upload => ("↑", ACCENT),
TransferKind::Download => ("↓", GREEN),
};
ui.label(RichText::new(arrow).color(arrow_col).size(13.0).strong());
ui.add_space(6.0);
// имя файла let (arrow, arrow_col) = match task.kind {
ui.label( TransferKind::Upload => (Icon::Upload, ACCENT),
TransferKind::Download => (Icon::Download, GREEN),
};
icons::icon(ui, arrow, 15.0, arrow_col);
ui.add_space(10.0);
// имя файла — фиксированная ширина, как в спеке
let name_label = egui::Label::new(
RichText::new(&task.file_name) RichText::new(&task.file_name)
.color(TEXT_PRIMARY) .color(TEXT_PRIMARY)
.size(12.0), .size(12.0),
); )
.truncate();
let name_resp = ui.add_sized([150.0, 18.0], name_label);
if let TaskState::Failed(err) = &task.state {
name_resp.on_hover_text(err);
}
ui.add_space(10.0);
// статус справа
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (state_str, state_col) = match &task.state {
TaskState::Queued => ("Queued", TEXT_HINT),
TaskState::Running => ("Uploading", ACCENT),
TaskState::Completed => ("✓ Done", GREEN),
TaskState::Failed(_) => ("× Failed", RED),
TaskState::Paused => ("Paused", YELLOW),
TaskState::Cancelled => ("Cancelled", TEXT_HINT),
TaskState::Retrying(_) => ("Retrying", YELLOW),
};
ui.label(RichText::new(state_str).color(state_col).size(11.0));
});
});
// прогресс
ui.horizontal(|ui| {
let pct = task.progress_pct() as f32 / 100.0; let pct = task.progress_pct() as f32 / 100.0;
let bar_col = match &task.state { let bar_col = match &task.state {
TaskState::Completed => GREEN, TaskState::Completed => GREEN,
TaskState::Failed(_) => RED, TaskState::Failed(_) => RED,
TaskState::Paused => TEXT_HINT,
_ => ACCENT, _ => ACCENT,
}; };
let bar = egui::ProgressBar::new(pct) let bar = egui::ProgressBar::new(pct)
.text(format!("{:.0}%", task.progress_pct())) .desired_width(ui.available_width() - 130.0)
.desired_width(ui.available_width() * 0.55)
.fill(bar_col); .fill(bar_col);
ui.add(bar); ui.add(bar);
ui.add_space(10.0);
ui.label( ui.label(
RichText::new(format!( RichText::new(format!("{:.0}%", task.progress_pct()))
" {}/{}",
format_size(Some(task.transferred_bytes)),
format_size(Some(task.total_bytes))
))
.color(TEXT_DIM) .color(TEXT_DIM)
.monospace()
.size(11.0), .size(11.0),
); );
if let Some(speed) = task.speed ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
&& speed > 0 let (state_str, state_col, state_icon) = match &task.state {
{ TaskState::Queued => ("Queued".to_string(), TEXT_HINT, None),
ui.label( TaskState::Running => match task.speed {
RichText::new(format!(" {}/s", format_size(Some(speed)))) Some(s) if s > 0 => (format!("{}/s", format_size(Some(s))), GREEN, None),
.color(GREEN) _ => ("Uploading".to_string(), ACCENT, None),
.size(11.0), },
); TaskState::Completed => ("Complete".to_string(), GREEN, Some(Icon::CheckCircle)),
} TaskState::Failed(_) => ("Failed".to_string(), RED, None),
if let Some(eta) = task.eta_secs TaskState::Paused => ("Paused".to_string(), YELLOW, Some(Icon::PlayCircle)),
&& eta > 0 TaskState::Cancelled => ("Cancelled".to_string(), TEXT_HINT, None),
{ TaskState::Retrying(_) => ("Retrying".to_string(), YELLOW, None),
ui.label( };
RichText::new(format!(" ETA {}s", eta)) if let Some(ic) = state_icon {
.color(TEXT_HINT) icons::icon(ui, ic, 13.0, state_col);
.size(10.0), ui.add_space(4.0);
);
} }
ui.label(RichText::new(state_str).color(state_col).size(10.5));
}); });
// ошибка если есть
if let TaskState::Failed(err) = &task.state {
ui.label(RichText::new(err).color(RED).size(10.0));
}
}); });
// разделитель
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, SEPARATOR);
} }

View file

@ -7,8 +7,11 @@ use crate::domain::transfer::{TransferKind, TransferTask};
use crate::fs::remote::RemoteRegistry; use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue; use crate::transfer::queue::TransferQueue;
use crate::ui::drag::{DragPayload, make_remote_payload}; use crate::ui::drag::{DragPayload, make_remote_payload};
use crate::ui::panels::file_pane::{FileTableResponse, file_table, format_size}; use crate::ui::icons::{self, Icon};
use crate::ui::state::{AppState, PendingRemoteList}; use crate::ui::panels::file_pane::{
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
};
use crate::ui::state::{AppState, Pane, PendingRemoteList};
use crate::ui::theme::*; use crate::ui::theme::*;
pub fn remote_parent(path: &str) -> String { pub fn remote_parent(path: &str) -> String {
@ -41,18 +44,42 @@ pub fn render(
.inner_margin(egui::Margin::same(0.0)); .inner_margin(egui::Margin::same(0.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| { let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
render_content(ui, state, tab_idx, registry, rt_handle); render_content(ui, state, tab_idx, registry, rt_handle, queue);
}); });
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
let connection_id = state.tabs[tab_idx].id.clone();
let remote_path = state.tabs[tab_idx].remote_path.clone(); let remote_path = state.tabs[tab_idx].remote_path.clone();
if let Some(payload_arc) = dropped { if let Some(payload_arc) = dropped {
match &*payload_arc { handle_drop(
state,
tab_idx,
queue,
registry,
rt_handle,
&payload_arc,
&remote_path,
);
}
}
/// Обрабатывает бросок файла в директорию dest_dir на удалённой стороне —
/// общий код для дропа на весь пейн и дропа на конкретную папку в списке.
fn handle_drop(
state: &mut AppState,
tab_idx: usize,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
payload: &DragPayload,
dest_dir: &str,
) {
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
let connection_id = state.tabs[tab_idx].id.clone();
match payload {
DragPayload::LocalFile(local_path, file_name) => { DragPayload::LocalFile(local_path, file_name) => {
if is_connected { if is_connected {
let dest = format!("{}/{}", remote_path.trim_end_matches('/'), file_name); let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
let task = TransferTask::new( let task = TransferTask::new(
TransferKind::Upload, TransferKind::Upload,
connection_id, connection_id,
@ -67,9 +94,33 @@ pub fn render(
state.status_message = "No active connection".into(); state.status_message = "No active connection".into();
} }
} }
DragPayload::RemoteFile(_, _, _) => { DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
state.status_message = "Cannot drop remote onto remote".into(); if *conn_id != connection_id {
state.status_message = "Cannot move between different connections".into();
return;
} }
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
if dest == *remote_path {
return;
}
let registry = registry.clone();
let from = remote_path.clone();
let conn_id_clone = conn_id.clone();
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
rt_handle.spawn(async move {
let fs = match registry.get(&conn_id_clone) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
state.pending_rename_result = Some(result);
state.status_message = format!("Moving {}…", file_name);
} }
} }
} }
@ -80,6 +131,7 @@ fn render_content(
tab_idx: usize, tab_idx: usize,
registry: &Arc<RemoteRegistry>, registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle, rt_handle: &tokio::runtime::Handle,
queue: &TransferQueue,
) { ) {
// Path bar // Path bar
render_path_bar(ui, state, tab_idx, registry, rt_handle); render_path_bar(ui, state, tab_idx, registry, rt_handle);
@ -109,18 +161,97 @@ fn render_content(
let conn_id = state.tabs[tab_idx].id.clone(); let conn_id = state.tabs[tab_idx].id.clone();
let table_id = format!("remote_table_{}", tab_idx); let table_id = format!("remote_table_{}", tab_idx);
let FileTableResponse { double_clicked, .. } = let conn_id_for_drag = conn_id.clone();
file_table(ui, &table_id, &entries, &mut selected, move |entry| { let FileTableResponse {
double_clicked,
clicked,
dropped_on_dir,
context_action,
} = file_table(
ui,
&table_id,
&entries,
&mut selected,
move |entry| {
if is_connected { if is_connected {
make_remote_payload(entry, &conn_id) make_remote_payload(entry, &conn_id_for_drag)
} else { } else {
None None
} }
}); },
build_context_menu,
);
state.tabs[tab_idx].remote_selected = selected; state.tabs[tab_idx].remote_selected = selected;
if clicked.is_some() {
state.active_pane = Pane::Remote;
}
if let Some((target_entry, payload)) = dropped_on_dir {
handle_drop(
state,
tab_idx,
queue,
registry,
rt_handle,
&payload,
&target_entry.path,
);
}
if let Some(name) = double_clicked { if let Some(name) = double_clicked {
open_entry(state, tab_idx, registry, rt_handle, queue, &name);
}
if let Some((entry, action)) = context_action {
handle_row_action(
ui.ctx(),
state,
tab_idx,
registry,
rt_handle,
queue,
&entry,
action,
);
}
// Footer
render_footer(ui, state, tab_idx, registry, rt_handle);
}
fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option<RowAction> {
if context_menu_item(ui, Icon::Folder, "Open", false) {
return Some(RowAction::Open);
}
if entry.name != ".." {
if context_menu_item(ui, Icon::Pen, "Rename", false) {
return Some(RowAction::Rename);
}
if context_menu_item(ui, Icon::Trash, "Delete", true) {
return Some(RowAction::Delete);
}
ui.separator();
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
return Some(RowAction::CopyPath);
}
if entry.kind == EntryKind::File
&& context_menu_item(ui, Icon::Download, "Download to Local", false)
{
return Some(RowAction::Transfer);
}
}
None
}
fn open_entry(
state: &mut AppState,
tab_idx: usize,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
queue: &TransferQueue,
name: &str,
) {
let (nav_path, is_dir) = if name == ".." { let (nav_path, is_dir) = if name == ".." {
let parent = remote_parent(&state.tabs[tab_idx].remote_path); let parent = remote_parent(&state.tabs[tab_idx].remote_path);
(parent, true) (parent, true)
@ -140,11 +271,61 @@ fn render_content(
if !state.tabs[tab_idx].loading { if !state.tabs[tab_idx].loading {
trigger_list(state, tab_idx, registry, rt_handle); trigger_list(state, tab_idx, registry, rt_handle);
} }
} else if let Some(entry) = state.tabs[tab_idx]
.remote_entries
.iter()
.find(|e| e.name == name)
.cloned()
{
queue_download(state, tab_idx, queue, &entry);
} }
} }
// Footer fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) {
render_footer(ui, state, tab_idx, registry, rt_handle); let connection_id = state.tabs[tab_idx].id.clone();
let task = TransferTask::new(
TransferKind::Download,
connection_id,
format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name),
entry.path.clone(),
entry.name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Download queued: {}", entry.name);
}
#[allow(clippy::too_many_arguments)]
fn handle_row_action(
ctx: &egui::Context,
state: &mut AppState,
tab_idx: usize,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
queue: &TransferQueue,
entry: &FileEntry,
action: RowAction,
) {
match action {
RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name),
RowAction::Rename => {
state.op_target = Pane::Remote;
state.show_rename_dialog = true;
state.rename_old_name = entry.name.clone();
state.rename_new_name = entry.name.clone();
}
RowAction::Delete => {
state.op_target = Pane::Remote;
state.show_delete_dialog = true;
state.delete_name = entry.name.clone();
}
RowAction::CopyPath => {
ctx.copy_text(entry.path.clone());
state.status_message = "Path copied".into();
}
RowAction::Transfer => queue_download(state, tab_idx, queue, entry),
RowAction::Bookmark => {}
}
} }
fn render_path_bar( fn render_path_bar(
@ -155,16 +336,24 @@ fn render_path_bar(
rt_handle: &tokio::runtime::Handle, rt_handle: &tokio::runtime::Handle,
) { ) {
let frame = egui::Frame::none() let frame = egui::Frame::none()
.fill(BG_PANEL) .fill(BG_BASE)
.inner_margin(egui::Margin { .inner_margin(egui::Margin::symmetric(12.0, 0.0));
left: 8.0,
right: 8.0,
top: 4.0,
bottom: 4.0,
});
frame.show(ui, |ui| { frame.show(ui, |ui| {
ui.horizontal(|ui| { let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, 30.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let host = state.tabs[tab_idx].params.host.clone();
ui.label(
RichText::new(format!("REMOTE · {}", host))
.color(TEXT_DIM)
.size(10.5)
.strong(),
);
ui.add_space(10.0);
// статус точка // статус точка
let (dot, dot_col) = match state.tabs[tab_idx].status { let (dot, dot_col) = match state.tabs[tab_idx].status {
ConnectionStatus::Connected => ("●", GREEN), ConnectionStatus::Connected => ("●", GREEN),
@ -200,7 +389,8 @@ fn render_path_bar(
if is_last { if is_last {
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
} else { } else {
let link = egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) let link =
egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
.sense(egui::Sense::click()); .sense(egui::Sense::click());
if ui.add(link).clicked() { if ui.add(link).clicked() {
state.tabs[tab_idx].remote_path = path_snap; state.tabs[tab_idx].remote_path = path_snap;
@ -211,7 +401,8 @@ fn render_path_bar(
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
} }
} }
}); },
);
}); });
} }
@ -253,7 +444,7 @@ fn render_footer(
// Refresh кнопка // Refresh кнопка
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let refresh = egui::Button::new(RichText::new("⟳").color(ACCENT).size(14.0)) let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, ACCENT))
.fill(egui::Color32::TRANSPARENT) .fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(20.0, 20.0)); .min_size(egui::vec2(20.0, 20.0));
if ui.add(refresh).on_hover_text("Refresh").clicked() if ui.add(refresh).on_hover_text("Refresh").clicked()

View file

@ -1,10 +1,12 @@
//! Левая боковая панель — bookmarks + места //! Левая боковая панель — bookmarks + места
use crate::ui::icons::{self, Icon};
use crate::ui::panels::local_pane::refresh_local; use crate::ui::panels::local_pane::refresh_local;
use crate::ui::state::AppState; use crate::ui::state::AppState;
use crate::ui::theme::*; use crate::ui::theme::*;
use egui::{RichText, Ui}; use egui::{RichText, Ui};
use std::sync::{Arc, Mutex};
pub fn render(ui: &mut Ui, state: &mut AppState) { pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connection>>) {
let frame = egui::Frame::none() let frame = egui::Frame::none()
.fill(BG_PANEL) .fill(BG_PANEL)
.inner_margin(egui::Margin::symmetric(0.0, 0.0)); .inner_margin(egui::Margin::symmetric(0.0, 0.0));
@ -20,11 +22,11 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
section_header(ui, "LOCAL"); section_header(ui, "LOCAL");
ui.add_space(4.0); ui.add_space(4.0);
for bm in &state.bookmarks.clone() { for qa in &state.quick_access.clone() {
let is_active = state.local_path == bm.path; let is_active = state.local_path == qa.path;
let icon = bookmark_icon(&bm.name); let icon = quick_access_icon(&qa.name);
sidebar_item(ui, icon, &bm.name, is_active, || { sidebar_item(ui, icon, &qa.name, is_active, || {
state.local_path = bm.path.clone(); state.local_path = qa.path.clone();
refresh_local(state); refresh_local(state);
}); });
} }
@ -38,7 +40,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
// Root + data drives // Root + data drives
let root_active = state.local_path == "/"; let root_active = state.local_path == "/";
sidebar_item(ui, "🖥", "/ (root)", root_active, || { sidebar_item(ui, Icon::Ssd, "/ (root)", root_active, || {
state.local_path = "/".into(); state.local_path = "/".into();
refresh_local(state); refresh_local(state);
}); });
@ -50,7 +52,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| mount_path.clone()); .unwrap_or_else(|| mount_path.clone());
let is_active = state.local_path == mount_path; let is_active = state.local_path == mount_path;
sidebar_item(ui, "💾", &label, is_active, || { sidebar_item(ui, Icon::Database, &label, is_active, || {
state.local_path = mount_path.clone(); state.local_path = mount_path.clone();
refresh_local(state); refresh_local(state);
}); });
@ -63,9 +65,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
section_header(ui, "BOOKMARKS"); section_header(ui, "BOOKMARKS");
ui.add_space(4.0); ui.add_space(4.0);
// Пользовательские закладки (после 3 дефолтных) if state.bookmarks.is_empty() {
let custom: Vec<_> = state.bookmarks.iter().skip(3).cloned().collect();
if custom.is_empty() {
ui.label( ui.label(
RichText::new(" No bookmarks yet") RichText::new(" No bookmarks yet")
.color(TEXT_HINT) .color(TEXT_HINT)
@ -77,13 +77,26 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
.size(10.0), .size(10.0),
); );
} else { } else {
for bm in &custom { let mut to_remove: Option<i64> = None;
for bm in &state.bookmarks.clone() {
let is_active = state.local_path == bm.path; let is_active = state.local_path == bm.path;
sidebar_item(ui, "📌", &bm.name, is_active, || { sidebar_item(ui, Icon::Star, &bm.name, is_active, || {
state.local_path = bm.path.clone(); state.local_path = bm.path.clone();
refresh_local(state); refresh_local(state);
})
.context_menu(|ui| {
if ui.button("Remove bookmark").clicked() {
to_remove = Some(bm.id);
ui.close_menu();
}
}); });
} }
if let Some(id) = to_remove {
state.bookmarks.retain(|b| b.id != id);
if let Ok(conn) = db.lock() {
let _ = crate::storage::db::remove_bookmark(&conn, id);
}
}
} }
ui.add_space(8.0); ui.add_space(8.0);
@ -93,8 +106,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
fn section_header(ui: &mut Ui, text: &str) { fn section_header(ui: &mut Ui, text: &str) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(10.0); ui.add_space(14.0);
ui.label(RichText::new(text).color(TEXT_HINT).size(10.0).strong()); ui.label(RichText::new(text).color(TEXT_HINT).size(10.5).strong());
}); });
} }
@ -103,51 +116,54 @@ fn section_divider(ui: &mut Ui) {
ui.painter().rect_filled(rect, 0.0, SEPARATOR); ui.painter().rect_filled(rect, 0.0, SEPARATOR);
} }
fn sidebar_item(ui: &mut Ui, icon: &str, label: &str, active: bool, mut on_click: impl FnMut()) { fn sidebar_item(
ui: &mut Ui,
icon: Icon,
label: &str,
active: bool,
mut on_click: impl FnMut(),
) -> egui::Response {
let bg = if active { let bg = if active {
BG_ROW_SEL BG_ROW_SEL
} else { } else {
egui::Color32::TRANSPARENT egui::Color32::TRANSPARENT
}; };
let text_col = if active { let text_col = TEXT_PRIMARY;
egui::Color32::WHITE let icon_col = if active { ACCENT } else { TEXT_DIM };
} else {
TEXT_PRIMARY
};
let resp = egui::Frame::none() let resp = egui::Frame::none()
.fill(bg) .fill(bg)
.rounding(4.0) .rounding(RADIUS_SM)
.inner_margin(egui::Margin { .inner_margin(egui::Margin::symmetric(8.0, 0.0))
left: 10.0,
right: 6.0,
top: 2.0,
bottom: 2.0,
})
.show(ui, |ui| { .show(ui, |ui| {
ui.set_min_width(SIDEBAR_W - 8.0); ui.allocate_ui_with_layout(
ui.horizontal(|ui| { egui::vec2(SIDEBAR_W - 32.0, 28.0),
ui.label(RichText::new(icon).size(12.0)); egui::Layout::left_to_right(egui::Align::Center),
ui.add_space(6.0); |ui| {
ui.label(RichText::new(label).color(text_col).size(12.0)); icons::icon(ui, icon, 14.0, icon_col);
}); ui.add_space(8.0);
ui.label(RichText::new(label).color(text_col).size(12.5));
},
);
}); });
if resp.response.interact(egui::Sense::click()).clicked() { let response = resp.response.interact(egui::Sense::click());
if response.clicked() {
on_click(); on_click();
} }
response
} }
fn bookmark_icon(name: &str) -> &'static str { fn quick_access_icon(name: &str) -> Icon {
match name { match name {
"Home" => "🏠", "Home" => Icon::Home,
"Desktop" => "🖥", "Desktop" => Icon::Monitor,
"Documents" => "📄", "Documents" => Icon::Document,
"Downloads" => "⬇", "Downloads" => Icon::DownloadSquare,
"Pictures" => "🖼", "Pictures" => Icon::Gallery,
"Music" => "🎵", "Music" => Icon::MusicNote,
"Videos" => "🎬", "Videos" => Icon::Videocamera,
_ => "📁", _ => Icon::Folder,
} }
} }

View file

@ -1,4 +1,5 @@
use crate::domain::connection::ConnectionStatus; use crate::domain::connection::ConnectionStatus;
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::format_size; use crate::ui::panels::file_pane::format_size;
use crate::ui::state::AppState; use crate::ui::state::AppState;
use crate::ui::theme::*; use crate::ui::theme::*;
@ -10,8 +11,11 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) {
.inner_margin(egui::Margin::symmetric(10.0, 2.0)); .inner_margin(egui::Margin::symmetric(10.0, 2.0));
frame.show(ui, |ui| { frame.show(ui, |ui| {
ui.set_min_height(STATUS_H); let width = ui.available_width();
ui.horizontal_centered(|ui| { ui.allocate_ui_with_layout(
egui::vec2(width, STATUS_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
// статус сообщение // статус сообщение
ui.label( ui.label(
RichText::new(&state.status_message) RichText::new(&state.status_message)
@ -30,8 +34,9 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) {
if agg > 0 { if agg > 0 {
ui.label( ui.label(
RichText::new(format!("⬆⬇ {}/s", format_size(Some(agg)))) RichText::new(format!("{}/s", format_size(Some(agg))))
.color(GREEN) .color(GREEN)
.monospace()
.size(11.0), .size(11.0),
); );
ui.add_space(10.0); ui.add_space(10.0);
@ -47,19 +52,20 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) {
if total > 0 { if total > 0 {
let dot_col = if connected > 0 { GREEN } else { TEXT_HINT }; let dot_col = if connected > 0 { GREEN } else { TEXT_HINT };
if connected > 0 {
icons::icon(ui, Icon::LockPassword, 11.0, GREEN);
ui.add_space(4.0);
}
ui.label( ui.label(
RichText::new(format!("● {}/{}", connected, total)) RichText::new(format!("{}/{}", connected, total))
.color(dot_col) .color(dot_col)
.size(11.0), .size(11.0),
); );
} else { } else {
ui.label( ui.label(RichText::new("Not connected").color(TEXT_HINT).size(11.0));
RichText::new("○ Not connected")
.color(TEXT_HINT)
.size(11.0),
);
} }
}); });
}); },
);
}); });
} }

View file

@ -14,8 +14,11 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
}); });
frame.show(ui, |ui| { frame.show(ui, |ui| {
ui.set_min_height(TABBAR_H); let width = ui.available_width();
ui.horizontal(|ui| { ui.allocate_ui_with_layout(
egui::vec2(width, TABBAR_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let mut to_close: Option<usize> = None; let mut to_close: Option<usize> = None;
if state.tabs.is_empty() { if state.tabs.is_empty() {
@ -41,12 +44,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
let tab_response = egui::Frame::none() let tab_response = egui::Frame::none()
.fill(bg) .fill(bg)
.rounding(egui::Rounding { .rounding(RADIUS_MD)
nw: 4.0,
ne: 4.0,
sw: 0.0,
se: 0.0,
})
.inner_margin(egui::Margin { .inner_margin(egui::Margin {
left: 10.0, left: 10.0,
right: 4.0, right: 4.0,
@ -54,28 +52,33 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
bottom: 0.0, bottom: 0.0,
}) })
.show(ui, |ui| { .show(ui, |ui| {
ui.set_min_height(TABBAR_H); ui.allocate_ui_with_layout(
ui.horizontal_centered(|ui| { egui::vec2(ui.available_width().min(220.0), 24.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
// цветная точка статуса // цветная точка статуса
ui.label(RichText::new(dot).color(dot_col).size(9.0)); ui.label(RichText::new(dot).color(dot_col).size(9.0));
ui.add_space(4.0); ui.add_space(4.0);
// название вкладки // название вкладки
let lbl = let lbl = egui::Label::new(
egui::Label::new(RichText::new(&tab.label).color(tcol).size(12.0)) RichText::new(&tab.label).color(tcol).size(12.0),
)
.sense(egui::Sense::click()); .sense(egui::Sense::click());
if ui.add(lbl).clicked() { if ui.add(lbl).clicked() {
// обрабатывается ниже // обрабатывается ниже
} }
ui.add_space(6.0); ui.add_space(6.0);
// кнопка закрытия // кнопка закрытия
let close = let close = egui::Label::new(
egui::Label::new(RichText::new("×").color(TEXT_HINT).size(14.0)) RichText::new("×").color(TEXT_HINT).size(14.0),
)
.sense(egui::Sense::click()); .sense(egui::Sense::click());
if ui.add(close).clicked() { if ui.add(close).clicked() {
to_close = Some(i); to_close = Some(i);
} }
ui.add_space(4.0); ui.add_space(4.0);
}); },
);
}); });
if tab_response.response.clicked() { if tab_response.response.clicked() {
@ -85,10 +88,10 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
// кнопка "+" // кнопка "+"
ui.add_space(4.0); ui.add_space(4.0);
let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(16.0)) let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(15.0))
.fill(Color32::TRANSPARENT) .fill(Color32::TRANSPARENT)
.rounding(4.0) .rounding(RADIUS_MD)
.min_size(egui::vec2(28.0, TABBAR_H)); .min_size(egui::vec2(24.0, 24.0));
if ui.add(plus).clicked() { if ui.add(plus).clicked() {
state.show_connect_dialog = true; state.show_connect_dialog = true;
} }
@ -99,7 +102,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
state.active_tab = state.tabs.len() - 1; state.active_tab = state.tabs.len() - 1;
} }
} }
}); },
);
}); });
// нижняя граница таббара // нижняя граница таббара

View file

@ -1,16 +1,24 @@
use crate::ui::state::AppState; use crate::domain::transfer::{TransferKind, TransferTask};
use crate::transfer::queue::TransferQueue;
use crate::ui::icons::{self, Icon};
use crate::ui::state::{AppState, Pane};
use crate::ui::theme::*; use crate::ui::theme::*;
use egui::{Align, Layout, RichText, Ui}; use egui::{Align, Layout, RichText, Ui};
pub fn render(ui: &mut Ui, state: &mut AppState) { pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) {
let frame = egui::Frame::none() let frame = egui::Frame::none()
.fill(BG_TOOLBAR) .fill(BG_TOOLBAR)
.inner_margin(egui::Margin::symmetric(8.0, 0.0)); .inner_margin(egui::Margin::symmetric(8.0, 0.0));
frame.show(ui, |ui| { frame.show(ui, |ui| {
ui.set_min_height(TOOLBAR_H); let width = ui.available_width();
ui.horizontal_centered(|ui| { ui.allocate_ui_with_layout(
egui::vec2(width, TOOLBAR_H),
Layout::left_to_right(Align::Center),
|ui| {
// Логотип // Логотип
icons::icon(ui, Icon::Folder, 17.0, ACCENT);
ui.add_space(4.0);
ui.label( ui.label(
RichText::new("LoFlum") RichText::new("LoFlum")
.color(TEXT_PRIMARY) .color(TEXT_PRIMARY)
@ -22,10 +30,19 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
separator_v(ui); separator_v(ui);
ui.add_space(8.0); ui.add_space(8.0);
// Новое подключение // Новое подключение — единственная акцентная кнопка в тулбаре
toolbar_btn(ui, "+ New Connection", false, || { let connect_btn = egui::Button::image_and_text(
icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY),
RichText::new("New Connection")
.color(TEXT_PRIMARY)
.size(12.5),
)
.fill(ACCENT_DIM)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 30.0));
if ui.add(connect_btn).clicked() {
state.show_connect_dialog = true; state.show_connect_dialog = true;
}); }
separator_v(ui); separator_v(ui);
@ -41,65 +58,109 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
.and_then(|t| t.remote_selected.as_ref()) .and_then(|t| t.remote_selected.as_ref())
.is_some(); .is_some();
toolbar_btn_enabled(ui, "⬆ Upload", has_conn && has_local_sel, || { toolbar_btn_enabled(
state.status_message = "Upload: select file and use drag & drop".into(); ui,
}); Icon::Upload,
toolbar_btn_enabled(ui, "⬇ Download", has_conn && has_remote_sel, || { "Upload",
state.status_message = "Download: select file and use drag & drop".into(); has_conn && has_local_sel,
}); || {
queue_selected_upload(state, queue);
},
);
toolbar_btn_enabled(
ui,
Icon::Download,
"Download",
has_conn && has_remote_sel,
|| {
queue_selected_download(state, queue);
},
);
separator_v(ui); separator_v(ui);
// Очередь — с счётчиком // Очередь — с счётчиком
let q_count = state.queue_tasks.len(); let q_count = state.queue_tasks.len();
let q_label = if q_count > 0 { let q_label = if q_count > 0 {
format!("⏳ Queue {}", q_count) format!("Queue {}", q_count)
} else { } else {
"⏳ Queue".to_string() "Queue".to_string()
}; };
let q_active = state.show_queue; let q_active = state.show_queue;
toolbar_btn(ui, &q_label, q_active, || { toolbar_btn(ui, Icon::Clock, &q_label, q_active, || {
state.show_queue = !state.show_queue; state.show_queue = !state.show_queue;
}); });
separator_v(ui); separator_v(ui);
toolbar_btn_enabled(ui, "⟳ Refresh", has_conn, || { let on_remote = state.active_pane == Pane::Remote;
let can_new_folder = if on_remote { has_conn } else { true };
let can_delete_rename = if on_remote {
has_conn && has_remote_sel
} else {
has_local_sel
};
toolbar_icon_btn(ui, Icon::Refresh, has_conn, "Refresh", || {
// refresh remote — вызывается снаружи через state флаг // refresh remote — вызывается снаружи через state флаг
state.pending_refresh = true; state.pending_refresh = true;
}); });
toolbar_btn_enabled(ui, "📁 New Folder", has_conn, || { toolbar_icon_btn(
ui,
Icon::FolderWithFiles,
can_new_folder,
"New Folder",
|| {
state.op_target = state.active_pane;
state.show_mkdir_dialog = true; state.show_mkdir_dialog = true;
state.mkdir_name.clear(); state.mkdir_name.clear();
}); },
toolbar_btn_enabled(ui, "× Delete", has_conn && has_remote_sel, || { );
if let Some(name) = state toolbar_icon_btn(ui, Icon::Trash, can_delete_rename, "Delete", || {
let name = if on_remote {
state
.active_tab_ref() .active_tab_ref()
.and_then(|t| t.remote_selected.clone()) .and_then(|t| t.remote_selected.clone())
{ } else {
state.local_selected.clone()
};
if let Some(name) = name {
state.op_target = state.active_pane;
state.show_delete_dialog = true; state.show_delete_dialog = true;
state.delete_name = name; state.delete_name = name;
} }
}); });
toolbar_btn_enabled(ui, "✎ Rename", has_conn && has_remote_sel, || { toolbar_icon_btn(ui, Icon::Pen, can_delete_rename, "Rename", || {
if let Some(name) = state let name = if on_remote {
state
.active_tab_ref() .active_tab_ref()
.and_then(|t| t.remote_selected.clone()) .and_then(|t| t.remote_selected.clone())
{ } else {
state.local_selected.clone()
};
if let Some(name) = name {
state.op_target = state.active_pane;
state.show_rename_dialog = true; state.show_rename_dialog = true;
state.rename_old_name = name.clone(); state.rename_old_name = name.clone();
state.rename_new_name = name; state.rename_new_name = name;
} }
}); });
// Правая часть — History / Bookmarks // Правая часть — History / Bookmarks / Settings
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
toolbar_btn(ui, "⏱ History", state.show_history, || { // На macOS кнопка настроек живёт в нативном верхнем меню
// (LoFlum → Settings…), здесь она не нужна.
#[cfg(not(target_os = "macos"))]
toolbar_icon_btn(ui, Icon::Settings, true, "Settings", || {
state.show_settings_dialog = true;
});
toolbar_btn(ui, Icon::History, "History", state.show_history, || {
state.show_history = !state.show_history; state.show_history = !state.show_history;
state.show_bookmarks = false; state.show_bookmarks = false;
}); });
}); });
}); },
);
}); });
// История поверх // История поверх
@ -120,17 +181,22 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
.order(egui::Order::Foreground) .order(egui::Order::Foreground)
.show(ui.ctx(), |ui| { .show(ui.ctx(), |ui| {
egui::Frame::none() egui::Frame::none()
.fill(egui::Color32::from_rgb(32, 32, 36)) .fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER)) .stroke(egui::Stroke::new(1.0, BORDER))
.rounding(6.0) .rounding(RADIUS_LG)
.inner_margin(egui::Margin::same(8.0)) .inner_margin(egui::Margin::same(10.0))
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(260.0); ui.set_width(260.0);
ui.horizontal(|ui| {
icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM);
ui.add_space(6.0);
ui.label( ui.label(
RichText::new("Recent Connections") RichText::new("Recent connections")
.color(TEXT_DIM) .color(TEXT_DIM)
.size(11.0), .size(11.0)
.strong(),
); );
});
ui.add_space(4.0); ui.add_space(4.0);
if state.history.is_empty() { if state.history.is_empty() {
ui.label(RichText::new("No history yet").color(TEXT_HINT)); ui.label(RichText::new("No history yet").color(TEXT_HINT));
@ -143,24 +209,30 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
format!("{}@{}:{}", entry.user, entry.host, entry.port); format!("{}@{}:{}", entry.user, entry.host, entry.port);
let time = let time =
RichText::new(&entry.time).color(TEXT_HINT).size(10.0); RichText::new(&entry.time).color(TEXT_HINT).size(10.0);
if ui let btn = ui.add(
.add(
egui::Button::new( egui::Button::new(
RichText::new(&label) RichText::new(&label).color(TEXT_PRIMARY).size(12.0),
.color(TEXT_PRIMARY)
.size(12.0),
) )
.fill(egui::Color32::TRANSPARENT) .fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(240.0, 24.0)), .min_size(egui::vec2(240.0, 24.0)),
) );
.clicked() if btn.clicked() {
{ state.pending_history_reconnect = Some(entry.clone());
state.connect_host = entry.host.clone();
state.connect_user = entry.user.clone();
state.connect_port = entry.port.to_string();
state.show_connect_dialog = true;
state.show_history = false; state.show_history = false;
} }
btn.context_menu(|ui| {
if ui.button("Edit").clicked() {
crate::ui::dialogs::connection::edit_history_entry(
state, entry,
);
state.show_history = false;
ui.close_menu();
}
if ui.button("Save").clicked() {
state.pending_history_save = Some(entry.clone());
ui.close_menu();
}
});
ui.label(time); ui.label(time);
} }
}); });
@ -179,25 +251,53 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
}); });
} }
fn toolbar_btn(ui: &mut Ui, label: &str, active: bool, mut on_click: impl FnMut()) { fn toolbar_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) {
let fill = if active { let fill = if active {
BG_TAB_ACTIVE BG_TAB_ACTIVE
} else { } else {
egui::Color32::TRANSPARENT egui::Color32::TRANSPARENT
}; };
let text_col = if active { ACCENT } else { TEXT_PRIMARY }; let text_col = if active { ACCENT } else { TEXT_PRIMARY };
let btn = egui::Button::new(RichText::new(label).color(text_col).size(12.0)) let btn = egui::Button::image_and_text(
icons::image(icon, 15.0, text_col),
RichText::new(label).color(text_col).size(12.5),
)
.fill(fill) .fill(fill)
.rounding(4.0) .rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 26.0)); .min_size(egui::vec2(0.0, 30.0));
if ui.add(btn).clicked() { if ui.add(btn).clicked() {
on_click(); on_click();
} }
} }
fn toolbar_btn_enabled(ui: &mut Ui, label: &str, enabled: bool, on_click: impl FnMut()) { fn toolbar_btn_enabled(
ui: &mut Ui,
icon: Icon,
label: &str,
enabled: bool,
on_click: impl FnMut(),
) {
ui.add_enabled_ui(enabled, |ui| { ui.add_enabled_ui(enabled, |ui| {
toolbar_btn(ui, label, false, on_click); toolbar_btn(ui, icon, label, false, on_click);
});
}
fn toolbar_icon_btn(
ui: &mut Ui,
icon: Icon,
enabled: bool,
hover: &str,
mut on_click: impl FnMut(),
) {
ui.add_enabled_ui(enabled, |ui| {
let col = if enabled { TEXT_DIM } else { TEXT_HINT };
let btn = egui::Button::image(icons::image(icon, 16.0, col))
.fill(egui::Color32::TRANSPARENT)
.rounding(RADIUS_MD)
.min_size(egui::vec2(30.0, 30.0));
if ui.add(btn).on_hover_text(hover).clicked() {
on_click();
}
}); });
} }
@ -205,3 +305,51 @@ fn separator_v(ui: &mut Ui) {
let (_, rect) = ui.allocate_space(egui::vec2(1.0, 20.0)); let (_, rect) = ui.allocate_space(egui::vec2(1.0, 20.0));
ui.painter().rect_filled(rect, 0.0, BORDER); ui.painter().rect_filled(rect, 0.0, BORDER);
} }
fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) {
let Some(name) = state.local_selected.clone() else {
return;
};
let Some(tab) = state.active_tab_ref() else {
return;
};
let connection_id = tab.id.clone();
let remote_dir = tab.remote_path.clone();
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id,
local_path,
remote_path,
name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Upload queued: {}", name);
}
fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) {
let Some(tab) = state.active_tab_ref() else {
return;
};
let Some(name) = tab.remote_selected.clone() else {
return;
};
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else {
return;
};
let connection_id = tab.id.clone();
let remote_path = entry.path.clone();
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
let task = TransferTask::new(
TransferKind::Download,
connection_id,
local_path,
remote_path,
name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Download queued: {}", name);
}

View file

@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use crate::domain::connection::{ConnectionParams, ConnectionStatus}; use crate::domain::connection::{ConnectionParams, ConnectionStatus, Protocol};
use crate::domain::file_entry::FileEntry; use crate::domain::file_entry::FileEntry;
use crate::domain::transfer::TransferTask; use crate::domain::transfer::TransferTask;
@ -16,6 +16,14 @@ pub struct ConnectionTab {
pub loading: bool, pub loading: bool,
} }
/// Какая панель последней принимала клик — используется тулбаром,
/// чтобы New Folder/Rename/Delete применялись к нужной стороне (local/remote).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Pane {
Local,
Remote,
}
type PendingResult<T> = Arc<std::sync::Mutex<Option<Result<T, String>>>>; type PendingResult<T> = Arc<std::sync::Mutex<Option<Result<T, String>>>>;
pub struct PendingConnect { pub struct PendingConnect {
@ -29,6 +37,14 @@ pub struct PendingRemoteList {
#[derive(Clone)] #[derive(Clone)]
pub struct Bookmark { pub struct Bookmark {
pub id: i64,
pub name: String,
pub path: String,
}
/// Фиксированный пункт быстрого доступа (Home/Desktop/...) — не удаляется, не хранится в БД.
#[derive(Clone)]
pub struct QuickAccess {
pub name: String, pub name: String,
pub path: String, pub path: String,
} }
@ -39,6 +55,10 @@ pub struct HistoryEntry {
pub port: u16, pub port: u16,
pub user: String, pub user: String,
pub time: String, pub time: String,
/// Стабильный id этой цели — под ним же лежит пароль в keychain.
pub conn_id: String,
pub protocol: Protocol,
pub key_path: Option<String>,
} }
pub struct AppState { pub struct AppState {
@ -49,10 +69,20 @@ pub struct AppState {
pub local_entries: Vec<FileEntry>, pub local_entries: Vec<FileEntry>,
pub local_selected: Option<String>, pub local_selected: Option<String>,
/// Панель, в которой был последний клик — New Folder/Rename/Delete в тулбаре
/// применяются к ней.
pub active_pane: Pane,
/// К какой стороне относится открытый сейчас диалог mkdir/rename/delete.
pub op_target: Pane,
pub show_connect_dialog: bool, pub show_connect_dialog: bool,
pub show_bookmarks: bool, pub show_bookmarks: bool,
pub show_history: bool, pub show_history: bool,
pub show_settings_dialog: bool,
/// Фиксированные пункты быстрого доступа (Home/Desktop/...) — не удаляются, не хранятся в БД.
pub quick_access: Vec<QuickAccess>,
/// Закладки, добавленные пользователем — хранятся в БД, можно удалять.
pub bookmarks: Vec<Bookmark>, pub bookmarks: Vec<Bookmark>,
pub history: Vec<HistoryEntry>, pub history: Vec<HistoryEntry>,
@ -74,6 +104,10 @@ pub struct AppState {
// action flags — выставляются тулбаром, обрабатываются в app.rs // action flags — выставляются тулбаром, обрабатываются в app.rs
pub pending_refresh: bool, pub pending_refresh: bool,
/// Клик по записи истории — переподключиться (новая вкладка, без диалога).
pub pending_history_reconnect: Option<HistoryEntry>,
/// ПКМ → "Save" на записи истории — сохранить как постоянный Site.
pub pending_history_save: Option<HistoryEntry>,
// диалоги операций над удалённой ФС // диалоги операций над удалённой ФС
pub show_mkdir_dialog: bool, pub show_mkdir_dialog: bool,
@ -105,41 +139,45 @@ impl Default for AppState {
local_path: home.clone(), local_path: home.clone(),
local_entries: Vec::new(), local_entries: Vec::new(),
local_selected: None, local_selected: None,
active_pane: Pane::Local,
op_target: Pane::Local,
show_connect_dialog: false, show_connect_dialog: false,
show_bookmarks: false, show_bookmarks: false,
show_history: false, show_history: false,
bookmarks: vec![ show_settings_dialog: false,
Bookmark { quick_access: vec![
QuickAccess {
name: "Home".into(), name: "Home".into(),
path: home.clone(), path: home.clone(),
}, },
Bookmark { QuickAccess {
name: "Desktop".into(), name: "Desktop".into(),
path: format!("{}/Desktop", home), path: format!("{}/Desktop", home),
}, },
Bookmark { QuickAccess {
name: "Documents".into(), name: "Documents".into(),
path: format!("{}/Documents", home), path: format!("{}/Documents", home),
}, },
Bookmark { QuickAccess {
name: "Downloads".into(), name: "Downloads".into(),
path: dirs::download_dir() path: dirs::download_dir()
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| format!("{}/Downloads", home)), .unwrap_or_else(|| format!("{}/Downloads", home)),
}, },
Bookmark { QuickAccess {
name: "Pictures".into(), name: "Pictures".into(),
path: format!("{}/Pictures", home), path: format!("{}/Pictures", home),
}, },
Bookmark { QuickAccess {
name: "Music".into(), name: "Music".into(),
path: format!("{}/Music", home), path: format!("{}/Music", home),
}, },
Bookmark { QuickAccess {
name: "Videos".into(), name: "Videos".into(),
path: format!("{}/Videos", home), path: format!("{}/Videos", home),
}, },
], ],
bookmarks: Vec::new(),
history: Vec::new(), history: Vec::new(),
connect_label: String::new(), connect_label: String::new(),
connect_host: String::new(), connect_host: String::new(),
@ -155,6 +193,8 @@ impl Default for AppState {
status_message: "Ready".into(), status_message: "Ready".into(),
connected_count: 0, connected_count: 0,
pending_refresh: false, pending_refresh: false,
pending_history_reconnect: None,
pending_history_save: None,
show_mkdir_dialog: false, show_mkdir_dialog: false,
show_delete_dialog: false, show_delete_dialog: false,
show_rename_dialog: false, show_rename_dialog: false,
@ -188,20 +228,25 @@ impl AppState {
Some(&self.tabs[idx]) Some(&self.tabs[idx])
} }
pub fn add_history(&mut self, host: &str, port: u16, user: &str) { /// Перечитывает историю подключений из БД (после записи новой попытки).
use chrono::Local; pub fn reload_history(&mut self, db: &Arc<std::sync::Mutex<rusqlite::Connection>>) {
let now = Local::now().format("%H:%M %d.%m").to_string(); if let Ok(conn) = db.lock()
self.history.insert( && let Ok(rows) = crate::storage::db::get_history(&conn)
0, {
HistoryEntry { self.history = rows
host: host.into(), .into_iter()
.map(
|(host, port, user, time, conn_id, protocol, key_path)| HistoryEntry {
host,
port, port,
user: user.into(), user,
time: now, time,
conn_id,
protocol,
key_path,
}, },
); )
if self.history.len() > 20 { .collect();
self.history.truncate(20);
} }
} }
} }

View file

@ -1,40 +1,46 @@
//! Цвета и константы темы LoFlum //! Цвета и константы темы LoFlum — "Clay"
use egui::Color32; use egui::Color32;
// Backgrounds // Backgrounds — тёплый почти-чёрный, а не нейтрально-синий
pub const BG_BASE: Color32 = Color32::from_rgb(18, 18, 18); // самый тёмный фон pub const BG_BASE: Color32 = Color32::from_rgb(23, 20, 15); // #17140F — самый тёмный фон
pub const BG_PANEL: Color32 = Color32::from_rgb(24, 24, 24); // боковая панель pub const BG_PANEL: Color32 = Color32::from_rgb(25, 22, 17); // #19160F — тулбар/боковая панель
pub const BG_CONTENT: Color32 = Color32::from_rgb(28, 28, 28); // основная область pub const BG_CONTENT: Color32 = Color32::from_rgb(32, 29, 25); // #201D19 — основная область
pub const BG_TOOLBAR: Color32 = Color32::from_rgb(22, 22, 22); // тулбар pub const BG_TOOLBAR: Color32 = Color32::from_rgb(25, 22, 17); // #19160F
pub const BG_TAB_ACTIVE: Color32 = Color32::from_rgb(38, 38, 42); // активная вкладка pub const BG_TAB_ACTIVE: Color32 = Color32::from_rgb(36, 29, 20); // #241D14 — активная вкладка (clay-tint)
pub const BG_TAB_IDLE: Color32 = Color32::from_rgb(28, 28, 30); // неактивная pub const BG_TAB_IDLE: Color32 = Color32::from_rgb(23, 20, 15);
pub const BG_ROW_HOVER: Color32 = Color32::from_rgb(35, 35, 38); pub const BG_ROW_HOVER: Color32 = Color32::from_rgb(38, 32, 25); // #262019
pub const BG_ROW_SEL: Color32 = Color32::from_rgb(42, 64, 95); // выделение — синеватое pub const BG_ROW_SEL: Color32 = Color32::from_rgb(42, 32, 24); // #2A2018 — тёплое выделение
pub const BG_QUEUE: Color32 = Color32::from_rgb(20, 20, 22); pub const BG_QUEUE: Color32 = Color32::from_rgb(22, 19, 16); // #161310
pub const BG_HEADER: Color32 = Color32::from_rgb(22, 22, 24); pub const BG_HEADER: Color32 = Color32::from_rgb(23, 20, 15);
// Accents // Accents — clay/терракота вместо синего, один акцент на экран
pub const ACCENT: Color32 = Color32::from_rgb(64, 130, 220); // синий акцент pub const ACCENT: Color32 = Color32::from_rgb(193, 103, 60); // #C1673C — clay
pub const ACCENT_DIM: Color32 = Color32::from_rgb(45, 95, 165); pub const ACCENT_DIM: Color32 = Color32::from_rgb(139, 74, 44); // #8B4A2C — приглушённый clay
pub const GREEN: Color32 = Color32::from_rgb(60, 180, 100); pub const ON_ACCENT: Color32 = Color32::from_rgb(26, 22, 17); // #1A1611 — текст на clay-заливке
pub const RED: Color32 = Color32::from_rgb(210, 70, 70); pub const GREEN: Color32 = Color32::from_rgb(122, 148, 113); // #7A9471 — sage (успех/подключено)
pub const YELLOW: Color32 = Color32::from_rgb(210, 160, 50); pub const RED: Color32 = Color32::from_rgb(179, 82, 62); // #B3523E — brick (ошибка)
pub const ORANGE: Color32 = Color32::from_rgb(210, 120, 50); pub const YELLOW: Color32 = Color32::from_rgb(217, 164, 65); // #D9A441 — amber (папки/предупреждение)
pub const ORANGE: Color32 = Color32::from_rgb(193, 103, 60);
// Text // Text
pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(220, 220, 220); pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(237, 232, 224); // #EDE8E0
pub const TEXT_DIM: Color32 = Color32::from_rgb(130, 130, 135); pub const TEXT_DIM: Color32 = Color32::from_rgb(166, 156, 142); // #A69C8E
pub const TEXT_HINT: Color32 = Color32::from_rgb(80, 80, 85); pub const TEXT_HINT: Color32 = Color32::from_rgb(110, 101, 90); // #6E655A
// Borders / separators // Borders / separators
pub const BORDER: Color32 = Color32::from_rgb(40, 40, 44); pub const BORDER: Color32 = Color32::from_rgb(46, 42, 36); // #2E2A24
pub const SEPARATOR: Color32 = Color32::from_rgb(35, 35, 38); pub const SEPARATOR: Color32 = Color32::from_rgb(40, 36, 30); // #28241E
// Size constants // Size constants
pub const SIDEBAR_W: f32 = 180.0; pub const SIDEBAR_W: f32 = 200.0;
pub const TOOLBAR_H: f32 = 40.0; pub const TOOLBAR_H: f32 = 48.0;
pub const TABBAR_H: f32 = 32.0; pub const TABBAR_H: f32 = 36.0;
pub const QUEUE_COLLAPSED_H: f32 = 32.0; pub const QUEUE_COLLAPSED_H: f32 = 34.0;
pub const QUEUE_EXPANDED_H: f32 = 160.0; pub const QUEUE_EXPANDED_H: f32 = 190.0;
pub const STATUS_H: f32 = 24.0; pub const STATUS_H: f32 = 26.0;
pub const ROW_H: f32 = 22.0; pub const ROW_H: f32 = 28.0;
// Radius
pub const RADIUS_SM: f32 = 5.0;
pub const RADIUS_MD: f32 = 6.0;
pub const RADIUS_LG: f32 = 10.0;