🔒 Repository is read-only – file editing is disabled.

luna/apps/luna-files/src/ui.rs main

2412 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412
//! 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<Rect>,
    items: Rect,
    preview: Option<Rect>,
}

/// 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<usize> {
    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<Tab> = 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<String> {
    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<T: TextInput>(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<MenuEntry>,
    cmds: Vec<Option<MenuCmd>>,
}

impl MenuBuild {
    fn new() -> Self {
        Self {
            entries: Vec::new(),
            cmds: Vec::new(),
        }
    }

    fn action(&mut self, label: impl Into<String>, 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<String>) {
        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<MenuEntry>, Vec<Option<MenuCmd>>) {
    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<MenuCmd> = 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)));
    }
}