🔒 Repository is read-only – file editing is disabled.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036
//! Stan aplikacji i pętla klatki (platforma libLuny).
use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use libluna_core::{Point, ThemePreference, ThemeSettings};
use libluna_i18n::{set_locale, tr, tr_args, Locale};
use libluna_platform::{run as run_app, AppConfig, Frame};
use libluna_render::ImageData;
use libluna_widgets::{
AlertDialog, Entry as TextEntry, ItemView, Menu, NavEntry, NavList, PaintCtx, ScrollView,
SearchEntry, Toast, ViewMode, WindowControl,
};
use crate::model::{human_size, now_secs, DirView, Entry, Kind, SortKey};
use crate::ops::{Job, Ops, OpsEvent};
use crate::places::{self, Mount, Place};
use crate::prefs::Prefs;
use crate::{archive, trash, ui};
/// Odstęp między panelami w trybie dzielonym.
pub const SPLIT_GAP: f64 = 4.0;
/// Edycja ścieżki albo nazwy (pole tekstowe w pasku adresu).
pub struct EditState {
pub entry: TextEntry,
pub rename: Option<PathBuf>,
pub new_folder: bool,
}
impl EditState {
pub fn path(path: &Path) -> Self {
let mut entry = TextEntry::new(tr("Path"));
entry.set_text(path.display().to_string());
entry.set_focused(true);
entry.select_all();
Self {
entry,
rename: None,
new_folder: false,
}
}
pub fn rename(path: &Path) -> Self {
let mut entry = TextEntry::new(tr("Name"));
entry.set_text(
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
);
entry.set_focused(true);
entry.select_all();
Self {
entry,
rename: Some(path.to_path_buf()),
new_folder: false,
}
}
}
/// Podgląd pliku (Space).
pub enum Preview {
Text(String),
Image(Arc<ImageData>),
/// Brak podglądu (np. JPG bez dekodera) — pokazujemy metadane.
None,
}
/// Jedna zakładka/panel z własną ścieżką i stanem widoku.
pub struct Pane {
pub dir: DirView,
pub view: ItemView,
pub scroll: ScrollView,
pub tabs: Vec<PathBuf>,
pub tab: usize,
pub back: Vec<PathBuf>,
pub forward: Vec<PathBuf>,
pub edit: Option<EditState>,
pub search: SearchEntry,
pub search_active: bool,
pub preview: bool,
pub preview_cache: Option<(PathBuf, Preview)>,
}
impl Pane {
fn new(path: PathBuf) -> Self {
let dir = DirView::load(&path).unwrap_or_else(|_| {
DirView::load(places::home()).unwrap_or_else(|_| DirView::load("/").expect("/"))
});
let mut view = ItemView::new(dir.items());
view.set_mode(libluna_widgets::ViewMode::List);
Self {
tabs: vec![dir.path.clone()],
tab: 0,
back: Vec::new(),
forward: Vec::new(),
edit: None,
search: SearchEntry::new(tr("Search")),
search_active: false,
preview: false,
preview_cache: None,
scroll: ScrollView::new(),
dir,
view,
}
}
/// Indeksy zaznaczonych wpisów (widoczne).
pub fn selection(&self) -> Vec<PathBuf> {
let visible = self.dir.visible_entries();
self.view
.selected_indices()
.iter()
.filter_map(|i| visible.get(*i))
.map(|e| e.path.clone())
.collect()
}
pub fn primary(&self) -> Option<&Entry> {
self.view.selected().and_then(|i| self.dir.entry(i))
}
/// Po zmianie katalogu/porządku odświeża `ItemView`, zachowując zaznaczenie
/// po ścieżce (jeśli element nadal istnieje).
pub fn sync_view(&mut self) {
let keep = self.primary().map(|e| e.path.clone());
let items = self.dir.items();
self.view.set_items(items);
if let Some(path) = keep {
if let Some(pos) = (0..self.view.len())
.find(|i| self.dir.entry(*i).map(|e| e.path == path).unwrap_or(false))
{
self.view.set_selected(Some(pos));
}
}
self.scroll = ScrollView::new();
self.preview_cache = None;
}
pub fn selected_title(&self) -> Option<String> {
self.primary().map(|e| e.name.clone())
}
}
/// Pozycja panelu bocznego.
pub enum SideAction {
Go(PathBuf),
Unmount(usize),
Mount(usize),
}
/// Co otworzyło menu kontekstowe.
pub enum MenuTarget {
Item(PathBuf, bool),
TrashItem(PathBuf),
Background,
Device(usize),
AppMenu,
}
pub struct ContextMenu {
pub menu: Menu,
pub panel: libluna_core::Rect,
pub target: MenuTarget,
/// Komenda dla każdego wpisu menu (`None` = sekcja/separator).
pub cmds: Vec<Option<MenuCmd>>,
}
/// Akcja wybrana z menu (indeks wpisu → komenda).
#[derive(Debug, Clone, Copy)]
pub enum MenuCmd {
Open,
OpenTerminal,
Copy,
Cut,
Paste,
SelectAll,
Rename,
Trash,
Delete,
Properties,
Restore,
Purge,
NewFolder,
NewTab,
Split,
ToggleHidden,
Sort(SortKey),
ToggleAsc,
Refresh,
EmptyTrash,
Unmount(usize),
OpenDevice(usize),
/// Otwiera okno Ustawień.
Settings,
/// Otwiera okno „O aplikacji”.
About,
/// Pakuje zaznaczone elementy do archiwum ZIP.
Compress,
/// Wypakowuje archiwum do jego katalogu.
ExtractHere,
/// Wypakowuje archiwum do podkatalogu o nazwie archiwum.
ExtractTo,
}
pub struct Clipboard {
pub paths: Vec<PathBuf>,
pub cut: bool,
}
impl Clone for Clipboard {
fn clone(&self) -> Self {
Self {
paths: self.paths.clone(),
cut: self.cut,
}
}
}
/// Dialog konfliktu nazw (blokuje operację do decyzji użytkownika).
pub struct ConflictPrompt {
pub dialog: AlertDialog,
pub src: PathBuf,
pub dst: PathBuf,
}
impl ConflictPrompt {
pub fn new(src: PathBuf, dst: PathBuf) -> Self {
let name = src
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let body = tr_args(
"{name} already exists in the destination.",
&[("name", &name)],
);
let dialog = AlertDialog::new(tr("File exists"), body)
.width(520.0)
.button(tr("Replace"))
.button(tr("Skip"))
.button(tr("Rename"))
.button(tr("Replace All"));
Self { dialog, src, dst }
}
}
/// Cały stan aplikacji.
pub struct App {
pub panes: [Pane; 2],
pub active: usize,
pub split: bool,
pub sidebar_visible: bool,
pub places: Vec<Place>,
pub mounts: Vec<Mount>,
pub sidebar: NavList,
pub sidebar_actions: Vec<SideAction>,
/// Dla każdego wpisu `sidebar.entries()` — indeks akcji (None = sekcja).
pub sidebar_entry_action: Vec<Option<usize>>,
pub context: Option<ContextMenu>,
pub conflict: Option<ConflictPrompt>,
/// Potwierdzenie opróżnienia kosza (modal).
pub confirm_empty: Option<AlertDialog>,
pub ops: Ops,
pub op_label: Option<String>,
pub op_progress: Option<(u64, u64)>,
pub op_current: String,
pub op_speed: f64,
op_sample: Option<(Instant, u64)>,
op_started: Option<Instant>,
pub toast: Toast,
pub clipboard: Option<Clipboard>,
pub last_click: Option<(usize, Instant)>,
pub last_mount_refresh: Instant,
pub status: String,
/// Zakładka pod kursorem (podświetlenie w pasku tytułu).
pub tab_hover: Option<usize>,
/// Ostatnia pozycja kursora w oknie (do podświetlania przycisków).
pub pointer: Option<Point>,
/// Kontrolka okna pod kursorem (— □ ✕).
pub controls_hover: Option<WindowControl>,
/// Preferencje (język, domyślny widok, ukryte pliki).
pub prefs: Prefs,
/// Okno Ustawień (modal).
pub settings_open: bool,
/// Okno „O aplikacji” (modal).
pub about: Option<AlertDialog>,
/// Motyw wybrany w Ustawieniach — aplikowany przez pętlę klatki.
pub pending_theme: Option<ThemePreference>,
/// Bieżąca preferencja motywu (do podświetlenia w Ustawieniach).
pub theme_pref: ThemePreference,
pub pending_close: bool,
pub pending_minimize: bool,
pub pending_maximize: bool,
pub pending_move: bool,
}
impl App {
pub fn new(start: PathBuf) -> Self {
Self::with_prefs(start, Prefs::load())
}
pub fn with_prefs(start: PathBuf, prefs: Prefs) -> Self {
let home = places::home();
let start = if start.is_dir() { start } else { home };
let mut panes = [Pane::new(start.clone()), Pane::new(start)];
for pane in &mut panes {
pane.view.set_mode(prefs.view);
pane.dir.set_show_hidden(prefs.show_hidden);
pane.sync_view();
}
let mut app = Self {
panes,
active: 0,
split: false,
sidebar_visible: true,
places: Vec::new(),
mounts: Vec::new(),
sidebar: NavList::new(Vec::new()),
sidebar_actions: Vec::new(),
sidebar_entry_action: Vec::new(),
context: None,
conflict: None,
confirm_empty: None,
ops: Ops::new(),
op_label: None,
op_progress: None,
op_current: String::new(),
op_speed: 0.0,
op_sample: None,
op_started: None,
toast: Toast::new(""),
clipboard: None,
last_click: None,
last_mount_refresh: Instant::now() - Duration::from_secs(10),
status: String::new(),
tab_hover: None,
pointer: None,
controls_hover: None,
prefs,
settings_open: false,
about: None,
pending_theme: None,
theme_pref: ThemePreference::System,
pending_close: false,
pending_minimize: false,
pending_maximize: false,
pending_move: false,
};
app.rebuild_sidebar();
app
}
/// Zapisuje bieżące preferencje (wywoływane po zmianie w Ustawieniach).
pub fn save_prefs(&self) {
self.prefs.save();
}
/// Ustawia język i odświeża wszystkie napisy zależne od katalogu tłumaczeń.
pub fn set_language(&mut self, code: &str) {
self.prefs.language = code.to_owned();
set_locale(Locale::new(code));
self.rebuild_sidebar();
self.status = self.status_text();
self.save_prefs();
}
/// Ustawia domyślny widok we wszystkich panelach.
pub fn set_view(&mut self, mode: ViewMode) {
self.prefs.view = mode;
for pane in &mut self.panes {
pane.view.set_mode(mode);
pane.sync_view();
}
self.save_prefs();
}
/// Pokazuje/ukrywa pliki ukryte we wszystkich panelach.
pub fn set_show_hidden(&mut self, show: bool) {
self.prefs.show_hidden = show;
for pane in &mut self.panes {
pane.dir.set_show_hidden(show);
pane.sync_view();
}
self.save_prefs();
}
/// Otwiera okno „O aplikacji”.
pub fn show_about(&mut self) {
let body = format!(
"Luna Files {}\n{}\n\n© 2026 Luna Project · GPL-3.0-or-later",
env!("CARGO_PKG_VERSION"),
tr("File manager for the Luna desktop.")
);
self.about = Some(
AlertDialog::new(tr("About Files"), body)
.width(500.0)
.button(tr("Done")),
);
}
/// Tworzy archiwum ZIP z podanych elementów w katalogu `dir`.
pub fn compress(&mut self, items: Vec<PathBuf>, dir: &Path) {
if items.is_empty() {
return;
}
let base = if items.len() == 1 {
items[0]
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| tr("Archive"))
} else {
tr("Archive")
};
let dest = archive::unique_path(dir, &base, "zip");
self.submit(Job::Compress { items, dest });
}
/// Wypakowuje archiwum: `here = true` → do katalogu archiwum, inaczej do podkatalogu.
pub fn extract(&mut self, archive_path: PathBuf, here: bool) {
if !archive::is_archive(&archive_path) {
self.show_toast(tr("Unsupported archive format"));
return;
}
let Some(parent) = archive_path.parent() else {
return;
};
let dest = if here {
parent.to_path_buf()
} else {
parent.join(archive::archive_stem(&archive_path))
};
self.submit(Job::Extract {
archive: archive_path,
dest,
});
}
pub fn pane(&self) -> &Pane {
&self.panes[self.active]
}
pub fn pane_mut(&mut self) -> &mut Pane {
let i = self.active;
&mut self.panes[i]
}
pub fn other_pane_mut(&mut self) -> &mut Pane {
let i = 1 - self.active;
&mut self.panes[i]
}
/// Nawigacja z zapisem historii.
pub fn navigate(&mut self, pane: usize, path: PathBuf) {
if !path.is_dir() {
return;
}
if debug() {
eprintln!("luna-files: navigate {}", path.display());
}
let previous = self.panes[pane].dir.path.clone();
match DirView::load(&path) {
Ok(dir) => {
let p = &mut self.panes[pane];
p.dir = dir;
p.back.push(previous);
p.forward.clear();
p.tabs[p.tab] = p.dir.path.clone();
p.search.clear();
p.search_active = false;
p.dir.set_filter("");
p.edit = None;
p.sync_view();
self.select_sidebar_for(&path);
}
Err(e) => self.show_toast(format!("{}: {e}", path.display())),
}
}
pub fn reload_pane(&mut self, pane: usize) {
if self.panes[pane].dir.reload().is_ok() {
self.panes[pane].sync_view();
}
}
pub fn reload_all(&mut self) {
for i in 0..2 {
if self.panes[i].dir.reload().is_ok() {
self.panes[i].sync_view();
}
}
}
pub fn back(&mut self) {
let p = &mut self.panes[self.active];
let Some(prev) = p.back.pop() else { return };
p.forward.push(p.dir.path.clone());
if let Ok(dir) = DirView::load(&prev) {
p.dir = dir;
p.tabs[p.tab] = p.dir.path.clone();
p.sync_view();
let path = p.dir.path.clone();
self.select_sidebar_for(&path);
}
}
pub fn forward(&mut self) {
let p = &mut self.panes[self.active];
let Some(next) = p.forward.pop() else {
return;
};
p.back.push(p.dir.path.clone());
if let Ok(dir) = DirView::load(&next) {
p.dir = dir;
p.tabs[p.tab] = p.dir.path.clone();
p.sync_view();
let path = p.dir.path.clone();
self.select_sidebar_for(&path);
}
}
pub fn up(&mut self) {
let path = self.panes[self.active].dir.parent();
if let Some(path) = path {
self.navigate(self.active, path);
}
}
/// Otwiera zaznaczony wpis (katalog → nawigacja, plik → `xdg-open`).
pub fn open_selected(&mut self) {
let Some(entry) = self.pane().primary().cloned() else {
return;
};
if entry.is_dir {
self.navigate(self.active, entry.path);
} else {
let _ = std::process::Command::new("xdg-open")
.arg(&entry.path)
.spawn();
}
}
/// Otwiera terminal w bieżącym katalogu.
pub fn open_terminal(&mut self) {
let dir = self.pane().dir.path.clone();
self.open_terminal_at(&dir);
}
/// Otwiera terminal w podanym katalogu.
pub fn open_terminal_at(&mut self, dir: &Path) {
let dir_arg = dir.display().to_string();
let terminal = std::env::var("TERMINAL").ok();
let spawned = match terminal.as_deref() {
Some(t) if !t.is_empty() => std::process::Command::new(t)
.current_dir(dir)
.spawn()
.is_ok(),
_ => ["xfce4-terminal", "foot", "alacritty", "kitty", "wezterm"]
.iter()
.any(|t| spawn_terminal(t, &dir_arg)),
};
if !spawned {
self.show_toast(tr("No terminal emulator found"));
}
}
pub fn new_tab(&mut self) {
let path = self.pane().dir.path.clone();
self.new_tab_at(path);
}
/// Otwiera `path` w nowej karcie (np. środkowym przyciskiem myszy).
pub fn new_tab_at(&mut self, path: PathBuf) {
if !path.is_dir() {
return;
}
{
let p = self.pane_mut();
p.tabs.push(path);
p.tab = p.tabs.len() - 1;
if let Ok(dir) = DirView::load(&p.tabs[p.tab]) {
p.dir = dir;
p.sync_view();
}
}
let path = self.pane().dir.path.clone();
self.select_sidebar_for(&path);
}
pub fn close_tab(&mut self) {
let p = self.pane_mut();
if p.tabs.len() <= 1 {
return;
}
p.tabs.remove(p.tab);
p.tab = p.tab.min(p.tabs.len() - 1);
let path = p.tabs[p.tab].clone();
if let Ok(dir) = DirView::load(&path) {
p.dir = dir;
p.sync_view();
}
}
pub fn select_tab(&mut self, index: usize) {
let p = self.pane_mut();
if index < p.tabs.len() {
p.tab = index;
let path = p.tabs[index].clone();
if let Ok(dir) = DirView::load(&path) {
p.dir = dir;
p.sync_view();
}
}
}
pub fn submit(&mut self, job: Job) {
if !self.ops.submit(job) {
self.show_toast(tr("Another operation is still running"));
}
}
pub fn drain_ops(&mut self) {
for ev in self.ops.poll() {
match ev {
OpsEvent::Started { label } => {
self.op_label = Some(label);
self.op_progress = Some((0, 0));
self.op_current.clear();
self.op_speed = 0.0;
self.op_sample = None;
self.op_started = Some(Instant::now());
}
OpsEvent::Progress {
done,
total,
current,
} => {
let now = Instant::now();
if let Some((t0, d0)) = self.op_sample {
let dt = now.duration_since(t0).as_secs_f64();
if dt >= 0.3 {
let inst = done.saturating_sub(d0) as f64 / dt;
self.op_speed = if self.op_speed <= 0.0 {
inst
} else {
self.op_speed * 0.7 + inst * 0.3
};
self.op_sample = Some((now, done));
}
} else {
self.op_sample = Some((now, done));
}
self.op_progress = Some((done, total));
self.op_current = current;
}
OpsEvent::Conflict { src, dst, .. } => {
self.conflict = Some(ConflictPrompt::new(src, dst));
}
OpsEvent::Finished {
label,
ok,
skipped,
failed,
created,
} => {
if debug() {
eprintln!(
"luna-files: ops '{label}' ok={ok} skipped={skipped} failed={failed:?} created={created:?}"
);
}
self.op_label = None;
self.op_progress = None;
self.op_current.clear();
self.op_speed = 0.0;
self.op_sample = None;
self.op_started = None;
self.conflict = None;
self.reload_all();
self.refresh_devices();
if let Some(path) = created {
self.select_path(&path);
}
let msg = if skipped > 0 {
tr_args(
"{label}: {n} done, {skipped} skipped",
&[
("label", &label),
("n", &ok.to_string()),
("skipped", &skipped.to_string()),
],
)
} else {
tr_args(
"{label}: {n} done",
&[("label", &label), ("n", &ok.to_string())],
)
};
if let Some(first) = failed.first() {
self.show_toast(format!("{msg} — {first}"));
} else {
self.show_toast(msg);
}
}
}
}
}
/// Odpowiedź na dialog konfliktu (indeks przycisku).
pub fn answer_conflict(&mut self, index: usize) {
use crate::ops::Decision;
let decision = match index {
0 => Decision::Overwrite,
1 => Decision::Skip,
2 => Decision::Rename,
3 => Decision::OverwriteAll,
_ => Decision::Skip,
};
if debug() {
eprintln!("luna-files: conflict -> {decision:?}");
}
self.ops.decide(decision);
self.conflict = None;
}
pub fn cancel_op(&mut self) {
self.ops.cancel();
}
pub fn refresh_devices(&mut self) {
let mounts = places::mounts();
let changed = mounts.len() != self.mounts.len()
|| mounts
.iter()
.zip(self.mounts.iter())
.any(|(a, b)| a.dev != b.dev || a.mount != b.mount);
self.mounts = mounts;
if changed {
self.rebuild_sidebar();
let path = self.pane().dir.path.clone();
self.select_sidebar_for(&path);
}
self.last_mount_refresh = Instant::now();
}
pub fn refresh_devices_if_needed(&mut self) {
if self.last_mount_refresh.elapsed() >= Duration::from_secs(2) {
self.refresh_devices();
}
}
pub fn rebuild_sidebar(&mut self) {
self.places = places::places();
let mut entries = Vec::new();
let mut actions = Vec::new();
let mut mapping = Vec::new();
entries.push(NavEntry::Section(tr("Places")));
mapping.push(None);
for place in &self.places {
entries.push(NavEntry::Item(place.label.clone()));
actions.push(SideAction::Go(place.path.clone()));
mapping.push(Some(actions.len() - 1));
}
entries.push(NavEntry::Item(tr("Trash")));
actions.push(SideAction::Go(trash::trash_root().join("files")));
mapping.push(Some(actions.len() - 1));
if !self.mounts.is_empty() {
entries.push(NavEntry::Section(tr("Devices")));
mapping.push(None);
for (i, m) in self.mounts.iter().enumerate() {
let free = human_size(m.free);
let label = tr_args(
"{name} — {free} free",
&[("name", &m.label), ("free", &free)],
);
entries.push(NavEntry::Item(label));
actions.push(SideAction::Go(m.mount.clone()));
mapping.push(Some(actions.len() - 1));
// Lewy klik nawiguje; odmontowanie jest w menu kontekstowym.
let _ = (i, SideAction::Unmount(i));
}
}
self.sidebar = NavList::new(entries);
self.sidebar_actions = actions;
self.sidebar_entry_action = mapping;
}
fn select_sidebar_for(&mut self, path: &Path) {
let mut best: Option<(usize, usize)> = None;
for (ai, act) in self.sidebar_actions.iter().enumerate() {
if let SideAction::Go(p) = act {
if path.starts_with(p) {
let depth = p.components().count();
if best.map(|(_, d)| depth > d).unwrap_or(true) {
best = Some((ai, depth));
}
}
}
}
if let Some((ai, _)) = best {
if let Some(entry) = self
.sidebar_entry_action
.iter()
.position(|a| *a == Some(ai))
{
self.sidebar.set_selected(Some(entry));
}
}
}
/// Zaznacza plik w bieżącym panelu (np. po utworzeniu folderu).
pub fn select_path(&mut self, path: &Path) {
let p = self.pane_mut();
let visible = p.dir.visible_entries();
if let Some(i) = visible.iter().position(|e| e.path == *path) {
p.view.set_selected(Some(i));
}
}
pub fn show_toast(&mut self, message: impl Into<String>) {
self.toast = Toast::new(message);
self.toast.show_animated();
}
/// Status w dolnej belce.
pub fn status_text(&self) -> String {
let p = self.pane();
let visible = p.view.len();
let selected = p.view.selected_indices().len();
let mut text = tr_args("{n} items", &[("n", &visible.to_string())]);
if selected > 0 {
let n = selected.to_string();
let visible_entries = p.dir.visible_entries();
let size: u64 = p
.view
.selected_indices()
.iter()
.filter_map(|i| visible_entries.get(*i))
.map(|e| if e.is_dir { 0 } else { e.size })
.sum();
text = format!(
"{text} · {}",
tr_args(
"{n} selected ({size})",
&[("n", &n), ("size", &human_size(size))]
)
);
}
if let Some((total, free)) = places::statvfs(&p.dir.path) {
let pct = if total > 0 {
(total.saturating_sub(free)) as f64 / total as f64 * 100.0
} else {
0.0
};
text = format!(
"{text} · {} {} ({}%)",
human_size(free),
tr("free"),
pct.round() as i64
);
}
text
}
/// Odświeża podgląd zaznaczonego pliku.
pub fn update_preview(&mut self) {
let p = self.pane_mut();
if !p.preview {
p.preview_cache = None;
return;
}
let Some(entry) = p.primary().cloned() else {
p.preview_cache = None;
return;
};
if p.preview_cache
.as_ref()
.map(|(path, _)| *path == entry.path)
.unwrap_or(false)
{
return;
}
let preview = preview_for(&entry);
p.preview_cache = Some((entry.path, preview));
}
}
/// Diagnostyka `LUNA_FILES_DEBUG=1`.
pub fn debug() -> bool {
std::env::var("LUNA_FILES_DEBUG").is_ok()
}
fn preview_for(entry: &Entry) -> Preview {
if entry.is_dir {
return Preview::None;
}
match entry.kind {
Kind::Image => {
// Dekoder PNG jest w libluna-render; inne formaty → metadane.
if entry
.path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("png"))
.unwrap_or(false)
{
if let Ok(bytes) = std::fs::read(&entry.path) {
if let Some(img) = libluna_render::wallpaper::decode_png(&bytes) {
return Preview::Image(Arc::new(img));
}
}
}
Preview::None
}
Kind::Text | Kind::Code => {
use std::io::Read;
let mut file = match std::fs::File::open(&entry.path) {
Ok(f) => f,
Err(_) => return Preview::None,
};
let mut buf = vec![0u8; 64 * 1024];
let n = file.read(&mut buf).unwrap_or(0);
buf.truncate(n);
let text = String::from_utf8_lossy(&buf);
let lines: Vec<&str> = text.lines().take(200).collect();
Preview::Text(lines.join("\n"))
}
_ => Preview::None,
}
}
fn spawn_terminal(program: &str, dir: &str) -> bool {
let args: &[&str] = match program {
"xfce4-terminal" => &["--working-directory", dir],
"gnome-terminal" => &["--working-directory", dir],
"kitty" => &["--directory", dir],
"alacritty" => &["--working-directory", dir],
_ => &[],
};
std::process::Command::new(program)
.args(args)
.current_dir(dir)
.spawn()
.is_ok()
}
/// Uruchamia aplikację (binarki `luna-files` i `gaminedes`).
pub fn run(title_key: &str, app_id: &str) -> Result<(), Box<dyn Error>> {
// Język z konfiguracji aplikacji ma pierwszeństwo nad `LANG` — jego wybór
// w Ustawieniach ma być trwały między uruchomieniami.
let prefs = Prefs::load();
set_locale(Locale::new(prefs.language.clone()));
libluna_i18n::load_catalogs_from_dirs();
let title = tr(title_key);
let settings = ThemeSettings::load();
let system_dark = std::env::var("LUNA_SYSTEM_DARK")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false);
let start = std::env::args()
.nth(1)
.map(PathBuf::from)
.filter(|p| p.is_dir())
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(places::home);
let config = AppConfig::new(title)
.app_id(app_id)
.initial_size(1120, 720)
.settings(settings)
.system_dark(system_dark);
let mut app = App::with_prefs(start, prefs);
app.status = app.status_text();
run_app(config, move |frame: &mut Frame| {
let theme = frame.settings().resolve_themed(frame.system_dark());
app.theme_pref = frame.settings().preference();
app.drain_ops();
app.refresh_devices_if_needed();
ui::handle_events(&mut app, frame.events, frame.size, &theme, frame.text);
if app.pending_close {
frame.close();
app.pending_close = false;
}
if app.pending_minimize {
frame.minimize();
app.pending_minimize = false;
}
if app.pending_maximize {
frame.toggle_maximize();
app.pending_maximize = false;
}
if app.pending_move {
frame.begin_move();
app.pending_move = false;
}
if let Some(pref) = app.pending_theme.take() {
frame.set_preference(pref);
}
app.update_preview();
app.status = app.status_text();
ui::paint(&app, frame.scene, frame.size, &theme, frame.text);
if app.toast.is_animating() {
app.toast.advance(frame.delta_time(), 0.3);
frame.request_frame();
}
if app.ops.busy() {
frame.request_frame();
}
})
}
/// Pomocnik dla UI: `PaintCtx` z klatki.
pub fn paint_ctx<'a>(
scene: &'a mut libluna_render::Scene,
theme: &'a libluna_core::Theme,
text: &'a libluna_render::TextEngine,
) -> PaintCtx<'a> {
PaintCtx::new(scene, theme, text)
}
/// Na potrzeby UI: bieżący czas do animacji/migań.
pub fn tick() -> i64 {
now_secs()
}