//! Warstwa UI: układ, rysowanie i obsługa wejścia. //! //! Wszystkie napisy idą przez `libluna_i18n::tr` (klucz = angielski tekst). use std::path::{Component, Path, PathBuf}; use std::time::{Duration, Instant}; use libluna_core::style::weight; use libluna_core::{ ButtonState, Color, Event, Insets, KeyEvent, Modifiers, Point, PointerButton, Rect, Size, Theme, ThemePreference, }; use libluna_render::{Scene, TextEngine}; use libluna_widgets::{ icon, AlertDialog, Button, ControlsSide, Entry as TextEntry, HeaderBar, IconName, Menu, MenuEntry, PaintCtx, PathBar, PreferencesDialog, Switch, Tab, TabBar, TextInput, ViewMode, ViewSwitcher, WindowControl, }; use crate::app::{App, ContextMenu, EditState, MenuCmd, MenuTarget, Pane, SideAction, SPLIT_GAP}; use crate::archive; use crate::list; use crate::model::{format_mtime, human_size, Kind, SortKey}; use crate::ops::Job; use crate::places; use crate::prefs; use crate::trash; const SIDEBAR_W: f64 = 216.0; const STATUS_H: f64 = 28.0; const PREVIEW_W: f64 = 300.0; const PAD: f64 = 8.0; const BTN: f64 = 28.0; const PANE_PATH_H: f64 = 26.0; struct Layout { /// Cały pasek tytułu: jeden wiersz albo dwa (gdy otwarte są zakładki). header: Rect, /// Górny wiersz paska — kontrolki okna i zakładki. top: Rect, /// Pasek zakładek wewnątrz `HeaderBar` (jego obszar `content`). tabs: Rect, /// Przycisk „nowa zakładka” obok zakładek (tylko przy >1 zakładce). plus: Rect, /// Wiersz nawigacji (przy jednej zakładce to ten sam wiersz co `top`). nav: Rect, sidebar: Rect, status: Rect, panes: [Rect; 2], } struct Chrome { sidebar_toggle: Rect, back: Rect, forward: Rect, up: Rect, search: Rect, view: Rect, menu: Rect, path: Rect, } #[derive(Clone, Copy)] struct PaneRects { path: Option, items: Rect, preview: Option, } /// Obszar interaktywnych wierszy (w trybie listy bez paska nagłówków). fn file_rows(pane: &Pane, items: Rect) -> Rect { if pane.view.mode() == ViewMode::List { list::rows_rect(items) } else { items } } /// Trafienie w wiersz zależnie od trybu widoku. fn hit_index(pane: &Pane, rows: Rect, p: Point, theme: &Theme) -> Option { match pane.view.mode() { ViewMode::List => list::index_at(rows, p, pane.view.scroll(), pane.view.len(), theme), ViewMode::Grid => pane.view.index_at(rows, p, theme), } } /// Pasek zakładek z bieżącego stanu aplikacji. fn tab_bar(app: &App) -> TabBar { let pane = app.pane(); let tabs: Vec = pane .tabs .iter() .map(|p| Tab::new(tab_title(p)).closable(true)) .collect(); let mut bar = TabBar::with_tabs(tabs).expand(false); bar.set_selected(pane.tab); bar.set_hovered(app.tab_hover); bar } fn layout(app: &App, size: Size, theme: &Theme, text: &TextEngine) -> Layout { let hh = theme.metrics.headerbar.height; let multi = app.pane().tabs.len() > 1; // Pasek jest **jeden**: nawigacja siedzi w tym samym wierszu co kontrolki okna. // Dopiero przy wielu zakładkach dochodzi drugi wiersz (na górze), bo tam jest // ich miejsce — dokładnie jak w GNOME Files. let header_h = if multi { hh * 2.0 } else { hh }; let header = Rect::from_xywh(0.0, 0.0, size.width, header_h); let top = Rect::from_xywh(0.0, 0.0, size.width, hh); let nav = if multi { Rect::from_xywh(0.0, hh, size.width, hh) } else { top }; // Zakładki zajmują `content` górnego wiersza — między krawędzią a kontrolkami okna. let content = HeaderBar::new() .controls(ControlsSide::Right) .controls_layout(top, theme) .content; let tabs_w = (content.width() - BTN - 4.0).max(1.0); let tab_h = theme.metrics.tabs.height.min(content.height()); let tabs = Rect::from_xywh(content.x(), content.center().y - tab_h / 2.0, tabs_w, tab_h); // „+” tuż za ostatnią zakładką (jak w przeglądarce), ale nigdy pod kontrolkami okna. let plus = if multi { let last_right = tab_bar(app) .segments(tabs, text, theme) .last() .map(|r| r.right()) .unwrap_or(tabs.x()); Rect::from_xywh( (last_right + 6.0).min(content.right() - BTN), content.center().y - BTN / 2.0, BTN, BTN, ) } else { Rect::ZERO }; let status = Rect::from_xywh( 0.0, (size.height - STATUS_H).max(nav.bottom()), size.width, STATUS_H, ); let content_h = (status.y() - nav.bottom()).max(0.0); let sidebar_w = if app.sidebar_visible && size.width > 720.0 { SIDEBAR_W.min(size.width * 0.35) } else { 0.0 }; let sidebar = Rect::from_xywh(0.0, nav.bottom(), sidebar_w, content_h); let area = Rect::from_xywh( sidebar.right(), nav.bottom(), (size.width - sidebar.right()).max(1.0), content_h, ); let mut panes = [Rect::ZERO; 2]; if app.split { let w = ((area.width() - SPLIT_GAP) / 2.0).max(1.0); panes[0] = Rect::from_xywh(area.x(), area.y(), w, area.height()); panes[1] = Rect::from_xywh(area.x() + w + SPLIT_GAP, area.y(), w, area.height()); } else { panes[0] = area; } Layout { header, top, tabs, plus, nav, sidebar, status, panes, } } fn chrome(app: &App, lay: &Layout, theme: &Theme) -> Chrome { let multi = app.pane().tabs.len() > 1; // Przy jednej zakładce nawigacja dzieli wiersz z kontrolkami okna, więc kończymy // przed nimi. Przy dwóch wierszach dolny jest wolny na całej szerokości. let (left, right) = if multi { (lay.nav.x() + PAD, lay.nav.right() - PAD) } else { let content = HeaderBar::new() .controls(ControlsSide::Right) .controls_layout(lay.top, theme) .content; (content.x(), content.right()) }; let cy = lay.nav.center().y; let mut x = left; let btn = |x: &mut f64| { let r = Rect::from_xywh(*x, cy - BTN / 2.0, BTN, BTN); *x += BTN + 2.0; r }; let sidebar_toggle = btn(&mut x); let back = btn(&mut x); let forward = btn(&mut x); let up = btn(&mut x); let menu = Rect::from_xywh(right - BTN, cy - BTN / 2.0, BTN, BTN); let view = Rect::from_xywh(menu.x() - BTN - 4.0, cy - BTN / 2.0, BTN, BTN); let search = Rect::from_xywh(view.x() - BTN - 4.0, cy - BTN / 2.0, BTN, BTN); let path = Rect::from_xywh( up.right() + PAD, lay.nav.y() + 5.0, (search.x() - PAD - (up.right() + PAD)).max(40.0), (lay.nav.height() - 10.0).max(20.0), ); Chrome { sidebar_toggle, back, forward, up, search, view, menu, path, } } fn pane_rects(app: &App, lay: &Layout, i: usize, theme: &Theme) -> PaneRects { let pane = &app.panes[i]; let rect = lay.panes[i]; let (path, body) = if app.split { ( Some(Rect::from_xywh( rect.x(), rect.y(), rect.width(), PANE_PATH_H, )), Rect::from_xywh( rect.x(), rect.y() + PANE_PATH_H, rect.width(), (rect.height() - PANE_PATH_H).max(0.0), ), ) } else { (None, rect) }; let preview = if pane.preview && body.width() > 560.0 { Some(Rect::from_xywh( body.right() - PREVIEW_W, body.y(), PREVIEW_W, body.height(), )) } else { None }; let items = match preview { Some(p) => Rect::from_xywh( body.x() + 6.0, body.y() + 4.0, (p.x() - body.x() - 12.0).max(1.0), (body.height() - 8.0).max(1.0), ), None => Rect::from_xywh( body.x() + 6.0, body.y() + 4.0, (body.width() - 12.0).max(1.0), (body.height() - 8.0).max(1.0), ), }; let _ = theme; PaneRects { path, items, preview, } } // --------------------------------------------------------------------------- // Rysowanie // --------------------------------------------------------------------------- pub fn paint(app: &App, scene: &mut Scene, size: Size, theme: &Theme, text: &TextEngine) { let lay = layout(app, size, theme, text); let ch = chrome(app, &lay, theme); let mut ctx = PaintCtx::new(scene, theme, text); ctx.scene .fill_rect(Rect::from_size(size), theme.colors.window_bg); paint_header(&mut ctx, app, &lay); paint_nav(&mut ctx, app, &ch); paint_sidebar(&mut ctx, app, &lay); paint_panes(&mut ctx, app, &lay); paint_status(&mut ctx, app, &lay); if let Some(context) = &app.context { context.menu.paint(&mut ctx, context.panel); } app.toast.paint(&mut ctx, size); if let Some(conflict) = &app.conflict { conflict.dialog.paint(&mut ctx, size); } if let Some(confirm) = &app.confirm_empty { confirm.paint(&mut ctx, size); } if app.settings_open { paint_settings(&mut ctx, app, size); } if let Some(about) = &app.about { about.paint(&mut ctx, size); } } fn paint_header(ctx: &mut PaintCtx, app: &App, lay: &Layout) { let multi = app.pane().tabs.len() > 1; // Jeden pasek: tło obejmuje też dolny wiersz, gdy są zakładki. ctx.scene .fill_rect(lay.header, ctx.theme.colors.headerbar_bg); // Kontrolki okna rysuje widget — bez tytułu, bo w jednym wierszu jest pasek ścieżki. HeaderBar::new() .controls(ControlsSide::Right) .paint(ctx, lay.top, app.controls_hover); if multi { // Oba wiersze mają wyglądać jak jeden pasek: zamalowuj linię pod górnym. ctx.scene.fill_rect( Rect::from_xywh(lay.top.x(), lay.top.bottom() - 1.0, lay.top.width(), 1.0), ctx.theme.colors.headerbar_bg, ); paint_tabs(ctx, app, lay); } // Jedna linia pod całym paskiem (nie dwie). ctx.scene.fill_rect( Rect::from_xywh( lay.header.x(), lay.header.bottom() - 1.0, lay.header.width(), 1.0, ), ctx.theme.colors.border, ); } fn paint_tabs(ctx: &mut PaintCtx, app: &App, lay: &Layout) { tab_bar(app).paint(ctx, lay.tabs); icon_button( ctx, lay.plus, IconName::Plus, true, false, is_hovered(app, lay.plus), ); } fn paint_nav(ctx: &mut PaintCtx, app: &App, ch: &Chrome) { let pane = app.pane(); // Przełącznik paska bocznego (jak w aplikacjach libLuny). let sidebar_bg = if is_hovered(app, ch.sidebar_toggle) { ctx.theme.colors.shade.scale_alpha(1.6) } else { ctx.theme.colors.shade }; ctx.scene .fill_rounded_rect(ch.sidebar_toggle, ctx.theme.radius.small, sidebar_bg); sidebar_icon( ctx, ctx.theme.colors.text, ch.sidebar_toggle, app.sidebar_visible, ); icon_button( ctx, ch.back, IconName::ChevronLeft, !pane.back.is_empty(), false, is_hovered(app, ch.back), ); icon_button( ctx, ch.forward, IconName::ChevronRight, !pane.forward.is_empty(), false, is_hovered(app, ch.forward), ); icon_up( ctx, ch.up, pane.dir.parent().is_some(), is_hovered(app, ch.up), ); if let Some(edit) = &pane.edit { edit.entry.paint(ctx, ch.path); } else if pane.search_active { pane.search.paint(ctx, ch.path); } else { let bar = PathBar::new(path_components(&pane.dir.path)); bar.paint(ctx, ch.path); } icon_button( ctx, ch.search, IconName::Search, true, pane.search_active, is_hovered(app, ch.search), ); let view_icon = match pane.view.mode() { ViewMode::List => IconName::Grid, ViewMode::Grid => IconName::List, }; icon_button( ctx, ch.view, view_icon, true, false, is_hovered(app, ch.view), ); icon_button( ctx, ch.menu, IconName::Menu, true, false, is_hovered(app, ch.menu), ); } fn paint_sidebar(ctx: &mut PaintCtx, app: &App, lay: &Layout) { if lay.sidebar.width() <= 0.0 { return; } ctx.scene .fill_rect(lay.sidebar, ctx.theme.colors.sidebar_bg); ctx.scene.fill_rect( Rect::from_xywh( lay.sidebar.right() - 1.0, lay.sidebar.y(), 1.0, lay.sidebar.height(), ), ctx.theme.colors.border, ); app.sidebar .paint(ctx, lay.sidebar.deflate(Insets::splat(6.0))); } fn paint_panes(ctx: &mut PaintCtx, app: &App, lay: &Layout) { let count = if app.split { 2 } else { 1 }; for i in 0..count { let pane = &app.panes[i]; let rect = lay.panes[i]; let pr = pane_rects(app, lay, i, ctx.theme); if app.split { let active = i == app.active; ctx.scene.stroke_rounded_rect( rect, 6.0, if active { 1.5 } else { 1.0 }, if active { ctx.theme.colors.accent } else { ctx.theme.colors.border }, ); if let Some(path) = pr.path { ctx.scene.fill_rect(path, ctx.theme.colors.headerbar_bg); let title = path_text(&pane.dir.path); ctx.label_left( path.x() + 10.0, path.center().y, &title, ctx.theme.typography.caption.size, weight::MEDIUM, ctx.theme.colors.dim_text, ); } } // Pasek przewijania i zawartość (lista ma własne kolumny). let rows = file_rows(pane, pr.items); let mut sv = pane.scroll.clone(); sv.set_content_height(pane.view.content_height(rows, ctx.theme)); sv.set_offset(pane.view.scroll(), rows); sv.paint_scrollbar(ctx, rows); if pane.view.mode() == ViewMode::List { list::paint( ctx, pr.items, &pane.dir, pane.view.selected_indices(), pane.view.hovered(), pane.view.scroll(), ctx.theme, ); } else { pane.view.paint(ctx, rows); } if pane.view.is_empty() { let msg = if !pane.dir.filter().is_empty() { libluna_i18n::tr("No matching files") } else { libluna_i18n::tr("Folder is empty") }; ctx.label_center( rows.center().x, rows.center().y, &msg, ctx.theme.typography.body.size, weight::NORMAL, ctx.theme.colors.dim_text, ); } if let Some(prect) = pr.preview { paint_preview(ctx, pane, prect); } } } fn paint_preview(ctx: &mut PaintCtx, pane: &Pane, rect: Rect) { ctx.scene .fill_rounded_rect(rect, 8.0, ctx.theme.colors.card_bg); ctx.scene .stroke_rounded_rect(rect, 8.0, 1.0, ctx.theme.colors.border); let inner = rect.deflate(Insets::splat(10.0)); let Some(entry) = pane.primary() else { ctx.label_center( inner.center().x, inner.center().y, &libluna_i18n::tr("No preview"), ctx.theme.typography.body.size, weight::NORMAL, ctx.theme.colors.dim_text, ); return; }; let body = ctx.theme.typography.body.size; let caption = ctx.theme.typography.caption.size; ctx.label_left( inner.x(), inner.y() + 8.0, &entry.name, body, weight::BOLD, ctx.theme.colors.text, ); let below = Rect::from_xywh( inner.x(), inner.y() + 22.0, inner.width(), (inner.height() - 22.0).max(1.0), ); match pane.preview_cache.as_ref().map(|(_, p)| p) { Some(crate::app::Preview::Text(text)) => { ctx.scene.push_clip(below); let line_h = body * 1.25; let max_lines = (below.height() / line_h).floor().max(1.0) as usize; for (i, line) in text.lines().take(max_lines).enumerate() { ctx.label_left( below.x(), below.y() + i as f64 * line_h + line_h * 0.75, line, body, weight::NORMAL, ctx.theme.colors.text, ); } ctx.scene.pop_clip(); } Some(crate::app::Preview::Image(img)) => { let scale = (below.width() / img.width as f64) .min(below.height() / img.height as f64) .max(0.0); let w = img.width as f64 * scale; let h = img.height as f64 * scale; let target = Rect::from_xywh( below.center().x - w / 2.0, below.center().y - h / 2.0, w.max(1.0), h.max(1.0), ); ctx.scene.image(target, std::sync::Arc::clone(img)); } _ => { let mut y = below.y() + 8.0; for (label, value) in [ (libluna_i18n::tr("Type"), entry.kind.label()), (libluna_i18n::tr("Size"), human_size(entry.size)), (libluna_i18n::tr("Modified"), format_mtime(entry.modified)), (libluna_i18n::tr("Path"), entry.path.display().to_string()), ] { ctx.label_left( below.x(), y, &format!("{label}: {value}"), caption, weight::NORMAL, ctx.theme.colors.dim_text, ); y += caption * 1.8; } } } } fn paint_status(ctx: &mut PaintCtx, app: &App, lay: &Layout) { let colors = ctx.theme.colors; ctx.scene.fill_rect(lay.status, colors.headerbar_bg); ctx.label_left( lay.status.x() + PAD, lay.status.center().y, &app.status, ctx.theme.typography.caption.size, weight::NORMAL, colors.dim_text, ); if let Some((done, total)) = app.op_progress { let ratio = if total > 0 { (done as f64 / total as f64).clamp(0.0, 1.0) } else { 0.0 }; if total > 0 { let bar = Rect::from_xywh( lay.status.x(), lay.status.y() - 3.0, lay.status.width() * ratio, 3.0, ); ctx.scene.fill_rect(bar, colors.accent); } let speed = if app.op_speed > 1.0 { format!("{}/s", human_size(app.op_speed as u64)) } else { String::new() }; let eta = if app.op_speed > 1.0 && total > done { let secs = ((total - done) as f64 / app.op_speed).round() as u64; format!("{:02}:{:02}", secs / 60, secs % 60) } else { String::new() }; // Operacje bez znanego rozmiaru (np. archiwa) nie mają procentów — pokazujemy // samą nazwę zadania i bieżący element. let mut label = if total > 0 { format!( "{} {}%", app.op_label.clone().unwrap_or_default(), (ratio * 100.0).round() as i64 ) } else { app.op_label.clone().unwrap_or_default() }; if !speed.is_empty() { label.push_str(" · "); label.push_str(&speed); } if !eta.is_empty() { label.push_str(" · "); label.push_str(&libluna_i18n::tr_args("{time} left", &[("time", &eta)])); } if !app.op_current.is_empty() { label.push_str(" — "); label.push_str(&app.op_current); } let cancel = op_cancel_rect(lay); ctx.label_right( cancel.x() - PAD, lay.status.center().y, &label, ctx.theme.typography.caption.size, weight::NORMAL, colors.accent, ); icon::paint_icon( ctx, IconName::Close, cancel.deflate(Insets::splat(4.0)), colors.text, ); } } fn icon_button( ctx: &mut PaintCtx, rect: Rect, name: IconName, enabled: bool, active: bool, hover: bool, ) { if enabled && !active && hover { ctx.scene .fill_rounded_rect(rect, 6.0, ctx.theme.colors.shade); } if active && enabled { ctx.scene .fill_rounded_rect(rect, 6.0, ctx.theme.colors.shade); } let color = if enabled { ctx.theme.colors.text } else { ctx.theme.colors.dim_text.scale_alpha(0.5) }; icon::paint_icon(ctx, name, rect.deflate(Insets::splat(6.0)), color); } /// Czy kursor jest nad prostokątem (dla podświetlenia przycisków). fn is_hovered(app: &App, rect: Rect) -> bool { app.pointer.is_some_and(|p| rect.contains(p)) } /// Ikona przełącznika paska bocznego (◧ / ▭) — jak w pozostałych aplikacjach Luny. fn sidebar_icon(ctx: &mut PaintCtx, color: Color, box_: Rect, filled: bool) { let c = box_.center(); let (w, h) = (18.0, 14.0); let outer = Rect::from_xywh(c.x - w / 2.0, c.y - h / 2.0, w, h); ctx.scene.stroke_rounded_rect(outer, 3.0, 1.5, color); if filled { let inner = Rect::from_xywh(outer.x() + 2.0, outer.y() + 2.0, w * 0.36, h - 4.0); ctx.scene.fill_rounded_rect(inner, 2.0, color); } } fn icon_up(ctx: &mut PaintCtx, rect: Rect, enabled: bool, hover: bool) { if enabled && hover { ctx.scene .fill_rounded_rect(rect, 6.0, ctx.theme.colors.shade); } let color = if enabled { ctx.theme.colors.text } else { ctx.theme.colors.dim_text.scale_alpha(0.5) }; icon::chevron_up(ctx, color, rect.center(), 5.0, 1.6); } /// Przycisk anulowania operacji w belce statusu. fn op_cancel_rect(lay: &Layout) -> Rect { Rect::from_xywh( lay.status.right() - PAD - 18.0, lay.status.center().y - 9.0, 18.0, 18.0, ) } // --------------------------------------------------------------------------- // Ścieżki i zakładki // --------------------------------------------------------------------------- fn tab_title(path: &Path) -> String { if path == Path::new("/") { "/".to_owned() } else if path == places::home() { "~".to_owned() } else { path.file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()) } } fn path_text(path: &Path) -> String { if path == places::home() { return "~".to_owned(); } if let Ok(rest) = path.strip_prefix(places::home()) { return format!("~/{}", rest.display()); } path.display().to_string() } fn path_components(path: &Path) -> Vec { let home = places::home(); if path == home { return vec!["~".to_owned()]; } if let Ok(rest) = path.strip_prefix(&home) { let mut out = vec!["~".to_owned()]; for c in rest.components() { if let Component::Normal(s) = c { out.push(s.to_string_lossy().into_owned()); } } return out; } let mut out = vec!["/".to_owned()]; for c in path.components() { if let Component::Normal(s) = c { out.push(s.to_string_lossy().into_owned()); } } if out.len() == 1 { return vec!["/".to_owned()]; } out } fn segment_path(path: &Path, index: usize) -> PathBuf { let comps = path_components(path); if index == 0 { return if comps[0] == "~" { places::home() } else { PathBuf::from("/") }; } let base = if comps[0] == "~" { places::home() } else { PathBuf::from("/") }; let mut out = base; for c in comps.iter().skip(1).take(index) { out.push(c); } out } // --------------------------------------------------------------------------- // Zdarzenia // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Ustawienia / O aplikacji // --------------------------------------------------------------------------- struct SettingsLayout { rows: [Rect; 4], theme_seg: Rect, view_seg: Rect, lang_seg: Rect, hidden_switch: Rect, done: Rect, } fn settings_dialog() -> PreferencesDialog { PreferencesDialog::new(libluna_i18n::tr("Settings"), 376.0) } fn settings_layout(size: Size, theme: &Theme) -> SettingsLayout { let rects = settings_dialog().rects(size, theme); let pad = theme.spacing.lg; let inner = rects.content.deflate(Insets::new(pad, pad, pad, pad)); let row_h = 46.0; let rows: [Rect; 4] = std::array::from_fn(|i| { Rect::from_xywh( inner.x(), inner.y() + i as f64 * row_h, inner.width(), row_h, ) }); let seg_w = 232.0_f64.min(inner.width() * 0.55); let seg = |i: usize| { Rect::from_xywh( inner.right() - seg_w, rows[i].center().y - 15.0, seg_w, 30.0, ) }; let (sw, sh) = Switch::new(false).size(theme); let hidden_switch = Rect::from_xywh(inner.right() - sw, rows[3].center().y - sh / 2.0, sw, sh); let bh = theme.metrics.button.height; let done = Rect::from_xywh(inner.right() - 120.0, inner.bottom() - bh, 120.0, bh); SettingsLayout { rows, theme_seg: seg(0), view_seg: seg(1), lang_seg: seg(2), hidden_switch, done, } } fn paint_settings(ctx: &mut PaintCtx, app: &App, size: Size) { settings_dialog().paint(ctx, size); let theme = *ctx.theme; let lay = settings_layout(size, &theme); let body = theme.typography.body.size; let labels = [ libluna_i18n::tr("Theme"), libluna_i18n::tr("View"), libluna_i18n::tr("Language"), libluna_i18n::tr("Show hidden files"), ]; for (i, label) in labels.iter().enumerate() { ctx.label_left( lay.rows[i].x() + 12.0, lay.rows[i].center().y, label, body, weight::NORMAL, theme.colors.text, ); if i > 0 { ctx.scene.fill_rect( Rect::from_xywh(lay.rows[i].x(), lay.rows[i].y(), lay.rows[i].width(), 1.0), theme.colors.border, ); } } let mut theme_switcher = ViewSwitcher::new([ libluna_i18n::tr("Light"), libluna_i18n::tr("Dark"), libluna_i18n::tr("Auto"), ]); let theme_index = ThemePreference::ALL .iter() .position(|p| *p == app.theme_pref) .unwrap_or(2); theme_switcher.set_selected(theme_index); theme_switcher.paint(ctx, lay.theme_seg); let mut view_switcher = ViewSwitcher::new([libluna_i18n::tr("List"), libluna_i18n::tr("Grid")]); view_switcher.set_selected(match app.pane().view.mode() { ViewMode::List => 0, ViewMode::Grid => 1, }); view_switcher.paint(ctx, lay.view_seg); // Nazwy języków pokazujemy natywnie — tak jak w systemowych ustawieniach. let mut lang_switcher = ViewSwitcher::new(["Polski", "English", "Deutsch"]); lang_switcher.set_selected(prefs::language_index(&app.prefs.language)); lang_switcher.paint(ctx, lay.lang_seg); Switch::new(app.pane().dir.show_hidden()).paint(ctx, lay.hidden_switch); Button::new(libluna_i18n::tr("Done")).paint(ctx, lay.done); } /// Obsługuje klik w otwartym oknie Ustawień (modal blokuje resztę). fn on_settings_press(app: &mut App, p: Point, size: Size, theme: &Theme) { let lay = settings_layout(size, theme); let dialog = settings_dialog(); if dialog.close_at(size, theme, p) || dialog.scrim_at(size, theme, p) || lay.done.contains(p) { app.settings_open = false; return; } let theme_switcher = ViewSwitcher::new([ libluna_i18n::tr("Light"), libluna_i18n::tr("Dark"), libluna_i18n::tr("Auto"), ]); if let Some(i) = theme_switcher.index_at(lay.theme_seg, p) { let pref = ThemePreference::ALL[i]; app.theme_pref = pref; app.pending_theme = Some(pref); return; } let view_switcher = ViewSwitcher::new([libluna_i18n::tr("List"), libluna_i18n::tr("Grid")]); if let Some(i) = view_switcher.index_at(lay.view_seg, p) { app.set_view(if i == 0 { ViewMode::List } else { ViewMode::Grid }); return; } let lang_switcher = ViewSwitcher::new(["Polski", "English", "Deutsch"]); if let Some(i) = lang_switcher.index_at(lay.lang_seg, p) { app.set_language(prefs::LANGUAGES[i]); return; } if lay.hidden_switch.contains(p) { let on = app.pane().dir.show_hidden(); app.set_show_hidden(!on); } } pub fn handle_events( app: &mut App, events: &[Event], size: Size, theme: &Theme, text: &TextEngine, ) { let lay = layout(app, size, theme, text); let ch = chrome(app, &lay, theme); // Modale (Ustawienia, O aplikacji) pochłaniają całe wejście. if app.settings_open { for event in events { if let Event::PointerButton(pe) = event { if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed { on_settings_press(app, pe.position, size, theme); } } } return; } if app.about.is_some() { for event in events { if let Event::PointerButton(pe) = event { if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed { let close = app.about.as_ref().is_some_and(|about| { let r = about.rects(size, theme, text); about.action_at(size, theme, text, pe.position).is_some() || r.dialog.close.contains(pe.position) || !r.dialog.panel.contains(pe.position) }); if close { app.about = None; } } } } return; } // Modal konfliktu blokuje resztę interfejsu. if app.conflict.is_some() { for event in events { if let Event::PointerButton(pe) = event { if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed { let index = app .conflict .as_ref() .and_then(|c| c.dialog.action_at(size, theme, text, pe.position)); if let Some(index) = index { app.answer_conflict(index); } } } } return; } // Modal potwierdzenia (opróżnienie kosza). if app.confirm_empty.is_some() { for event in events { if let Event::PointerButton(pe) = event { if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed { let index = app .confirm_empty .as_ref() .and_then(|d| d.action_at(size, theme, text, pe.position)); match index { Some(0) => { if crate::app::debug() { eprintln!("luna-files: confirm empty trash"); } let _ = trash::empty_trash(); app.confirm_empty = None; app.reload_all(); app.refresh_devices(); app.show_toast(libluna_i18n::tr("Trash emptied")); } Some(_) => { if crate::app::debug() { eprintln!("luna-files: confirm cancelled"); } app.confirm_empty = None; } None => {} } } } } return; } for event in events { match event { Event::PointerButton(pe) => match (pe.button, pe.state) { (PointerButton::Left, ButtonState::Pressed) => { on_left_press(app, pe.position, pe.modifiers, &lay, &ch, size, theme, text) } (PointerButton::Right, ButtonState::Pressed) => { on_right_press(app, pe.position, &lay, size, theme, text) } (PointerButton::Middle, ButtonState::Pressed) => { on_middle_press(app, pe.position, &lay, theme) } (PointerButton::Back, ButtonState::Pressed) => app.back(), (PointerButton::Forward, ButtonState::Pressed) => app.forward(), (_, ButtonState::Released) => {} _ => {} }, Event::PointerMotion { position, .. } => { let p = *position; app.pointer = Some(p); // Podświetlenie kontrolek okna (— □ ✕): `HeaderBar::paint` rysuje je // tylko dla kontrolki przekazanej jako `hovered`. app.controls_hover = if lay.top.contains(p) { HeaderBar::new() .controls(ControlsSide::Right) .control_at(lay.top, theme, p) } else { None }; if let Some(context) = app.context.as_mut() { context.menu.hover_at(context.panel, Some(p), theme); continue; } let tab_hover = if app.pane().tabs.len() > 1 && lay.tabs.contains(p) { tab_bar(app).index_at(lay.tabs, text, theme, p) } else { None }; app.tab_hover = tab_hover; let _hovered = app.sidebar .index_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme); app.sidebar.set_hovered(_hovered); for i in 0..if app.split { 2 } else { 1 } { let pr = pane_rects(app, &lay, i, theme); let rows = file_rows(&app.panes[i], pr.items); let hovered = hit_index(&app.panes[i], rows, p, theme); app.panes[i].view.set_hovered(hovered); app.panes[i] .search .set_hover_clear(app.panes[i].search.clear_at(ch.path, theme, p)); } } Event::PointerLeave => { app.pointer = None; app.controls_hover = None; app.tab_hover = None; app.sidebar.set_hovered(None); for pane in &mut app.panes { pane.view.set_hovered(None); } } Event::Scroll(se) => { let count = if app.split { 2 } else { 1 }; for i in 0..count { let pr = pane_rects(app, &lay, i, theme); let rows = file_rows(&app.panes[i], pr.items); if !rows.contains(se.position) { continue; } app.active = i; let step = theme.metrics.itemview.row_h; let pane = &mut app.panes[i]; let max = pane.view.max_scroll(rows, theme); let next = (pane.view.scroll() + se.dy * step).clamp(0.0, max); pane.view.set_scroll(next); pane.scroll.set_offset(next, rows); break; } } Event::Key(ke) if ke.state == ButtonState::Pressed => { on_key(app, ke, &lay, &ch, size, theme, text) } _ => {} } } } fn on_left_press( app: &mut App, p: Point, mods: Modifiers, lay: &Layout, ch: &Chrome, size: Size, theme: &Theme, text: &TextEngine, ) { // Anulowanie trwającej operacji ma pierwszeństwo. if app.op_progress.is_some() && op_cancel_rect(lay).contains(p) { app.cancel_op(); return; } // 1. Menu kontekstowe ma pierwszeństwo. if let Some(context) = &mut app.context { if let Some(index) = context.menu.click_at(context.panel, p, theme) { run_menu(app, index); } else if !context.panel.contains(p) { app.context = None; } return; } // 2. Kontrolki okna (górny wiersz paska). let header = HeaderBar::new().controls(ControlsSide::Right); if lay.top.contains(p) { if let Some(control) = header.control_at(lay.top, theme, p) { match control { WindowControl::Close => app.pending_close = true, WindowControl::Minimize => app.pending_minimize = true, WindowControl::Maximize => app.pending_maximize = true, } return; } } // 3. Zakładki (tylko gdy jest ich więcej niż jedna). if app.pane().tabs.len() > 1 && lay.top.contains(p) { if lay.plus.contains(p) { app.new_tab(); return; } let bar = tab_bar(app); if let Some(i) = bar.close_at(lay.tabs, text, theme, p) { app.select_tab(i); app.close_tab(); return; } if let Some(i) = bar.index_at(lay.tabs, text, theme, p) { app.select_tab(i); return; } } // 4. Pasek nawigacji (przy jednej zakładce w tym samym wierszu co kontrolki). if lay.nav.contains(p) { if ch.sidebar_toggle.contains(p) { app.sidebar_visible = !app.sidebar_visible; return; } if ch.back.contains(p) { app.back(); return; } if ch.forward.contains(p) { app.forward(); return; } if ch.up.contains(p) { app.up(); return; } if ch.search.contains(p) { let pane = app.pane_mut(); pane.search_active = !pane.search_active; pane.search.set_focused(pane.search_active); if !pane.search_active { pane.search.clear(); pane.dir.set_filter(""); pane.sync_view(); } return; } if ch.view.contains(p) { let pane = app.pane_mut(); pane.view.toggle_mode(); return; } if ch.menu.contains(p) { open_menu(app, p, MenuTarget::AppMenu, size, theme, text); return; } if ch.path.contains(p) { let pane = app.pane_mut(); if pane.edit.is_some() { pane.edit.as_mut().unwrap().entry.set_focused(true); return; } if pane.search_active { pane.search.set_focused(true); return; } let path = pane.dir.path.clone(); let comps = PathBar::new(path_components(&path)); if let Some(i) = comps.index_at(ch.path, text, theme, p) { let target = segment_path(&path, i); app.navigate(app.active, target); return; } // Puste miejsce paska ścieżki (między segmentami) to nasz „pasek tytułu” // — bez tego okna nie dało się przesunąć, bo pasek zajmuje cały wiersz. app.pending_move = true; return; } } // 5. Reszta paska (wolne miejsce, także dolny wiersz z zakładkami) // przeciąga okno — dokładnie jak pasek tytułu. if lay.header.contains(p) { app.pending_move = true; return; } // 6. Panel boczny. if lay.sidebar.contains(p) { let entry = app .sidebar .click_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme); if entry { let idx = app.sidebar.selected(); if let Some(action) = idx .and_then(|e| app.sidebar_entry_action.get(e).copied().flatten()) .and_then(|ai| app.sidebar_actions.get(ai)) { match action { SideAction::Go(path) => { let path = path.clone(); app.navigate(app.active, path); } SideAction::Unmount(i) => { if let Some(m) = app.mounts.get(*i) { places::unmount_device(&m.dev); app.show_toast(libluna_i18n::tr("Unmounting…")); } } SideAction::Mount(i) => { if let Some(m) = app.mounts.get(*i) { places::mount_device(&m.dev); app.show_toast(libluna_i18n::tr("Mounting…")); } } } } } return; } // 6. Panele. let count = if app.split { 2 } else { 1 }; for i in 0..count { if !lay.panes[i].contains(p) { continue; } app.active = i; let pr = pane_rects(app, lay, i, theme); // Klik w nagłówek kolumny sortuje (tak jak w Nautilusie). if app.panes[i].view.mode() == ViewMode::List && p.y < pr.items.y() + list::HEADER_H && pr.items.contains(p) { if let Some(key) = list::header_at(pr.items, p, theme) { let pane = &mut app.panes[i]; if pane.dir.sort_key() == key { let asc = pane.dir.ascending(); pane.dir.set_ascending(!asc); } else { pane.dir.set_sort_key(key); // Rozmiar i data naturalnie malejąco. pane.dir .set_ascending(!matches!(key, SortKey::Size | SortKey::Modified)); } pane.sync_view(); if crate::app::debug() { eprintln!( "luna-files: sort {:?} asc={}", pane.dir.sort_key(), pane.dir.ascending() ); } } return; } let rows = file_rows(&app.panes[i], pr.items); if rows.contains(p) { let index = hit_index(&app.panes[i], rows, p, theme); let Some(index) = index else { app.panes[i].view.clear_selection(); app.panes[i].preview_cache = None; return; }; let now = Instant::now(); let double = app .last_click .map(|(last, t)| { last == index && now.duration_since(t) < Duration::from_millis(400) }) .unwrap_or(false); if double { app.last_click = None; let entry = app.panes[i].dir.entry(index).cloned(); if let Some(entry) = entry { if entry.is_dir { app.navigate(i, entry.path); } else { let _ = std::process::Command::new("xdg-open") .arg(&entry.path) .spawn(); } } } else { app.last_click = Some((index, now)); if mods.ctrl { app.panes[i].view.toggle_selected(index); } else if mods.shift { app.panes[i].view.select_range(index); } else { app.panes[i].view.set_selected(Some(index)); } app.panes[i].preview_cache = None; } return; } // Klik poza listą czyści zaznaczenie. app.panes[i].view.clear_selection(); app.panes[i].preview_cache = None; return; } } fn on_right_press( app: &mut App, p: Point, lay: &Layout, size: Size, theme: &Theme, text: &TextEngine, ) { if let Some(context) = &app.context { if context.panel.contains(p) { return; } app.context = None; } // Panel boczny: menu urządzenia. if lay.sidebar.contains(p) { let entry = app .sidebar .index_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme); if let Some(entry) = entry { app.sidebar.set_selected(Some(entry)); if let Some(ai) = app.sidebar_entry_action.get(entry).copied().flatten() { if let Some(SideAction::Go(path)) = app.sidebar_actions.get(ai) { if let Some(mi) = app.mounts.iter().position(|m| &m.mount == path) { open_menu(app, p, MenuTarget::Device(mi), size, theme, text); return; } } } } return; } let count = if app.split { 2 } else { 1 }; for i in 0..count { if !lay.panes[i].contains(p) { continue; } app.active = i; let pr = pane_rects(app, lay, i, theme); let rows = file_rows(&app.panes[i], pr.items); if let Some(index) = hit_index(&app.panes[i], rows, p, theme) { if !app.panes[i].view.is_selected(index) { app.panes[i].view.set_selected(Some(index)); } if let Some(entry) = app.panes[i].dir.entry(index).cloned() { let in_trash = app.panes[i].dir.path == trash::trash_root().join("files"); let target = if in_trash { MenuTarget::TrashItem(entry.path) } else { MenuTarget::Item(entry.path, entry.is_dir) }; open_menu(app, p, target, size, theme, text); } } else { open_menu(app, p, MenuTarget::Background, size, theme, text); } return; } } /// Środkowy przycisk myszy: otwiera folder (albo miejsce z panelu bocznego) /// w nowej karcie — jak w Nautilusie. Dla plików nic nie robi. fn on_middle_press(app: &mut App, p: Point, lay: &Layout, theme: &Theme) { let count = if app.split { 2 } else { 1 }; for i in 0..count { if !lay.panes[i].contains(p) { continue; } let pr = pane_rects(app, lay, i, theme); let rows = file_rows(&app.panes[i], pr.items); if let Some(index) = hit_index(&app.panes[i], rows, p, theme) { if let Some(entry) = app.panes[i].dir.entry(index) { let path = entry.path.clone(); if entry.is_dir { app.new_tab_at(path); } } } return; } if lay.sidebar.contains(p) { let hovered = app .sidebar .index_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme); if let Some(entry) = hovered { if let Some(action) = app.sidebar_entry_action.get(entry).copied().flatten() { if let Some(SideAction::Go(path)) = app.sidebar_actions.get(action) { let path = path.clone(); app.new_tab_at(path); } } } } } fn on_key( app: &mut App, ke: &KeyEvent, lay: &Layout, ch: &Chrome, _size: Size, theme: &Theme, text: &TextEngine, ) { // Menu kontekstowe: Escape zamyka. if app.context.is_some() { if ke.keysym == 0xff1b { app.context = None; } return; } // Escape anuluje trwającą operację (gdy nie edytujemy tekstu). if ke.keysym == 0xff1b && app.ops.busy() && app.pane().edit.is_none() && !app.pane().search_active && app.conflict.is_none() { app.cancel_op(); return; } // Pole edycji (ścieżka / nazwa). if app.pane().edit.is_some() { if ke.keysym == 0xff1b { app.pane_mut().edit = None; return; } if ke.keysym == 0xff0d { apply_edit(app); return; } let handled = { let pane = app.pane_mut(); let edit = pane.edit.as_mut().unwrap(); handle_text_key(&mut edit.entry, ke) }; if handled { return; } } // Wyszukiwanie. if app.pane().search_active && app.pane().search.is_focused() { if ke.keysym == 0xff1b || ke.keysym == 0xff0d { let pane = app.pane_mut(); pane.search_active = false; pane.search.set_focused(false); pane.search.clear(); pane.dir.set_filter(""); pane.sync_view(); return; } let handled = { let pane = app.pane_mut(); handle_text_key(&mut pane.search, ke) }; if handled { let query = app.pane().search.text().to_owned(); let pane = app.pane_mut(); pane.dir.set_filter(query); pane.sync_view(); return; } } let ctrl = ke.modifiers.ctrl; let shift = ke.modifiers.shift; let alt = ke.modifiers.alt; if ctrl { match ke.keysym { 0x6c => { // Ctrl+L — edycja ścieżki let path = app.pane().dir.path.clone(); app.pane_mut().edit = Some(EditState::path(&path)); } 0x66 => { // Ctrl+F — szukanie let pane = app.pane_mut(); pane.search_active = true; pane.search.set_focused(true); } 0x74 => app.new_tab(), // Ctrl+T 0x77 => app.close_tab(), // Ctrl+W 0x2c => app.settings_open = true, // Ctrl+, 0x63 => copy_selection(app, false), // Ctrl+C 0x78 => copy_selection(app, true), // Ctrl+X 0x76 => paste(app), // Ctrl+V 0x61 => app.pane_mut().view.select_all(), // Ctrl+A 0x68 => toggle_hidden(app), // Ctrl+H 0x6e => new_folder_edit(app), // Ctrl+N 0x72 => app.reload_pane(app.active), // Ctrl+R _ => {} } return; } match ke.keysym { 0x72 => app.reload_pane(app.active), // r 0xff0d => app.open_selected(), // Enter 0xff08 => app.up(), // Backspace 0xffff => { // Delete — do kosza (Shift = trwale) if shift { delete_selection(app, true); } else { delete_selection(app, false); } } 0xff1b => { let pane = app.pane_mut(); pane.preview = false; pane.preview_cache = None; pane.view.clear_selection(); } 0x20 => { // Space — podgląd let pane = app.pane_mut(); pane.preview = !pane.preview; pane.preview_cache = None; } 0xffbe => {} // F1 0xffbf => { // F2 — zmiana nazwy if let Some(entry) = app.pane().primary().cloned() { app.pane_mut().edit = Some(EditState::rename(&entry.path)); } } 0xffc0 => { // F3 — dwa panele app.split = !app.split; if !app.split { app.active = 0; } } 0xffc1 => app.open_terminal(), // F4 0xffc2 => app.reload_pane(app.active), // F5 0xffc6 => app.sidebar_visible = !app.sidebar_visible, // F9 0xff51 => { if alt { app.back(); } else { move_selection(app, -1, shift, lay, theme); } } 0xff53 => { if alt { app.forward(); } else { move_selection(app, 1, shift, lay, theme); } } 0xff52 => move_selection(app, -1, shift, lay, theme), 0xff54 => move_selection(app, 1, shift, lay, theme), 0xff55 => move_selection(app, -10, shift, lay, theme), 0xff56 => move_selection(app, 10, shift, lay, theme), 0xff50 => select_boundary(app, false, shift, lay, theme), 0xff57 => select_boundary(app, true, shift, lay, theme), 0xff09 => { // Tab — następna zakładka (bez Ctrl) let next = { let p = app.pane(); (p.tab + 1) % p.tabs.len().max(1) }; app.select_tab(next); } _ => { // Pisanie bez fokusu pola = szybkie wyszukiwanie. if let Some(t) = &ke.text { if !t.is_empty() && !t.chars().all(char::is_control) { let pane = app.pane_mut(); pane.search_active = true; pane.search.set_focused(true); pane.search.insert_str(t); let query = pane.search.text().to_owned(); pane.dir.set_filter(query); pane.sync_view(); } } } } // Pasek edycji ma priorytet nad resztą — nic więcej nie rób. if app.pane().edit.is_some() { let _ = ch; let _ = text; } } fn handle_text_key(entry: &mut T, ke: &KeyEvent) -> bool { let shift = ke.modifiers.shift; let ctrl = ke.modifiers.ctrl; if ctrl && ke.keysym == 0x61 { entry.select_all(); return true; } if !ctrl { if let Some(t) = &ke.text { if !t.is_empty() && !t.chars().all(char::is_control) { entry.insert_str(t); return true; } } } match ke.keysym { 0xff08 => entry.backspace(), 0xffff => entry.delete_forward(), 0xff51 => { if shift { entry.move_left_selecting(true) } else { entry.move_left() } } 0xff53 => { if shift { entry.move_right_selecting(true) } else { entry.move_right() } } 0xff50 => { if shift { entry.home_selecting(true) } else { entry.home() } } 0xff57 => { if shift { entry.end_selecting(true) } else { entry.end() } } _ => return false, } true } fn apply_edit(app: &mut App) { let dir = app.pane().dir.path.clone(); let edit = app.pane_mut().edit.take(); let Some(edit) = edit else { return }; let text = edit.entry.text().trim().to_owned(); if text.is_empty() { return; } if let Some(from) = edit.rename { let to = from .parent() .map(|p| p.join(&text)) .unwrap_or_else(|| dir.join(&text)); if to != from { app.submit(Job::Rename { from, to }); } } else if edit.new_folder { app.submit(Job::Mkdir { path: dir.join(&text), }); } else { let target = if text.starts_with('/') { PathBuf::from(&text) } else if text == "~" { places::home() } else if let Some(rest) = text.strip_prefix("~/") { places::home().join(rest) } else { dir.join(&text) }; app.navigate(app.active, target); } } fn move_selection(app: &mut App, delta: i32, extend: bool, lay: &Layout, theme: &Theme) { let pr = pane_rects(app, lay, app.active, theme); let rows = file_rows(&app.panes[app.active], pr.items); let p = &mut app.panes[app.active]; let len = p.view.len(); if len == 0 { return; } let current = p.view.selected().unwrap_or(0) as i32; let next = (current + delta).clamp(0, len as i32 - 1) as usize; if extend { p.view.select_range(next); } else { p.view.set_selected(Some(next)); } let slot = p.view.slot_rect(rows, next, theme); if slot.y() < rows.y() { let d = rows.y() - slot.y(); p.view.set_scroll(p.view.scroll() - d); } else if slot.bottom() > rows.bottom() { let d = slot.bottom() - rows.bottom(); p.view.set_scroll(p.view.scroll() + d); } p.view.clamp_scroll(rows, theme); p.preview_cache = None; } fn select_boundary(app: &mut App, end: bool, extend: bool, lay: &Layout, theme: &Theme) { let pr = pane_rects(app, lay, app.active, theme); let rows = file_rows(&app.panes[app.active], pr.items); let p = &mut app.panes[app.active]; if p.view.len() == 0 { return; } let index = if end { p.view.len() - 1 } else { 0 }; if extend { p.view.select_range(index); } else { p.view.set_selected(Some(index)); } let slot = p.view.slot_rect(rows, index, theme); if slot.y() < rows.y() { let d = rows.y() - slot.y(); p.view.set_scroll(p.view.scroll() - d); } else if slot.bottom() > rows.bottom() { let d = slot.bottom() - rows.bottom(); p.view.set_scroll(p.view.scroll() + d); } p.view.clamp_scroll(rows, theme); p.preview_cache = None; } fn toggle_hidden(app: &mut App) { let pane = app.pane_mut(); let show = !pane.dir.show_hidden(); pane.dir.set_show_hidden(show); pane.sync_view(); } fn new_folder_edit(app: &mut App) { let dir = app.pane().dir.path.clone(); let mut edit = EditState::path(&dir); let mut entry = TextEntry::new(libluna_i18n::tr("Name")); entry.set_text(libluna_i18n::tr("New Folder")); entry.set_focused(true); entry.select_all(); edit.entry = entry; edit.rename = None; edit.new_folder = true; app.pane_mut().edit = Some(edit); } fn copy_selection(app: &mut App, cut: bool) { let paths = app.pane().selection(); if paths.is_empty() { return; } let n = paths.len(); app.clipboard = Some(crate::app::Clipboard { paths, cut }); app.show_toast(if cut { libluna_i18n::tr_args("{n} items cut", &[("n", &n.to_string())]) } else { libluna_i18n::tr_args("{n} items copied", &[("n", &n.to_string())]) }); } fn paste(app: &mut App) { let Some(clip) = app.clipboard.clone() else { return; }; let dst = app.pane().dir.path.clone(); if clip.cut { app.submit(Job::Move { srcs: clip.paths.clone(), dst, }); app.clipboard = None; } else { app.submit(Job::Copy { srcs: clip.paths, dst, }); } } fn delete_selection(app: &mut App, permanent: bool) { let paths = app.pane().selection(); if paths.is_empty() { return; } if permanent { app.submit(Job::Delete { paths }); } else { app.submit(Job::Trash { paths }); } } // --------------------------------------------------------------------------- // Menu // --------------------------------------------------------------------------- fn open_menu( app: &mut App, at: Point, target: MenuTarget, size: Size, theme: &Theme, text: &TextEngine, ) { let (entries, cmds) = menu_entries(app, &target); let menu = Menu::new(entries); let width = menu.width(text, theme); let anchor = Rect::from_xywh(at.x, at.y, 1.0, 1.0); let panel = menu.place(anchor, width, Rect::from_size(size), theme); app.context = Some(ContextMenu { menu, panel, target, cmds, }); } struct MenuBuild { entries: Vec, cmds: Vec>, } impl MenuBuild { fn new() -> Self { Self { entries: Vec::new(), cmds: Vec::new(), } } fn action(&mut self, label: impl Into, shortcut: Option<&str>, cmd: MenuCmd) { let mut entry = MenuEntry::action(label); if let Some(shortcut) = shortcut { entry = entry.shortcut(shortcut); } self.entries.push(entry); self.cmds.push(Some(cmd)); } fn disabled(&mut self, label: impl Into) { self.entries.push(MenuEntry::action(label).disabled()); self.cmds.push(None); } fn sep(&mut self) { self.entries.push(MenuEntry::separator()); self.cmds.push(None); } } fn menu_entries(app: &App, target: &MenuTarget) -> (Vec, Vec>) { let tr = libluna_i18n::tr; let mut m = MenuBuild::new(); match target { MenuTarget::Item(..) => { m.action(tr("Open"), Some("Enter"), MenuCmd::Open); m.action(tr("Open in Terminal"), Some("F4"), MenuCmd::OpenTerminal); m.sep(); m.action(tr("Compress to ZIP"), None, MenuCmd::Compress); if let MenuTarget::Item(path, is_dir) = target { if !*is_dir && archive::is_archive(path) { m.action(tr("Extract Here"), None, MenuCmd::ExtractHere); m.action(tr("Extract to Folder"), None, MenuCmd::ExtractTo); } } m.sep(); m.action(tr("Copy"), Some("Ctrl+C"), MenuCmd::Copy); m.action(tr("Cut"), Some("Ctrl+X"), MenuCmd::Cut); m.sep(); m.action(tr("Rename"), Some("F2"), MenuCmd::Rename); m.action(tr("Move to Trash"), Some("Delete"), MenuCmd::Trash); m.action( tr("Delete Permanently"), Some("Shift+Delete"), MenuCmd::Delete, ); m.sep(); m.action(tr("Properties"), None, MenuCmd::Properties); } MenuTarget::TrashItem(_) => { m.action(tr("Restore"), None, MenuCmd::Restore); m.action( tr("Delete Permanently"), Some("Shift+Delete"), MenuCmd::Purge, ); m.sep(); m.action(tr("Properties"), None, MenuCmd::Properties); } MenuTarget::Device(i) => { m.action(tr("Unmount"), None, MenuCmd::Unmount(*i)); m.action(tr("Open"), None, MenuCmd::OpenDevice(*i)); } MenuTarget::Background | MenuTarget::AppMenu => { let pane = app.pane(); if matches!(target, MenuTarget::Background) { if app.clipboard.is_some() { m.action(tr("Paste"), Some("Ctrl+V"), MenuCmd::Paste); } else { m.disabled(tr("Paste")); } m.action(tr("Select All"), Some("Ctrl+A"), MenuCmd::SelectAll); m.sep(); } m.action(tr("New Folder"), Some("Ctrl+N"), MenuCmd::NewFolder); m.action(tr("New Tab"), Some("Ctrl+T"), MenuCmd::NewTab); m.action( if app.split { tr("Single Pane") } else { tr("Split View") }, None, MenuCmd::Split, ); m.sep(); m.action( if pane.dir.show_hidden() { tr("Hide Hidden Files") } else { tr("Show Hidden Files") }, Some("Ctrl+H"), MenuCmd::ToggleHidden, ); m.sep(); for key in [ SortKey::Name, SortKey::Size, SortKey::Modified, SortKey::Kind, ] { let mark = if pane.dir.sort_key() == key { "• " } else { " " }; m.action(format!("{mark}{}", key.label()), None, MenuCmd::Sort(key)); } m.action( if pane.dir.ascending() { tr("Descending") } else { tr("Ascending") }, None, MenuCmd::ToggleAsc, ); m.sep(); m.action(tr("Refresh"), Some("F5"), MenuCmd::Refresh); m.action(tr("Empty Trash"), None, MenuCmd::EmptyTrash); if matches!(target, MenuTarget::AppMenu) { m.sep(); m.action(tr("Settings"), Some("Ctrl+,"), MenuCmd::Settings); m.action(tr("About Files"), None, MenuCmd::About); } } } (m.entries, m.cmds) } fn run_menu(app: &mut App, index: usize) { let Some(context) = app.context.take() else { return; }; let Some(cmd) = context.cmds.get(index).copied().flatten() else { return; }; if crate::app::debug() { eprintln!("luna-files: menu {cmd:?}"); } let target = match &context.target { MenuTarget::Item(path, is_dir) => Some((path.clone(), *is_dir)), MenuTarget::TrashItem(path) => Some((path.clone(), false)), _ => None, }; match cmd { MenuCmd::Open => { if let Some((path, is_dir)) = target { if is_dir { app.navigate(app.active, path); } else { let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); } } } MenuCmd::OpenTerminal => { if let Some((path, is_dir)) = target { let dir = if is_dir { path } else { path.parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| app.pane().dir.path.clone()) }; app.open_terminal_at(&dir); } } MenuCmd::Copy | MenuCmd::Cut => { if let Some((path, _)) = target { app.clipboard = Some(crate::app::Clipboard { paths: vec![path], cut: matches!(cmd, MenuCmd::Cut), }); } } MenuCmd::Rename => { if let Some((path, _)) = target { app.pane_mut().edit = Some(EditState::rename(&path)); } } MenuCmd::Trash => { if let Some((path, _)) = target { app.submit(Job::Trash { paths: vec![path] }); } } MenuCmd::Delete => { if let Some((path, _)) = target { app.submit(Job::Delete { paths: vec![path] }); } } MenuCmd::Restore => { if let Some((path, _)) = target { app.submit(Job::Restore { path }); } } MenuCmd::Purge => { if let Some((path, _)) = target { app.submit(Job::Purge { path }); } } MenuCmd::Properties => { if let Some((path, _)) = target { show_properties(app, &path); } } MenuCmd::Paste => paste(app), MenuCmd::SelectAll => app.pane_mut().view.select_all(), MenuCmd::NewFolder => new_folder_edit(app), MenuCmd::NewTab => app.new_tab(), MenuCmd::Split => { app.split = !app.split; if !app.split { app.active = 0; } } MenuCmd::ToggleHidden => toggle_hidden(app), MenuCmd::Sort(key) => set_sort(app, key), MenuCmd::ToggleAsc => { let asc = app.pane().dir.ascending(); app.pane_mut().dir.set_ascending(!asc); app.pane_mut().sync_view(); } MenuCmd::Refresh => app.reload_pane(app.active), MenuCmd::EmptyTrash => { app.confirm_empty = Some( AlertDialog::new( libluna_i18n::tr("Empty Trash"), libluna_i18n::tr("Permanently delete all items in the Trash?"), ) .button(libluna_i18n::tr("Empty Trash")) .button(libluna_i18n::tr("Cancel")), ); } MenuCmd::Unmount(i) => { if let Some(m) = app.mounts.get(i) { places::unmount_device(&m.dev); app.show_toast(libluna_i18n::tr("Unmounting…")); } } MenuCmd::OpenDevice(i) => { if let Some(m) = app.mounts.get(i) { let mount = m.mount.clone(); app.navigate(app.active, mount); } } MenuCmd::Settings => app.settings_open = true, MenuCmd::About => app.show_about(), MenuCmd::Compress => { if let Some((path, _)) = target { // Kompresujemy całe zaznaczenie, jeśli kliknięty element jest jego częścią. let mut items = app.pane().selection(); if !items.contains(&path) { items = vec![path]; } let dir = app.pane().dir.path.clone(); app.compress(items, &dir); } } MenuCmd::ExtractHere | MenuCmd::ExtractTo => { if let Some((path, _)) = target { app.extract(path, matches!(cmd, MenuCmd::ExtractHere)); } } } } fn set_sort(app: &mut App, key: SortKey) { let pane = app.pane_mut(); pane.dir.set_sort_key(key); pane.sync_view(); } fn show_properties(app: &mut App, path: &Path) { let Ok(meta) = std::fs::metadata(path) else { return; }; let kind = if meta.is_dir() { Kind::Folder.label() } else { libluna_i18n::tr("File") }; let modified = meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64); let msg = libluna_i18n::tr_args( "{name} · {kind} · {size} · {date}", &[ ( "name", &path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(), ), ("kind", &kind), ( "size", &human_size(if meta.is_dir() { 0 } else { meta.len() }), ), ("date", &format_mtime(modified)), ], ); app.show_toast(msg); } #[cfg(test)] mod tests { use super::*; fn app() -> App { App::new(std::env::temp_dir()) } #[test] fn settings_dialog_renders_panel() { let mut app = app(); app.settings_open = true; let theme = Theme::light(); let text = TextEngine::system(); let size = Size::new(1120.0, 720.0); let mut scene = Scene::new(); paint(&app, &mut scene, size, &theme, &text); let mut renderer = libluna_render::SoftwareRenderer::new(1120, 720); renderer.draw(&scene, 1.0, theme.colors.window_bg); let panel = settings_dialog().rects(size, &theme).panel; let px = |x: usize, y: usize| { let i = (y * 1120 + x) * 4; renderer.data()[i..i + 4].to_vec() }; // Wnętrze panelu ma inne tło niź obszar poza modalem (przygaszenie + karta). assert_ne!( px(panel.center().x as usize, panel.center().y as usize), px(10, 700) ); } #[test] fn window_controls_track_hover() { let mut app = app(); let theme = Theme::light(); let text = TextEngine::system(); let size = Size::new(1120.0, 720.0); let lay = layout(&app, size, &theme, &text); let close = HeaderBar::new() .controls(ControlsSide::Right) .controls_layout(lay.top, &theme) .close .unwrap(); handle_events( &mut app, &[Event::PointerMotion { position: close.center(), modifiers: Modifiers::default(), }], size, &theme, &text, ); assert_eq!(app.controls_hover, Some(WindowControl::Close)); assert!(app.pointer.is_some()); handle_events( &mut app, &[Event::PointerMotion { position: Point::new(close.center().x, close.center().y + 300.0), modifiers: Modifiers::default(), }], size, &theme, &text, ); assert_eq!(app.controls_hover, None); } #[test] fn middle_click_opens_folder_in_new_tab() { let dir = std::env::temp_dir().join("luna-files-middle-click"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("podkatalog")).unwrap(); let mut app = App::new(dir.clone()); let theme = Theme::light(); let text = TextEngine::system(); let size = Size::new(1120.0, 720.0); let lay = layout(&app, size, &theme, &text); let pr = pane_rects(&app, &lay, 0, &theme); let rows = file_rows(&app.panes[0], pr.items); let row_h = theme.metrics.itemview.row_h; on_middle_press( &mut app, Point::new(rows.center().x, rows.y() + row_h / 2.0), &lay, &theme, ); assert_eq!(app.pane().tabs.len(), 2); assert_eq!(app.pane().dir.path, dir.join("podkatalog")); assert_eq!(app.pane().tab, 1); let _ = std::fs::remove_dir_all(&dir); } #[test] fn about_dialog_can_be_opened() { let mut app = app(); assert!(app.about.is_none()); app.show_about(); assert!(app.about.is_some()); } #[test] fn settings_controls_stay_inside_dialog() { let theme = Theme::light(); let size = Size::new(1120.0, 720.0); let lay = settings_layout(size, &theme); let dialog = settings_dialog(); let panel = dialog.rects(size, &theme).panel; for row in lay.rows { assert!(panel.contains(row.center()), "wiersz poza panelem"); } assert!(panel.contains(lay.theme_seg.center())); assert!(panel.contains(lay.view_seg.center())); assert!(panel.contains(lay.lang_seg.center())); assert!(panel.contains(lay.hidden_switch.center())); assert!(panel.contains(lay.done.center())); // Kontrolki nie nachodzą na przycisk zamknięcia w nagłówku. let close = dialog.rects(size, &theme).close; assert!(!lay.theme_seg.intersects(&close)); } #[test] fn app_menu_has_settings_and_about_at_the_end() { let app = app(); let (_, cmds) = menu_entries(&app, &MenuTarget::AppMenu); let tail: Vec = cmds.iter().rev().filter_map(|c| *c).take(2).collect(); assert_eq!(tail.len(), 2); assert!(matches!(tail[0], MenuCmd::About)); assert!(matches!(tail[1], MenuCmd::Settings)); } #[test] fn archive_item_menu_offers_extract_actions() { let app = app(); let dir = std::env::temp_dir(); let (_, cmds) = menu_entries(&app, &MenuTarget::Item(dir.join("paczka.zip"), false)); let has = |needle: fn(&MenuCmd) -> bool| cmds.iter().flatten().any(needle); assert!(has(|c| matches!(c, MenuCmd::Compress))); assert!(has(|c| matches!(c, MenuCmd::ExtractHere))); assert!(has(|c| matches!(c, MenuCmd::ExtractTo))); } #[test] fn regular_item_menu_has_no_extract_actions() { let app = app(); let dir = std::env::temp_dir(); let (_, cmds) = menu_entries(&app, &MenuTarget::Item(dir.join("notatka.txt"), false)); assert!(cmds .iter() .flatten() .all(|c| !matches!(c, MenuCmd::ExtractHere | MenuCmd::ExtractTo))); assert!(cmds .iter() .flatten() .any(|c| matches!(c, MenuCmd::Compress))); // Katalog (nawet o nazwie „.zip”) nie jest archiwum w menu. let (_, cmds) = menu_entries(&app, &MenuTarget::Item(dir.join("folder.zip"), true)); assert!(cmds .iter() .flatten() .all(|c| !matches!(c, MenuCmd::ExtractHere | MenuCmd::ExtractTo))); } }