From e8513b943a5c7edc2aa9c40e1f9642a3374d1668 Mon Sep 17 00:00:00 2001 From: "Wiktor A. Falkowski" Date: Sun, 18 Oct 2026 14:25:13 +0000 Subject: [PATCH] Initial Wayfinder release --- Cargo.lock | 671 +++++++ Cargo.toml | 10 + README.md | 50 + colors.ini | 31 + config.ini | 80 + data/dev.wfalkowski.Wayfinder.desktop | 10 + .../gui-apps/wayfinder/wayfinder-0.1.0.ebuild | 105 + ...-0.1.0.ebuild.backup-before-config-install | 103 + ...-0.1.0.ebuild.backup-before-crate-name-fix | 105 + .../wayfinder-0.1.0.ebuild.backup-before-fix | 102 + src/main.rs | 1764 +++++++++++++++++ src/main.rs.backup-20261018T115319Z | 498 +++++ src/main.rs.backup-20261018T134521Z | 929 +++++++++ src/main.rs.backup-20261018T135128Z | 1102 ++++++++++ ...in.rs.backup-code-preview-20261018T140500Z | 1480 ++++++++++++++ src/main.rs.backup-ini-prep-20261018T140000Z | 1194 +++++++++++ 16 files changed, 8234 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 colors.ini create mode 100644 config.ini create mode 100644 data/dev.wfalkowski.Wayfinder.desktop create mode 100644 packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild create mode 100644 packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-config-install create mode 100644 packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-crate-name-fix create mode 100644 packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-fix create mode 100644 src/main.rs create mode 100644 src/main.rs.backup-20261018T115319Z create mode 100644 src/main.rs.backup-20261018T134521Z create mode 100644 src/main.rs.backup-20261018T135128Z create mode 100644 src/main.rs.backup-code-preview-20261018T140500Z create mode 100644 src/main.rs.backup-ini-prep-20261018T140000Z diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..38b2298 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,671 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cairo-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756564212bbe4a4ce05d88ffbd2582581ac6003832d0d32822d0825cca84bfbf" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb21d53cfc6f7bfaf43549731c43b67ca47d87348d81c8cfc4dcdd44828e1a4" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ccfb5a14a3d941244815d5f8101fa12d4577b59cc47245778d8d907b0003e42" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842577fe5a1ee15d166cd3afe804ce0cab6173bc789ca32e21308834f20088dd" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pango" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wayfinder" +version = "0.1.0" +dependencies = [ + "gtk4", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fc63130 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "wayfinder" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "A compact GTK4 file manager for Wayland desktops" +license = "MIT" + +[dependencies] +gtk = { package = "gtk4", version = "0.10" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffaabcd --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Wayfinder + +Wayfinder is a compact graphical file manager for Linux desktops running Wayland. It is written in Rust and uses GTK 4. The main view is a directory listing with a configurable places sidebar; selecting a supported file opens an inline preview pane. + +## What it does + +- Browse directories by entering a path, using the places sidebar, or opening a folder from the file list. +- Filter the current directory, sort folders before files, and toggle hidden files with `Ctrl+H`. +- See file sizes and relative modification times in the list. +- Preview supported text and source files, with syntax colors enabled by default, and preview images in the side pane. Text previews are capped at 64 KiB by default. +- Open text files in Neovim inside Foot, images in imv, executable files in Foot, and other files with their default desktop application. +- Use keyboard shortcuts: `Ctrl+L` focuses the location field, `Backspace` goes to the parent directory, `F5` reloads the current directory, and `Esc` exits preview. +- Adjust window, pane, layout, color, place, and preview settings through nested INI configuration files. + +Wayfinder is focused on browsing and previewing. It does not currently provide file copy, move, delete, rename, or mount operations. + +## Requirements + +Build requirements: + +- Rust and Cargo (Rust 1.85 or newer) +- GTK 4 development files and `pkg-config` + +The application uses GTK's Wayland support when launched in a Wayland session. Image preview uses `gdk-pixbuf-thumbnailer` in a Bubblewrap sandbox. JPEG preview also has a Python 3 and Pillow fallback for systems where GdkPixbuf lacks a JPEG loader. To open files from the list, Wayfinder can use Foot and Neovim for text, imv for images, and the system's default application for other file types. + +## Build and run + +```sh +cargo build --release +./target/release/wayfinder +``` + +A Gentoo ebuild is provided under `packaging/gentoo/gui-apps/wayfinder/`. + +## Configuration + +Wayfinder reads `~/.config/wayfinder/config.ini`. Copy the example files from the source tree: + +```sh +mkdir -p ~/.config/wayfinder +cp config.ini colors.ini ~/.config/wayfinder/ +``` + +`config.ini` contains window and pane sizes, spacing, typography, preview limits, and places. It includes `colors.ini`, where all interface and syntax colors are defined. Set `code_preview = false` in the `[General]` section to disable syntax coloring. + +Configuration files can include other INI files recursively. Add comma-separated file names under `[Includes]` in `config.ini` (for example, `files = colors.ini, work.ini`). Relative include paths are resolved from the including file, included values load first, and the including file overrides matching values. Places are defined in `[Places]` as `Name = path`; `~` and `~/...` paths are supported. + +## License + +Wayfinder is distributed under the MIT License. See `LICENSE` in the source repository. diff --git a/colors.ini b/colors.ini new file mode 100644 index 0000000..c2e401d --- /dev/null +++ b/colors.ini @@ -0,0 +1,31 @@ +; Every application color is defined here and can be overridden in config.ini +; or in any nested INI file listed in [Includes]. Values must be #RGB, #RGBA, +; #RRGGBB, or #RRGGBBAA hexadecimal colors. +[Colors] +background = #151515 +panel = #181818 +surface = #202020 +hover = #282828 +selected = #303030 +border = #2F2F2F +focus_border = #454545 +text = #D0D0D0 +muted = #858585 +dim = #505050 +emphasis = #F5F5F5 +accent = #6C99BB +accent_secondary = #547C99 +directory = #6C99BB +success = #7B9246 +warning = #D3A04D +danger = #A53C23 +teal = #5EA8A2 +; Syntax colors follow the active Aether Neovim palette. +code_keyword = #9F4E85 +code_string = #7B9246 +code_number = #D3A04D +code_comment = #505050 +code_function = #6C99BB +code_type = #5EA8A2 +code_constant = #A53C23 +code_operator = #547C99 diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..4b2d767 --- /dev/null +++ b/config.ini @@ -0,0 +1,80 @@ +; Wayfinder master configuration. +; Values in this file override included files. Include files can include other .ini files. +; Place these files in ~/.config/wayfinder/. + +[Includes] +files = colors.ini + +[General] +; Window and pane sizes, in pixels. +window_width = 1080 +window_height = 700 +sidebar_width = 136 +preview_width = 320 +compact_columns_width = 520 + +; Preview behavior. +text_preview_limit_kib = 64 +image_preview_size = 512 +; Colorize source and configuration files in the preview pane. +code_preview = true + +; Toolbar allocation: Filter receives this percentage of available entry width. +filter_width_ratio = 30 +filter_width_min = 84 +filter_width_max = 180 + +; Typography. +font_family = Maple Mono, "JetBrainsMono Nerd Font", monospace +font_size = 10 + +; Spacing and component dimensions. Numeric values are pixels unless noted as font sizes. +toolbar_pad_x = 10 +toolbar_pad_y = 8 +toolbar_gap = 6 +control_pad_x = 9 +control_pad_y = 7 +control_min_height = 18 +button_size = 34 +icon_size = 16 +corner_radius = 2 +sidebar_pad_x = 6 +sidebar_pad_y = 8 +place_pad_x = 9 +place_pad_y = 7 +place_min_height = 20 +heading_pad_x = 8 +heading_pad_y = 9 +preview_pad = 12 +preview_spacing = 10 +preview_heading_size = 11 +preview_title_size = 10 +preview_details_size = 9 +preview_title_pad = 2 +preview_message_pad = 12 +preview_text_pad = 10 +top_border_width = 2 +border_width = 1 +row_height = 36 +row_padding_x = 8 +row_padding_y = 5 +size_column_width = 72 +modified_column_width = 126 +metadata_value_height = 24 +place_gap = 2 +status_margin_x = 12 +status_margin_y = 6 +metadata_font_size = 10 +metadata_pad_top = 3 +metadata_pad_bottom = 5 +scrollbar_width = 6 +scrollbar_thumb_width = 4 +scrollbar_thumb_radius = 0 +column_gap = 8 + +; Each key is the visible shortcut name; values may be absolute paths or use ~/. +[Places] +Home = ~ +Documents = ~/Documents +Downloads = ~/Downloads +Pictures = ~/Pictures diff --git a/data/dev.wfalkowski.Wayfinder.desktop b/data/dev.wfalkowski.Wayfinder.desktop new file mode 100644 index 0000000..6df937c --- /dev/null +++ b/data/dev.wfalkowski.Wayfinder.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Wayfinder +Comment=Browse files and preview text and images +Exec=wayfinder +Icon=system-file-manager +Terminal=false +Type=Application +Categories=System;FileTools;FileManager; +StartupNotify=true +DBusActivatable=false diff --git a/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild new file mode 100644 index 0000000..093f4b7 --- /dev/null +++ b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild @@ -0,0 +1,105 @@ +# Copyright 2026 Wiktor A. Falkowski +# Distributed under the terms of the MIT License + +EAPI=8 + +CRATES=" + autocfg@1.5.1 + bitflags@2.13.2 + cairo-rs@0.21.5 + cairo-sys-rs@0.21.5 + cfg-expr@0.20.10 + equivalent@1.0.3 + field-offset@0.3.6 + futures-channel@0.3.34 + futures-core@0.3.34 + futures-executor@0.3.34 + futures-io@0.3.34 + futures-macro@0.3.34 + futures-task@0.3.34 + futures-util@0.3.34 + gdk-pixbuf-sys@0.21.5 + gdk-pixbuf@0.21.5 + gdk4-sys@0.10.3 + gdk4@0.10.3 + gio-sys@0.21.5 + gio@0.21.5 + glib-macros@0.21.5 + glib-sys@0.21.5 + glib@0.21.5 + gobject-sys@0.21.5 + graphene-rs@0.21.5 + graphene-sys@0.21.5 + gsk4-sys@0.10.3 + gsk4@0.10.3 + gtk4-macros@0.10.3 + gtk4-sys@0.10.3 + gtk4@0.10.3 + hashbrown@0.17.1 + heck@0.5.0 + indexmap@2.14.2 + libc@0.2.190 + memchr@2.8.3 + memoffset@0.9.1 + pango-sys@0.21.5 + pango@0.21.5 + pin-project-lite@0.2.17 + pkg-config@0.3.34 + proc-macro-crate@3.5.0 + proc-macro2@1.0.107 + quote@1.0.47 + rustc_version@0.4.1 + semver@1.0.28 + serde_core@1.0.229 + serde_derive@1.0.229 + serde_spanned@1.1.2 + slab@0.4.12 + smallvec@1.16.3 + syn@2.0.119 + syn@3.0.9 + system-deps@7.0.8 + target-lexicon@0.13.5 + toml@1.1.8+spec-1.1.0 + toml_datetime@1.1.2+spec-1.1.0 + toml_edit@0.25.17+spec-1.1.0 + toml_parser@1.1.5+spec-1.1.0 + toml_writer@1.1.3+spec-1.1.0 + unicode-ident@1.0.27 + version-compare@0.2.1 + windows-link@0.2.1 + windows-sys@0.61.2 + winnow@1.0.4 +" + +RUST_MIN_VER="1.85.0" +inherit cargo + +DESCRIPTION="Compact GTK4 file manager for Wayland desktops" +HOMEPAGE="https://git.anchorhost.eu/wfalkowski/wayfinder" +SRC_URI="https://git.anchorhost.eu/wfalkowski/wayfinder/archive/v${PV}.tar.gz -> ${P}.tar.gz ${CARGO_CRATE_URIS}" +S="${WORKDIR}/wayfinder" + +LICENSE="MIT Apache-2.0 Apache-2.0-with-LLVM-exceptions Unicode-3.0" +SLOT="0" +KEYWORDS="~amd64" + +DEPEND="gui-libs/gtk:4[wayland]" +RDEPEND=" + ${DEPEND} + sys-apps/bubblewrap + media-libs/gdk-pixbuf +" +BDEPEND="virtual/pkgconfig" + +src_configure() { + cargo_src_configure --locked --bin wayfinder +} + +src_install() { + cargo_src_install + insinto /usr/share/applications + doins data/dev.wfalkowski.Wayfinder.desktop + dodoc README.md LICENSE + insinto /usr/share/wayfinder + doins config.ini colors.ini +} diff --git a/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-config-install b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-config-install new file mode 100644 index 0000000..a58c017 --- /dev/null +++ b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-config-install @@ -0,0 +1,103 @@ +# Copyright 2026 Wiktor A. Falkowski +# Distributed under the terms of the MIT License + +EAPI=8 + +CRATES=" + autocfg@1.5.1 + bitflags@2.13.2 + cairo-rs@0.21.5 + cairo-sys-rs@0.21.5 + cfg-expr@0.20.10 + equivalent@1.0.3 + field-offset@0.3.6 + futures-channel@0.3.34 + futures-core@0.3.34 + futures-executor@0.3.34 + futures-io@0.3.34 + futures-macro@0.3.34 + futures-task@0.3.34 + futures-util@0.3.34 + gdk-pixbuf@0.21.5 + gdk-pixbuf-sys@0.21.5 + gdk4@0.10.3 + gdk4-sys@0.10.3 + gio@0.21.5 + gio-sys@0.21.5 + glib@0.21.5 + glib-macros@0.21.5 + glib-sys@0.21.5 + gobject-sys@0.21.5 + graphene-rs@0.21.5 + graphene-sys@0.21.5 + gsk4@0.10.3 + gsk4-sys@0.10.3 + gtk4@0.10.3 + gtk4-macros@0.10.3 + gtk4-sys@0.10.3 + hashbrown@0.17.1 + heck@0.5.0 + indexmap@2.14.2 + libc@0.2.190 + memchr@2.8.3 + memoffset@0.9.1 + pango@0.21.5 + pango-sys@0.21.5 + pin-project-lite@0.2.17 + pkg-config@0.3.34 + proc-macro-crate@3.5.0 + proc-macro2@1.0.107 + quote@1.0.47 + rustc-version@0.4.1 + semver@1.0.28 + serde-core@1.0.229 + serde-derive@1.0.229 + serde-spanned@1.1.2 + slab@0.4.12 + smallvec@1.16.3 + syn@2.0.119 + syn@3.0.9 + system-deps@7.0.8 + target-lexicon@0.13.5 + toml@1.1.8+spec-1.1.0 + toml-datetime@1.1.2+spec-1.1.0 + toml-edit@0.25.17+spec-1.1.0 + toml-parser@1.1.5+spec-1.1.0 + toml-writer@1.1.3+spec-1.1.0 + unicode-ident@1.0.27 + version-compare@0.2.1 + windows-link@0.2.1 + windows-sys@0.61.2 + winnow@1.0.4 +" + +RUST_MIN_VER="1.85.0" +inherit cargo + +DESCRIPTION="Compact GTK4 file manager for Wayland desktops" +HOMEPAGE="https://git.anchorhost.eu/wfalkowski/wayfinder" +SRC_URI="https://git.anchorhost.eu/wfalkowski/wayfinder/archive/v${PV}.tar.gz -> ${P}.tar.gz ${CARGO_CRATE_URIS}" +S="${WORKDIR}/wayfinder" + +LICENSE="MIT Apache-2.0 Apache-2.0-with-LLVM-exceptions Unicode-3.0" +SLOT="0" +KEYWORDS="~amd64" + +DEPEND="gui-libs/gtk:4[wayland]" +RDEPEND=" + ${DEPEND} + sys-apps/bubblewrap + media-libs/gdk-pixbuf +" +BDEPEND="virtual/pkgconfig" + +src_configure() { + cargo_src_configure --locked --bin wayfinder +} + +src_install() { + cargo_src_install + insinto /usr/share/applications + doins data/dev.wfalkowski.Wayfinder.desktop + dodoc README.md LICENSE config.ini colors.ini +} diff --git a/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-crate-name-fix b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-crate-name-fix new file mode 100644 index 0000000..9de43a6 --- /dev/null +++ b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-crate-name-fix @@ -0,0 +1,105 @@ +# Copyright 2026 Wiktor A. Falkowski +# Distributed under the terms of the MIT License + +EAPI=8 + +CRATES=" + autocfg@1.5.1 + bitflags@2.13.2 + cairo-rs@0.21.5 + cairo-sys-rs@0.21.5 + cfg-expr@0.20.10 + equivalent@1.0.3 + field-offset@0.3.6 + futures-channel@0.3.34 + futures-core@0.3.34 + futures-executor@0.3.34 + futures-io@0.3.34 + futures-macro@0.3.34 + futures-task@0.3.34 + futures-util@0.3.34 + gdk-pixbuf@0.21.5 + gdk-pixbuf-sys@0.21.5 + gdk4@0.10.3 + gdk4-sys@0.10.3 + gio@0.21.5 + gio-sys@0.21.5 + glib@0.21.5 + glib-macros@0.21.5 + glib-sys@0.21.5 + gobject-sys@0.21.5 + graphene-rs@0.21.5 + graphene-sys@0.21.5 + gsk4@0.10.3 + gsk4-sys@0.10.3 + gtk4@0.10.3 + gtk4-macros@0.10.3 + gtk4-sys@0.10.3 + hashbrown@0.17.1 + heck@0.5.0 + indexmap@2.14.2 + libc@0.2.190 + memchr@2.8.3 + memoffset@0.9.1 + pango@0.21.5 + pango-sys@0.21.5 + pin-project-lite@0.2.17 + pkg-config@0.3.34 + proc-macro-crate@3.5.0 + proc-macro2@1.0.107 + quote@1.0.47 + rustc-version@0.4.1 + semver@1.0.28 + serde-core@1.0.229 + serde-derive@1.0.229 + serde-spanned@1.1.2 + slab@0.4.12 + smallvec@1.16.3 + syn@2.0.119 + syn@3.0.9 + system-deps@7.0.8 + target-lexicon@0.13.5 + toml@1.1.8+spec-1.1.0 + toml-datetime@1.1.2+spec-1.1.0 + toml-edit@0.25.17+spec-1.1.0 + toml-parser@1.1.5+spec-1.1.0 + toml-writer@1.1.3+spec-1.1.0 + unicode-ident@1.0.27 + version-compare@0.2.1 + windows-link@0.2.1 + windows-sys@0.61.2 + winnow@1.0.4 +" + +RUST_MIN_VER="1.85.0" +inherit cargo + +DESCRIPTION="Compact GTK4 file manager for Wayland desktops" +HOMEPAGE="https://git.anchorhost.eu/wfalkowski/wayfinder" +SRC_URI="https://git.anchorhost.eu/wfalkowski/wayfinder/archive/v${PV}.tar.gz -> ${P}.tar.gz ${CARGO_CRATE_URIS}" +S="${WORKDIR}/wayfinder" + +LICENSE="MIT Apache-2.0 Apache-2.0-with-LLVM-exceptions Unicode-3.0" +SLOT="0" +KEYWORDS="~amd64" + +DEPEND="gui-libs/gtk:4[wayland]" +RDEPEND=" + ${DEPEND} + sys-apps/bubblewrap + media-libs/gdk-pixbuf +" +BDEPEND="virtual/pkgconfig" + +src_configure() { + cargo_src_configure --locked --bin wayfinder +} + +src_install() { + cargo_src_install + insinto /usr/share/applications + doins data/dev.wfalkowski.Wayfinder.desktop + dodoc README.md LICENSE + insinto /usr/share/wayfinder + doins config.ini colors.ini +} diff --git a/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-fix b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-fix new file mode 100644 index 0000000..9b264d7 --- /dev/null +++ b/packaging/gentoo/gui-apps/wayfinder/wayfinder-0.1.0.ebuild.backup-before-fix @@ -0,0 +1,102 @@ +# Copyright 2026 Wiktor A. Falkowski +# Distributed under the terms of the MIT License + +EAPI=8 + +CRATES=" + autocfg@1.5.1 + bitflags@2.13.2 + cairo-rs@0.21.5 + cairo-sys-rs@0.21.5 + cfg-expr@0.20.10 + equivalent@1.0.3 + field-offset@0.3.6 + futures-channel@0.3.34 + futures-core@0.3.34 + futures-executor@0.3.34 + futures-io@0.3.34 + futures-macro@0.3.34 + futures-task@0.3.34 + futures-util@0.3.34 + gdk-pixbuf@0.21.5 + gdk-pixbuf-sys@0.21.5 + gdk4@0.10.3 + gdk4-sys@0.10.3 + gio@0.21.5 + gio-sys@0.21.5 + glib@0.21.5 + glib-macros@0.21.5 + glib-sys@0.21.5 + gobject-sys@0.21.5 + graphene-rs@0.21.5 + graphene-sys@0.21.5 + gsk4@0.10.3 + gsk4-sys@0.10.3 + gtk4@0.10.3 + gtk4-macros@0.10.3 + gtk4-sys@0.10.3 + hashbrown@0.17.1 + heck@0.5.0 + indexmap@2.14.2 + libc@0.2.190 + memchr@2.8.3 + memoffset@0.9.1 + pango@0.21.5 + pango-sys@0.21.5 + pin-project-lite@0.2.17 + pkg-config@0.3.34 + proc-macro-crate@3.5.0 + proc-macro2@1.0.107 + quote@1.0.47 + rustc-version@0.4.1 + semver@1.0.28 + serde-core@1.0.229 + serde-derive@1.0.229 + serde-spanned@1.1.2 + slab@0.4.12 + smallvec@1.16.3 + syn@2.0.119 + syn@3.0.9 + system-deps@7.0.8 + target-lexicon@0.13.5 + toml@1.1.8+spec-1.1.0 + toml-datetime@1.1.2+spec-1.1.0 + toml-edit@0.25.17+spec-1.1.0 + toml-parser@1.1.5+spec-1.1.0 + toml-writer@1.1.3+spec-1.1.0 + unicode-ident@1.0.27 + version-compare@0.2.1 + windows-link@0.2.1 + windows-sys@0.61.2 + winnow@1.0.4 +" + +RUST_MIN_VER="1.85.0" +inherit cargo + +DESCRIPTION="Compact GTK4 file manager for Wayland desktops" +HOMEPAGE="https://git.anchorhost.eu/wfalkowski/wayfinder" +SRC_URI="https://git.anchorhost.eu/wfalkowski/wayfinder/archive/v${PV}.tar.gz -> ${P}.tar.gz ${CARGO_CRATE_URIS}" +S="${WORKDIR}/wayfinder" + +LICENSE="MIT Apache-2.0 Apache-2.0-with-LLVM-exceptions Unicode-3.0" +SLOT="0" +KEYWORDS="~amd64" + +DEPEND="gui-libs/gtk:4[wayland]" +RDEPEND=" + ${DEPEND} + app-misc/ bubblewrap + media-libs/gdk-pixbuf +" +BDEPEND="virtual/pkgconfig" + +src_configure() { + cargo_src_configure --locked --bin wayfinder +} + +src_install() { + cargo_src_install + domenu data/dev.wfalkowski.Wayfinder.desktop + dodoc README.md LICENSE config.ini colors.ini +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..530b10e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1764 @@ +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use std::cell::Cell; +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Entry { + path: PathBuf, + name: String, + directory: bool, + size: u64, + modified: String, +} + +#[derive(Clone)] +struct AppConfig { + sidebar_width: i32, + preview_width: i32, + window_width: i32, + window_height: i32, + text_preview_limit_kib: usize, + image_preview_size: i32, + places: Vec<(String, PathBuf)>, + colors: Palette, + style_values: HashMap, + settings: HashMap, +} + +#[derive(Clone)] +struct Palette(HashMap); + +impl Default for Palette { + fn default() -> Self { + let values = [ + ("background", "#151515"), + ("panel", "#181818"), + ("surface", "#202020"), + ("hover", "#282828"), + ("selected", "#303030"), + ("border", "#2F2F2F"), + ("focus_border", "#454545"), + ("text", "#D0D0D0"), + ("muted", "#858585"), + ("dim", "#505050"), + ("emphasis", "#F5F5F5"), + ("accent", "#6C99BB"), + ("accent_secondary", "#547C99"), + ("directory", "#6C99BB"), + ("success", "#7B9246"), + ("warning", "#D3A04D"), + ("danger", "#A53C23"), + ("teal", "#5EA8A2"), + ("code_keyword", "#9F4E85"), + ("code_string", "#7B9246"), + ("code_number", "#D3A04D"), + ("code_comment", "#505050"), + ("code_function", "#6C99BB"), + ("code_type", "#5EA8A2"), + ("code_constant", "#A53C23"), + ("code_operator", "#547C99"), + ]; + Self( + values + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(), + ) + } +} + +#[derive(Clone)] +struct IniEntry { + section: String, + key: String, + value: String, +} + +fn default_style_values() -> HashMap { + [ + ( + "font_family", + "Maple Mono, \"JetBrainsMono Nerd Font\", monospace", + ), + ("font_size", "10pt"), + ("toolbar_pad_x", "10px"), + ("toolbar_pad_y", "8px"), + ("toolbar_gap", "6px"), + ("control_pad_x", "9px"), + ("control_pad_y", "7px"), + ("control_min_height", "18px"), + ("button_size", "34px"), + ("icon_size", "16px"), + ("corner_radius", "2px"), + ("sidebar_pad_x", "6px"), + ("sidebar_pad_y", "8px"), + ("place_pad_x", "9px"), + ("place_pad_y", "7px"), + ("place_min_height", "20px"), + ("heading_pad_x", "8px"), + ("heading_pad_y", "9px"), + ("preview_pad", "12px"), + ("preview_spacing", "10px"), + ("preview_heading_size", "11pt"), + ("preview_title_size", "10pt"), + ("preview_details_size", "9pt"), + ("preview_title_pad", "2px"), + ("preview_message_pad", "12px"), + ("preview_text_pad", "10px"), + ("top_border_width", "2px"), + ("border_width", "1px"), + ("row_height", "36px"), + ("row_padding_x", "8px"), + ("row_padding_y", "5px"), + ("size_column_width", "72px"), + ("modified_column_width", "126px"), + ("metadata_value_height", "24px"), + ("place_gap", "2px"), + ("status_margin_x", "12px"), + ("status_margin_y", "6px"), + ("metadata_font_size", "10pt"), + ("metadata_pad_top", "3px"), + ("metadata_pad_bottom", "5px"), + ("scrollbar_width", "6px"), + ("scrollbar_thumb_width", "4px"), + ("scrollbar_thumb_radius", "0px"), + ("column_gap", "8px"), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect() +} + +fn default_settings() -> HashMap { + [ + ("compact_columns_width", "520"), + ("filter_width_ratio", "30"), + ("filter_width_min", "84"), + ("filter_width_max", "180"), + ("code_preview", "true"), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect() +} + +fn load_config(home: &Path) -> AppConfig { + let mut config = AppConfig { + sidebar_width: 136, + preview_width: 320, + window_width: 1080, + window_height: 700, + text_preview_limit_kib: 64, + image_preview_size: 512, + places: ["Desktop", "Documents", "Downloads", "Pictures"] + .into_iter() + .map(|name| (name.to_string(), home.join(name))) + .collect(), + colors: Palette::default(), + style_values: default_style_values(), + settings: default_settings(), + }; + let config_path = home.join(".config/wayfinder/config.ini"); + let mut entries = Vec::new(); + load_ini_recursive(&config_path, &mut Vec::new(), &mut entries); + + config.sidebar_width = ini_number(&entries, "general", "sidebar_width", 136, 96, 240); + config.preview_width = ini_number(&entries, "general", "preview_width", 320, 240, 720); + config.window_width = ini_number(&entries, "general", "window_width", 1080, 480, 4096); + config.window_height = ini_number(&entries, "general", "window_height", 700, 320, 4096); + config.text_preview_limit_kib = + ini_number(&entries, "general", "text_preview_limit_kib", 64, 1, 4096) as usize; + config.image_preview_size = + ini_number(&entries, "general", "image_preview_size", 512, 64, 2048); + let configured_places: Vec<_> = entries + .iter() + .filter(|entry| entry.section.eq_ignore_ascii_case("places")) + .map(|entry| (entry.key.clone(), expand_home_path(&entry.value, home))) + .collect(); + if !configured_places.is_empty() { + config.places = configured_places; + } + for entry in entries + .iter() + .filter(|entry| entry.section.eq_ignore_ascii_case("general")) + { + let key = entry.key.to_ascii_lowercase(); + if let Some(current) = config.settings.get_mut(&key) { + if (key == "code_preview" && entry.value.parse::().is_ok()) + || (key != "code_preview" && entry.value.parse::().is_ok()) + { + *current = entry.value.clone(); + } + } + if let Some(current) = config.style_values.get_mut(&key) { + if key == "font_family" { + if entry.value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, ' ' | ',' | '"' | '-' | '_' | '.') + }) { + *current = entry.value.clone(); + } + } else if let Ok(value) = entry.value.parse::() { + let value = if key == "font_size" { + value.clamp(7.0, 24.0) + } else { + value.clamp(0.0, 64.0) + }; + let unit = if key == "font_size" + || (key.ends_with("_size") && key != "button_size") + || key == "metadata_font_size" + { + "pt" + } else { + "px" + }; + *current = format!("{value}{unit}"); + } + } + } + + for entry in entries + .iter() + .filter(|entry| entry.section.eq_ignore_ascii_case("colors")) + { + let key = entry.key.to_ascii_lowercase(); + if valid_color(&entry.value) { + config.colors.0.insert(key, entry.value.clone()); + } + } + config +} + +fn ini_number_from_config( + config: &AppConfig, + key: &str, + default: T, + min: T, + max: T, +) -> T { + config + .settings + .get(key) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) + .clamp(min, max) +} + +fn style_int(config: &AppConfig, key: &str, default: i32) -> i32 { + config + .style_values + .get(key) + .and_then(|value| style_value_int(value)) + .unwrap_or(default) +} + +fn style_value_int(value: &str) -> Option { + value.trim_end_matches("px").parse().ok() +} + +fn load_ini_recursive(path: &Path, stack: &mut Vec, merged: &mut Vec) { + let Ok(path) = fs::canonicalize(path) else { + return; + }; + if stack.contains(&path) { + eprintln!("Wayfinder config include cycle at {}", path.display()); + return; + } + let Ok(contents) = fs::read_to_string(&path) else { + return; + }; + let entries = parse_ini(&contents); + stack.push(path.clone()); + for include in entries.iter().filter(|entry| { + (entry.section.eq_ignore_ascii_case("includes") + && (entry.key.eq_ignore_ascii_case("file") || entry.key.eq_ignore_ascii_case("files"))) + || (entry.section.eq_ignore_ascii_case("general") + && entry.key.eq_ignore_ascii_case("include")) + }) { + for name in include + .value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let include_path = expand_home_path(name, &home); + let include_path = if include_path.is_absolute() { + include_path + } else { + path.parent().unwrap_or(Path::new(".")).join(include_path) + }; + load_ini_recursive(&include_path, stack, merged); + } + } + stack.pop(); + + for entry in entries.into_iter().filter(|entry| { + !entry.section.eq_ignore_ascii_case("includes") + && !(entry.section.eq_ignore_ascii_case("general") + && entry.key.eq_ignore_ascii_case("include")) + }) { + if let Some(existing) = merged.iter_mut().find(|existing| { + existing.section.eq_ignore_ascii_case(&entry.section) + && existing.key.eq_ignore_ascii_case(&entry.key) + }) { + existing.value = entry.value; + } else { + merged.push(entry); + } + } +} + +fn parse_ini(contents: &str) -> Vec { + let mut section = String::new(); + let mut entries = Vec::new(); + for raw in contents.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + section = line[1..line.len() - 1].trim().to_ascii_lowercase(); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + entries.push(IniEntry { + section: section.clone(), + key: key.to_string(), + value: ini_unquote(value.trim()), + }); + } + entries +} + +fn ini_unquote(value: &str) -> String { + let value = value.trim(); + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + value[1..value.len() - 1].to_string() + } else { + value.to_string() + } +} + +fn ini_value(entries: &[IniEntry], section: &str, key: &str) -> Option { + entries + .iter() + .rev() + .find(|entry| { + entry.section.eq_ignore_ascii_case(section) && entry.key.eq_ignore_ascii_case(key) + }) + .map(|entry| entry.value.clone()) +} + +fn ini_number( + entries: &[IniEntry], + section: &str, + key: &str, + default: T, + min: T, + max: T, +) -> T { + ini_value(entries, section, key) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) + .clamp(min, max) +} + +fn expand_home_path(value: &str, home: &Path) -> PathBuf { + if value == "~" { + home.to_path_buf() + } else if let Some(rest) = value.strip_prefix("~/") { + home.join(rest) + } else { + PathBuf::from(value) + } +} + +fn valid_color(value: &str) -> bool { + matches!(value.len(), 4 | 7 | 9) + && value.starts_with('#') + && value[1..].chars().all(|ch| ch.is_ascii_hexdigit()) +} + +#[derive(Clone)] +struct Browser { + location: Rc>, + show_hidden: Rc>, + list: gtk::ListBox, + location_entry: gtk::Entry, + status: gtk::Label, + filter: gtk::SearchEntry, + preview_title: gtk::Label, + preview_details: gtk::Label, + preview_panel: gtk::Box, + preview_stack: gtk::Stack, + preview_text: gtk::TextView, + syntax_tags: HashMap<&'static str, gtk::TextTag>, + code_preview: bool, + preview_picture: gtk::Picture, + preview_message: gtk::Label, + preview_request: Rc>, + preview_job: Rc>>, + preview_file: Rc>>, + size_heading: gtk::Label, + modified_heading: gtk::Label, + available_width: Rc>, + sidebar_width: i32, + preview_width: i32, + text_preview_limit_kib: usize, + image_preview_size: i32, + compact_columns_width: i32, + filter_width_ratio: i32, + filter_width_min: i32, + filter_width_max: i32, + style_values: HashMap, +} + +struct PreviewJob { + request: u64, + child: Child, + output: PathBuf, + started: Instant, +} + +fn main() { + let app = gtk::Application::builder() + .application_id("dev.wfalkowski.Wayfinder") + .flags(gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.connect_activate(build_ui); + app.run(); +} + +fn build_ui(app: >k::Application) { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let config = load_config(&home); + install_style(&config); + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Wayfinder") + .default_width(config.window_width) + .default_height(config.window_height) + .build(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + root.add_css_class("root"); + let toolbar = gtk::Box::new( + gtk::Orientation::Horizontal, + style_int(&config, "toolbar_gap", 6), + ); + toolbar.add_css_class("toolbar"); + + let button_size = config.style_values["button_size"] + .trim_end_matches("px") + .parse() + .unwrap_or(34); + let back = tool_button("", "Go to parent folder", button_size); + let back_icon = gtk::Image::from_icon_name("go-previous"); + back_icon.set_pixel_size(style_int(&config, "icon_size", 16)); + back_icon.set_halign(gtk::Align::Center); + back_icon.set_valign(gtk::Align::Center); + back.set_child(Some(&back_icon)); + back.add_css_class("back-button"); + let refresh = tool_button("↻", "Reload this folder", button_size); + let location_entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text("Enter a folder path") + .build(); + location_entry.add_css_class("location"); + let filter = gtk::SearchEntry::builder() + .placeholder_text("Filter") + .width_chars(1) + .build(); + toolbar.append(&back); + toolbar.append(&refresh); + toolbar.append(&location_entry); + toolbar.append(&filter); + root.append(&toolbar); + + let body = gtk::Box::new(gtk::Orientation::Horizontal, 0); + body.set_vexpand(true); + let sidebar = gtk::Box::new( + gtk::Orientation::Vertical, + style_int(&config, "place_gap", 2), + ); + sidebar.set_width_request(config.sidebar_width); + sidebar.set_hexpand(false); + sidebar.add_css_class("sidebar"); + for (name, path) in &config.places { + let button = gtk::Button::with_label(name); + button.set_halign(gtk::Align::Fill); + if let Some(label) = button + .child() + .and_then(|child| child.downcast::().ok()) + { + label.set_xalign(0.0); + label.set_hexpand(true); + } + button.add_css_class("place"); + // Place actions are wired after the browser exists below. + button.set_tooltip_text(Some(&path.display().to_string())); + button.set_widget_name(&format!("place:{}", path.display())); + sidebar.append(&button); + } + body.append(&sidebar); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_hexpand(true); + content.set_vexpand(true); + let headings = gtk::Box::new( + gtk::Orientation::Horizontal, + style_int(&config, "column_gap", 8), + ); + headings.add_css_class("headings"); + headings.append(&heading("Name", true)); + let size_heading = heading("Size", false); + let modified_heading = heading("Modified", false); + size_heading.set_size_request(style_int(&config, "size_column_width", 72), -1); + modified_heading.set_size_request(style_int(&config, "modified_column_width", 126), -1); + headings.append(&size_heading); + headings.append(&modified_heading); + content.append(&headings); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::Single); + list.set_activate_on_single_click(false); + list.add_css_class("file-list"); + let scroll = gtk::ScrolledWindow::builder() + .child(&list) + .hexpand(true) + .vexpand(true) + .build(); + scroll.set_kinetic_scrolling(false); + content.append(&scroll); + + let preview_heading = gtk::Label::new(Some("Preview")); + preview_heading.set_xalign(0.0); + preview_heading.add_css_class("preview-heading"); + let preview_title = gtk::Label::new(Some("Select a file")); + preview_title.set_xalign(0.0); + preview_title.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + preview_title.add_css_class("preview-title"); + let preview_details = gtk::Label::new(Some("Select a file to preview")); + preview_details.set_xalign(0.0); + preview_details.set_wrap(true); + preview_details.add_css_class("preview-details"); + + let preview_stack = gtk::Stack::new(); + preview_stack.set_hexpand(true); + preview_stack.set_vexpand(true); + preview_stack.set_visible_child_name("empty"); + let preview_empty = gtk::Label::new(Some("Select a file to preview")); + preview_empty.set_wrap(true); + preview_empty.add_css_class("preview-message"); + preview_stack.add_named(&preview_empty, Some("empty")); + + let preview_text = gtk::TextView::new(); + preview_text.set_editable(false); + preview_text.set_cursor_visible(false); + preview_text.set_monospace(true); + preview_text.set_wrap_mode(gtk::WrapMode::None); + preview_text.add_css_class("preview-text"); + let text_buffer = preview_text.buffer(); + let mut syntax_tags = HashMap::new(); + for (name, color_key) in [ + ("keyword", "code_keyword"), + ("string", "code_string"), + ("number", "code_number"), + ("comment", "code_comment"), + ("function", "code_function"), + ("type", "code_type"), + ("constant", "code_constant"), + ("operator", "code_operator"), + ] { + let tag = gtk::TextTag::new(Some(name)); + let color = config + .colors + .0 + .get(color_key) + .map(String::as_str) + .unwrap_or("#D0D0D0"); + tag.set_foreground(Some(color)); + text_buffer.tag_table().add(&tag); + syntax_tags.insert(name, tag); + } + let text_scroll = gtk::ScrolledWindow::builder() + .child(&preview_text) + .hexpand(true) + .vexpand(true) + .build(); + text_scroll.set_kinetic_scrolling(false); + preview_stack.add_named(&text_scroll, Some("text")); + + let preview_picture = gtk::Picture::new(); + preview_picture.set_can_shrink(true); + preview_picture.set_keep_aspect_ratio(true); + preview_picture.set_halign(gtk::Align::Center); + preview_picture.set_valign(gtk::Align::Center); + preview_picture.set_hexpand(true); + preview_picture.set_vexpand(true); + preview_picture.add_css_class("preview-picture"); + preview_stack.add_named(&preview_picture, Some("image")); + + let preview_message = gtk::Label::new(None); + preview_message.set_wrap(true); + preview_message.set_justify(gtk::Justification::Center); + preview_message.add_css_class("preview-message"); + preview_stack.add_named(&preview_message, Some("message")); + + let preview = gtk::Box::new( + gtk::Orientation::Vertical, + style_int(&config, "preview_spacing", 10), + ); + preview.set_width_request(config.preview_width); + preview.set_hexpand(false); + preview.set_vexpand(true); + preview.set_visible(false); + preview.add_css_class("preview-pane"); + preview.append(&preview_heading); + preview.append(&preview_title); + preview.append(&preview_details); + preview.append(&preview_stack); + + let workspace = gtk::Paned::new(gtk::Orientation::Horizontal); + workspace.set_start_child(Some(&content)); + workspace.set_end_child(Some(&preview)); + workspace.set_position(620); + workspace.set_hexpand(true); + workspace.set_vexpand(true); + workspace.set_resize_start_child(true); + workspace.set_resize_end_child(false); + workspace.set_shrink_start_child(false); + workspace.set_shrink_end_child(false); + workspace.add_css_class("workspace-split"); + body.append(&workspace); + root.append(&body); + + let status = gtk::Label::new(None); + status.set_xalign(0.0); + status.add_css_class("status"); + status.set_margin_start(style_int(&config, "status_margin_x", 12)); + status.set_margin_end(style_int(&config, "status_margin_x", 12)); + status.set_margin_top(style_int(&config, "status_margin_y", 6)); + status.set_margin_bottom(style_int(&config, "status_margin_y", 6)); + root.append(&status); + let size_observer = gtk::DrawingArea::new(); + size_observer.set_hexpand(true); + size_observer.set_vexpand(true); + size_observer.set_halign(gtk::Align::Fill); + size_observer.set_valign(gtk::Align::Fill); + size_observer.set_can_target(false); + size_observer.set_opacity(0.0); + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&root)); + overlay.add_overlay(&size_observer); + window.set_child(Some(&overlay)); + + let browser = Browser { + location: Rc::new(RefCell::new(home)), + show_hidden: Rc::new(RefCell::new(false)), + list: list.clone(), + location_entry: location_entry.clone(), + status: status.clone(), + filter: filter.clone(), + preview_title, + preview_details, + preview_panel: preview, + preview_stack, + preview_text, + syntax_tags, + code_preview: config + .settings + .get("code_preview") + .and_then(|v| v.parse().ok()) + .unwrap_or(true), + preview_picture, + preview_message, + preview_request: Rc::new(Cell::new(0)), + preview_job: Rc::new(RefCell::new(None)), + preview_file: Rc::new(RefCell::new(None)), + size_heading, + modified_heading, + available_width: Rc::new(Cell::new(window.default_width())), + sidebar_width: config.sidebar_width, + preview_width: config.preview_width, + text_preview_limit_kib: config.text_preview_limit_kib, + image_preview_size: config.image_preview_size, + compact_columns_width: ini_number_from_config( + &config, + "compact_columns_width", + 520, + 320, + 900, + ), + filter_width_ratio: ini_number_from_config(&config, "filter_width_ratio", 30, 20, 45), + filter_width_min: ini_number_from_config(&config, "filter_width_min", 84, 64, 240), + filter_width_max: ini_number_from_config(&config, "filter_width_max", 180, 100, 360), + style_values: config.style_values.clone(), + }; + { + let b = browser.clone(); + let back = back.clone(); + let refresh = refresh.clone(); + let location_entry = location_entry.clone(); + let filter = filter.clone(); + let toolbar = toolbar.clone(); + size_observer.connect_resize(move |_, width, _| { + b.available_width.set(width); + update_toolbar_widths( + &toolbar, + &back, + &refresh, + &location_entry, + &filter, + b.filter_width_ratio, + b.filter_width_min, + b.filter_width_max, + ); + update_metadata_columns(&b); + }); + } + reload(&browser); + + { + let b = browser.clone(); + location_entry.connect_activate(move |entry| { + let path = PathBuf::from(entry.text().as_str()); + navigate(&b, path); + }); + } + { + let b = browser.clone(); + back.connect_clicked(move |_| { + let parent = b.location.borrow().parent().map(Path::to_path_buf); + if let Some(path) = parent { + navigate(&b, path); + } + }); + } + { + let b = browser.clone(); + refresh.connect_clicked(move |_| reload(&b)); + } + { + let b = browser.clone(); + filter.connect_search_changed(move |_| apply_filter(&b)); + } + { + let b = browser.clone(); + list.connect_row_selected(move |_, row| { + if let Some(path) = row + .and_then(|row| row.tooltip_text()) + .map(|path| PathBuf::from(path.as_str())) + { + show_preview(&b, path); + } else { + clear_preview(&b); + } + }); + } + { + let b = browser.clone(); + list.connect_row_activated(move |_, row| { + if let Some(path) = row.tooltip_text() { + navigate(&b, PathBuf::from(path.as_str())); + } + }); + } + // Sidebar uses the path stored on each button; this avoids hard-coded home assumptions. + let mut child = sidebar.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(button) = widget.clone().downcast::() { + let b = browser.clone(); + let path = button + .widget_name() + .strip_prefix("place:") + .map(PathBuf::from); + if let Some(path) = path { + button.connect_clicked(move |_| navigate(&b, path.clone())); + } + } + } + { + let b = browser.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, state| { + if key == gtk::gdk::Key::Escape && b.preview_panel.is_visible() { + clear_preview(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l { + b.location_entry.grab_focus(); + b.location_entry.select_region(0, -1); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::F5 { + reload(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h { + let visible = !*b.show_hidden.borrow(); + *b.show_hidden.borrow_mut() = visible; + reload(&b); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::BackSpace { + if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) { + navigate(&b, parent); + } + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + window.add_controller(keys); + } + { + let b = browser.clone(); + window.connect_close_request(move |_| { + clear_preview(&b); + glib::Propagation::Proceed + }); + } + window.present(); +} + +fn navigate(browser: &Browser, path: PathBuf) { + if path.is_dir() { + *browser.location.borrow_mut() = path; + reload(browser); + } else if path.is_file() { + match open_file(&path) { + Ok(()) => browser.status.set_text(&format!( + "Opened {}", + path.file_name().unwrap_or_default().to_string_lossy() + )), + Err(error) => browser + .status + .set_text(&format!("Cannot open {}: {error}", path.display())), + } + } else { + browser + .status + .set_text("That path does not exist or cannot be opened."); + } +} + +fn clear_preview(browser: &Browser) { + cancel_preview_work(browser); + browser.preview_panel.set_visible(false); + update_metadata_columns(browser); + browser.preview_title.set_text("Select a file"); + browser.preview_details.set_text("Select a file to preview"); + browser.preview_stack.set_visible_child_name("empty"); +} + +fn cancel_preview_work(browser: &Browser) -> u64 { + let request = browser.preview_request.get().wrapping_add(1); + browser.preview_request.set(request); + if let Some(mut job) = browser.preview_job.borrow_mut().take() { + let _ = job.child.kill(); + let _ = job.child.wait(); + let _ = fs::remove_file(job.output); + } + browser.preview_picture.set_filename(None::<&Path>); + if let Some(path) = browser.preview_file.borrow_mut().take() { + let _ = fs::remove_file(path); + } + request +} + +fn show_preview(browser: &Browser, path: PathBuf) { + if !supports_preview(&path) { + clear_preview(browser); + return; + } + let request = cancel_preview_work(browser); + browser.preview_panel.set_visible(true); + update_metadata_columns(browser); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + browser.preview_title.set_text(&name); + + let details = fs::metadata(&path) + .map(|metadata| { + let modified = metadata + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "Unknown date".into()); + format!("{} · {modified}", format_size(metadata.len())) + }) + .unwrap_or_else(|_| path.display().to_string()); + browser.preview_details.set_text(&details); + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if is_preview_text(&extension) { + show_text_preview(browser, &path); + } else if is_preview_image(&extension) { + start_image_preview(browser, path, request, details); + } +} + +fn update_toolbar_widths( + toolbar: >k::Box, + back: >k::Button, + refresh: >k::Button, + location: >k::Entry, + filter: >k::SearchEntry, + filter_ratio: i32, + filter_min: i32, + filter_max: i32, +) { + let controls = back.width().max(34) + refresh.width().max(34) + 3 * 6 + 20; + let available = (toolbar.width() - controls).max(168); + let filter_outer = (available * filter_ratio / 100).clamp(filter_min, filter_max); + let location_outer = (available - filter_outer).max(84); + // Requests are content widths; the toolbar inputs add 20 px of CSS padding and border. + filter.set_width_request((filter_outer - 20).max(64)); + location.set_width_request((location_outer - 20).max(64)); +} + +fn update_metadata_columns(browser: &Browser) { + let preview_width = if browser.preview_panel.is_visible() { + browser.preview_width + 24 + } else { + 0 + }; + let file_area_width = browser.available_width.get() - browser.sidebar_width - preview_width; + let visible = + !browser.preview_panel.is_visible() || file_area_width >= browser.compact_columns_width; + browser.size_heading.set_visible(visible); + browser.modified_heading.set_visible(visible); + + let mut next = browser.list.first_child(); + while let Some(row) = next { + next = row.next_sibling(); + let Some(line) = row.first_child() else { + continue; + }; + if let Some(size) = line.first_child().and_then(|name| name.next_sibling()) { + size.set_visible(visible); + if let Some(modified) = size.next_sibling() { + modified.set_visible(visible); + } + } + } +} + +fn supports_preview(path: &Path) -> bool { + if path.is_dir() { + return false; + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + is_preview_text(&extension) || is_preview_image(&extension) +} + +fn set_preview_message(browser: &Browser, message: &str) { + browser.preview_message.set_text(message); + browser.preview_stack.set_visible_child_name("message"); +} + +fn is_preview_text(extension: &str) -> bool { + matches!( + extension, + "txt" + | "md" + | "markdown" + | "rs" + | "toml" + | "json" + | "yaml" + | "yml" + | "ini" + | "conf" + | "log" + | "sh" + | "bash" + | "zsh" + | "fish" + | "c" + | "h" + | "cc" + | "cpp" + | "hpp" + | "py" + | "html" + | "css" + | "js" + | "ts" + | "xml" + | "csv" + | "lock" + | "desktop" + | "service" + | "lua" + | "nix" + ) +} + +fn is_preview_image(extension: &str) -> bool { + matches!( + extension, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "avif" | "ico" + ) +} + +fn show_text_preview(browser: &Browser, path: &Path) { + let preview_limit = browser.text_preview_limit_kib * 1024; + let mut bytes = Vec::with_capacity(preview_limit + 1); + let result = fs::File::open(path).and_then(|file| { + file.take((preview_limit + 1) as u64) + .read_to_end(&mut bytes) + }); + if let Err(error) = result { + set_preview_message(browser, &format!("Cannot read this file: {error}")); + return; + } + if bytes.contains(&0) { + set_preview_message( + browser, + "This file contains binary data and cannot be previewed as text.", + ); + return; + } + let truncated = bytes.len() > preview_limit; + bytes.truncate(preview_limit); + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + text.push_str(&format!( + "\n\n[Preview limited to the first {} KiB]", + browser.text_preview_limit_kib + )); + } + let buffer = browser.preview_text.buffer(); + buffer.set_text(&text); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if browser.code_preview && is_code_extension(&extension) { + for (start, end, kind) in syntax_ranges(&text, &extension) { + if let Some(tag) = browser.syntax_tags.get(kind) { + let start = buffer.iter_at_offset(text[..start].chars().count() as i32); + let end = buffer.iter_at_offset(text[..end].chars().count() as i32); + buffer.apply_tag(tag, &start, &end); + } + } + } + browser.preview_stack.set_visible_child_name("text"); +} + +fn is_code_extension(ext: &str) -> bool { + matches!( + ext, + "rs" | "toml" + | "json" + | "yaml" + | "yml" + | "ini" + | "conf" + | "sh" + | "bash" + | "zsh" + | "fish" + | "c" + | "h" + | "cc" + | "cpp" + | "hpp" + | "py" + | "html" + | "css" + | "js" + | "ts" + | "xml" + | "lua" + | "nix" + | "sql" + | "go" + | "java" + | "kt" + | "swift" + | "rb" + | "php" + | "pl" + | " Makefile" + ) +} + +fn syntax_ranges(text: &str, ext: &str) -> Vec<(usize, usize, &'static str)> { + let bytes = text.as_bytes(); + let mut ranges = Vec::new(); + let (hash_comments, dash_comments) = ( + matches!( + ext, + "py" | "sh" + | "bash" + | "zsh" + | "fish" + | "nix" + | "yaml" + | "yml" + | "toml" + | "ini" + | "conf" + | "rb" + | "pl" + ), + matches!(ext, "lua" | "sql"), + ); + let mut i = 0; + let mut previous_word = ""; + while i < bytes.len() { + let start = i; + if bytes[i].is_ascii_whitespace() { + i += 1; + continue; + } + let line_start = i == 0 || text[..i].ends_with('\n'); + let line_comment = bytes.get(i..i + 2) == Some(b"//") + || (hash_comments && bytes[i] == b'#') + || (dash_comments && bytes.get(i..i + 2) == Some(b"--")); + if line_comment { + i = text[i..].find('\n').map(|n| i + n).unwrap_or(bytes.len()); + ranges.push((start, i, "comment")); + continue; + } + if bytes.get(i..i + 2) == Some(b"/*") { + i += 2; + while i + 1 < bytes.len() && &bytes[i..i + 2] != b"*/" { + i += text[i..].chars().next().unwrap().len_utf8(); + } + i = (i + 2).min(bytes.len()); + ranges.push((start, i, "comment")); + continue; + } + if matches!(bytes[i], b'\'' | b'"' | b'`') { + let quote = bytes[i]; + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i = (i + 2).min(bytes.len()); + continue; + } + if bytes[i] == quote { + i += 1; + break; + } + if bytes[i] == b'\n' && quote != b'`' { + break; + } + i += text[i..].chars().next().unwrap().len_utf8(); + } + ranges.push((start, i, "string")); + previous_word = ""; + continue; + } + if bytes[i].is_ascii_digit() { + i += 1; + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'.' | b'_')) + { + i += 1; + } + ranges.push((start, i, "number")); + previous_word = ""; + continue; + } + if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' { + i += 1; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + let word = &text[start..i]; + let kind = if matches!(previous_word, "fn" | "def" | "function" | "func") { + Some("function") + } else if matches!( + previous_word, + "struct" | "class" | "enum" | "trait" | "type" | "interface" | "impl" + ) { + Some("type") + } else if matches!( + word, + "true" + | "false" + | "None" + | "null" + | "undefined" + | "nil" + | "self" + | "Self" + | "this" + | "TRUE" + | "FALSE" + ) { + Some("constant") + } else if KEYWORDS.contains(&word) { + Some("keyword") + } else { + None + }; + if let Some(kind) = kind { + ranges.push((start, i, kind)); + } + previous_word = word; + continue; + } + let ch = text[i..].chars().next().unwrap(); + i += ch.len_utf8(); + if !line_start && !ch.is_alphanumeric() { + ranges.push((start, i, "operator")); + } + previous_word = ""; + } + ranges +} + +const KEYWORDS: &[&str] = &[ + "as", + "async", + "await", + "break", + "case", + "catch", + "const", + "continue", + "else", + "export", + "extends", + "for", + "from", + "fn", + "if", + "import", + "in", + "let", + "loop", + "match", + "mut", + "new", + "priv", + "pub", + "raise", + "return", + "static", + "throw", + "try", + "use", + "var", + "while", + "where", + "with", + "yield", + "def", + "class", + "function", + "func", + "struct", + "enum", + "trait", + "type", + "interface", + "impl", + "package", + "module", + "do", + "then", + "end", + "local", + "select", + "insert", + "update", + "delete", + "create", + "true", + "false", +]; + +fn start_image_preview(browser: &Browser, path: PathBuf, request: u64, details: String) { + let source = match fs::canonicalize(&path) { + Ok(source) => source, + Err(error) => { + set_preview_message(browser, &format!("Cannot open this image: {error}")); + return; + } + }; + let output_dir = std::env::temp_dir().join(format!("wayfinder-preview-{}", std::process::id())); + if let Err(error) = fs::create_dir_all(&output_dir) { + set_preview_message(browser, &format!("Cannot create preview cache: {error}")); + return; + } + if let Err(error) = fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o700)) { + set_preview_message(browser, &format!("Cannot secure preview cache: {error}")); + return; + } + let output = output_dir.join(format!("preview-{request}.png")); + let _ = fs::remove_file(&output); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let is_jpeg = matches!(extension.to_ascii_lowercase().as_str(), "jpg" | "jpeg"); + let thumbnail_size = browser.image_preview_size; + let sandbox_input = format!("/wayfinder-input/preview.{extension}"); + let mut command = Command::new("bwrap"); + command + .args([ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/etc", + "/etc", + "--ro-bind-try", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--dir", + "/wayfinder-input", + "--ro-bind", + ]) + .arg(&source) + .arg(&sandbox_input) + .arg("--bind") + .arg(&output_dir) + .args([ + "/wayfinder-output", + "--chdir", + "/", + "--setenv", + "HOME", + "/tmp", + "--", + ]); + let sandbox_output = format!("/wayfinder-output/preview-{request}.png"); + if is_jpeg { + let script = format!( + "from PIL import Image; import sys; Image.MAX_IMAGE_PIXELS = 100000000; im = Image.open(sys.argv[1]); im.thumbnail(({thumbnail_size}, {thumbnail_size}), Image.Resampling.LANCZOS); im.convert('RGBA' if 'A' in im.getbands() else 'RGB').save(sys.argv[2], format='PNG')" + ); + command + .args(["/usr/bin/python3", "-c", &script]) + .arg(&sandbox_input) + .arg(&sandbox_output); + } else { + command + .arg("/usr/bin/gdk-pixbuf-thumbnailer") + .arg("--size") + .arg(thumbnail_size.to_string()) + .arg(&sandbox_input) + .arg(&sandbox_output); + } + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::null()); + + match command.spawn() { + Ok(child) => { + browser + .preview_details + .set_text("Generating image preview…"); + *browser.preview_job.borrow_mut() = Some(PreviewJob { + request, + child, + output: output.clone(), + started: Instant::now(), + }); + let browser = browser.clone(); + glib::timeout_add_local(Duration::from_millis(80), move || { + if browser.preview_request.get() != request { + return glib::ControlFlow::Break; + } + let completion = { + let mut pending = browser.preview_job.borrow_mut(); + let Some(job) = pending.as_mut() else { + return glib::ControlFlow::Break; + }; + if job.request != request { + return glib::ControlFlow::Break; + } + match job.child.try_wait() { + Ok(None) if job.started.elapsed() < Duration::from_secs(8) => None, + Ok(None) => { + let _ = job.child.kill(); + let _ = job.child.wait(); + Some(false) + } + Ok(Some(status)) => Some(status.success()), + Err(_) => Some(false), + } + .map(|success| { + let job = pending.take().expect("preview job exists"); + (success, job.output) + }) + }; + let Some((success, output)) = completion else { + return glib::ControlFlow::Continue; + }; + if success && output.is_file() { + browser.preview_picture.set_filename(Some(&output)); + browser.preview_details.set_text(&details); + *browser.preview_file.borrow_mut() = Some(output); + browser.preview_stack.set_visible_child_name("image"); + } else { + let _ = fs::remove_file(output); + set_preview_message( + &browser, + "Image preview failed. The format may be unsupported, or the sandboxed thumbnailer may be unavailable.", + ); + } + glib::ControlFlow::Break + }); + } + Err(error) => set_preview_message( + browser, + &format!("Could not start the sandboxed image preview: {error}"), + ), + } +} + +fn open_file(path: &Path) -> Result<(), String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let text_extensions = [ + "txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh", + "bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts", + "xml", "csv", "lock", "desktop", "service", "lua", "nix", + ]; + let image_extensions = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif", + ]; + + let mut command = if text_extensions.contains(&extension.as_str()) { + let mut command = Command::new("foot"); + command.args(["-e", "nvim", "--"]).arg(path); + command + } else if image_extensions.contains(&extension.as_str()) { + let mut command = Command::new("imv"); + command.arg(path); + command + } else if fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + { + let mut command = Command::new("foot"); + command.arg("-e").arg(path); + command + } else { + let uri = gio::File::for_path(path).uri(); + return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>) + .map_err(|error| error.to_string()); + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not start the requested application: {error}")) +} + +fn reload(browser: &Browser) { + clear_preview(browser); + while let Some(row) = browser.list.first_child() { + browser.list.remove(&row); + } + let path = browser.location.borrow().clone(); + browser.location_entry.set_text(&path.display().to_string()); + let entries = match fs::read_dir(&path) { + Ok(items) => items, + Err(error) => { + browser + .status + .set_text(&format!("Cannot read {}: {error}", path.display())); + return; + } + }; + let mut rows = Vec::new(); + for item in entries.flatten() { + let path = item.path(); + let name = item.file_name().to_string_lossy().into_owned(); + if !*browser.show_hidden.borrow() && name.starts_with('.') { + continue; + } + let Ok(meta) = item.metadata() else { continue }; + let directory = meta.is_dir(); + let modified = meta + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "—".into()); + rows.push(Entry { + path, + name, + directory, + size: meta.len(), + modified, + }); + } + rows.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in rows { + let row = make_row(&entry, &browser.style_values); + row.set_selectable(true); + row.set_activatable(true); + row.set_tooltip_text(Some(&entry.path.to_string_lossy())); + browser.list.append(&row); + } + let count = browser.list.observe_children().n_items(); + browser + .status + .set_text(&format!("{count} items · {}", path.display())); + apply_filter(browser); +} + +fn make_row(entry: &Entry, style_values: &HashMap) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let get_int = |key: &str, default| { + style_values + .get(key) + .and_then(|value| style_value_int(value)) + .unwrap_or(default) + }; + let line = gtk::Box::new(gtk::Orientation::Horizontal, get_int("column_gap", 8)); + line.set_margin_start(get_int("row_padding_x", 8)); + line.set_margin_end(get_int("row_padding_x", 8)); + line.set_margin_top(get_int("row_padding_y", 5)); + line.set_margin_bottom(get_int("row_padding_y", 5)); + let name = gtk::Label::new(None); + let display_name: String = entry + .name + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect(); + name.set_text(&if entry.directory { + format!("{display_name}/") + } else { + display_name + }); + name.set_xalign(0.0); + name.set_wrap(true); + name.set_wrap_mode(gtk::pango::WrapMode::WordChar); + name.set_valign(gtk::Align::Center); + name.set_hexpand(true); + name.add_css_class(if entry.directory { + "directory" + } else { + "filename" + }); + let size_text = if entry.directory { + "Folder".to_string() + } else { + format_size(entry.size) + }; + let size = metadata_value( + &size_text, + get_int("size_column_width", 72), + get_int("metadata_value_height", 24), + ); + let modified = metadata_value( + &entry.modified, + get_int("modified_column_width", 126), + get_int("metadata_value_height", 24), + ); + line.append(&name); + line.append(&size); + line.append(&modified); + row.set_child(Some(&line)); + row +} + +fn heading(text: &str, expand: bool) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + if expand { + label.set_hexpand(true); + } else { + label.set_size_request(if text == "Size" { 72 } else { 126 }, -1); + } + label.add_css_class("column-heading"); + label +} + +fn metadata_value(text: &str, width: i32, height: i32) -> gtk::Label { + let value = gtk::Label::new(Some(text)); + value.set_size_request(width, height); + value.set_xalign(0.0); + value.set_valign(gtk::Align::Center); + value.set_ellipsize(gtk::pango::EllipsizeMode::End); + value.add_css_class("metadata"); + value +} + +fn tool_button(label: &str, tip: &str, size: i32) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.set_tooltip_text(Some(tip)); + button.set_size_request(size, size); + button.add_css_class("tool-button"); + button +} + +fn apply_filter(browser: &Browser) { + let needle = browser.filter.text().to_lowercase(); + let mut child = browser.list.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(row) = widget.clone().downcast::() { + let name = row + .child() + .and_then(|line| line.first_child()) + .and_then(|label| label.downcast::().ok()) + .map(|label| label.text().to_string().to_lowercase()) + .unwrap_or_default(); + row.set_visible(name.contains(&needle)); + } + } +} + +fn format_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_time(time: std::time::SystemTime) -> String { + // Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable. + match time.elapsed() { + Ok(age) if age.as_secs() < 60 => "Just now".into(), + Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60), + Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600), + Ok(age) => format!("{} days ago", age.as_secs() / 86400), + Err(_) => "Just now".into(), + } +} + +fn install_style(config: &AppConfig) { + let provider = gtk::CssProvider::new(); + let css = r#" + * { font-family: @font_family@; font-size: @font_size@; } + window, .root { background: @background@; color: @text@; } + .toolbar { background: @panel@; padding: @toolbar_pad_y@ @toolbar_pad_x@; } + entry, searchentry { color: @text@; background: @surface@; border: @border_width@ solid @border@; border-radius: @corner_radius@; padding: @control_pad_y@ @control_pad_x@; min-height: @control_min_height@; } + entry:focus, searchentry:focus { border-color: @accent@; } + .tool-button { color: @text@; background: @surface@; border: @border_width@ solid @border@; border-radius: @corner_radius@; padding: 0; min-width: @button_size@; min-height: @button_size@; } + .back-button { min-width: @button_size@; min-height: @button_size@; } + .tool-button:hover { color: @emphasis@; background: @selected@; border-color: @accent@; } + .tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: @border_width@ solid @focus_border@; outline-offset: -2px; } + .sidebar { background: @panel@; border-top: @top_border_width@ solid @border@; border-right: @border_width@ solid @border@; padding: @sidebar_pad_y@ @sidebar_pad_x@; min-width: @sidebar_width@px; max-width: @sidebar_width@px; } + .place { color: @text@; background: transparent; border: @border_width@ solid transparent; border-radius: @corner_radius@; padding: @place_pad_y@ @place_pad_x@; min-height: @place_min_height@; } + .place:hover { color: @emphasis@; background: @hover@; border-color: @border@; } + .place:active { background: @selected@; } + .headings { background: @panel@; border-top: @top_border_width@ solid @border@; border-bottom: @border_width@ solid @border@; padding: @heading_pad_y@ @heading_pad_x@; } + .preview-pane { background: @panel@; border-left: @border_width@ solid @border@; padding: @preview_pad@; min-width: @preview_width@px; max-width: @preview_width@px; } + .preview-heading { color: @emphasis@; font-size: @preview_heading_size@; } + .preview-title { color: @text@; font-size: @preview_title_size@; padding: @preview_title_pad@ 0; } + .preview-details { color: @muted@; font-size: @preview_details_size@; padding: @preview_title_pad@ 0; } + .preview-message { color: @muted@; padding: @preview_message_pad@; } + .preview-text { background: @background@; color: @text@; padding: @preview_text_pad@; } + .preview-picture { background: @background@; } + .workspace-split > separator { background: @border@; min-width: @border_width@; } + .column-heading { color: @muted@; font-size: @preview_details_size@; } + .file-list { background: @background@; } + .file-list row { border-bottom: @border_width@ solid @border@; min-height: @row_height@; } + .file-list row:hover { background: @hover@; } + .file-list row:selected { background: @selected@; outline: @border_width@ solid @accent@; outline-offset: -1px; } + .directory { color: @directory@; } + .filename { color: @text@; } + .metadata, .status { color: @muted@; font-size: @metadata_font_size@; } + .metadata { padding: @metadata_pad_top@ 0 @metadata_pad_bottom@; } + scrolledwindow { border: 0; } + scrolledwindow overshoot { opacity: 0; background: transparent; border: none; box-shadow: none; } + scrollbar { background: transparent; border: none; box-shadow: none; } + scrollbar.vertical { min-width: @scrollbar_width@; } + scrollbar.horizontal { min-height: @scrollbar_width@; } + scrollbar > range > trough { background: transparent; border: none; box-shadow: none; } + scrollbar > range > trough > slider { background: @accent@; min-width: @scrollbar_thumb_width@; min-height: @scrollbar_thumb_width@; border: none; border-radius: @scrollbar_thumb_radius@; outline-style: none; outline-width: 0; box-shadow: none; } + scrollbar > range > trough > slider:hover, + scrollbar > range > trough > slider:active, + scrollbar > range > trough > slider:focus { background: @accent@; border: none; outline-style: none; outline-width: 0; box-shadow: none; } + scrollbar button { min-width: 0; min-height: 0; padding: 0; border: 0; } + "#; + let mut css = css.to_string(); + for (key, value) in &config.colors.0 { + css = css.replace(&format!("@{key}@"), value); + } + for (key, value) in &config.style_values { + css = css.replace(&format!("@{key}@"), value); + } + css = css + .replace("@sidebar_width@", &config.sidebar_width.to_string()) + .replace("@preview_width@", &config.preview_width.to_string()); + provider.load_from_data(&css); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/src/main.rs.backup-20261018T115319Z b/src/main.rs.backup-20261018T115319Z new file mode 100644 index 0000000..6e1bf51 --- /dev/null +++ b/src/main.rs.backup-20261018T115319Z @@ -0,0 +1,498 @@ +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use std::cell::RefCell; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::rc::Rc; + +#[derive(Clone)] +struct Entry { + path: PathBuf, + name: String, + directory: bool, + size: u64, + modified: String, +} + +#[derive(Clone)] +struct Browser { + location: Rc>, + show_hidden: Rc>, + list: gtk::ListBox, + location_entry: gtk::Entry, + status: gtk::Label, + filter: gtk::SearchEntry, +} + +fn main() { + let app = gtk::Application::builder() + .application_id("dev.wfalkowski.Wayfinder") + .flags(gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.connect_activate(build_ui); + app.run(); +} + +fn build_ui(app: >k::Application) { + install_style(); + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Wayfinder") + .default_width(1080) + .default_height(700) + .build(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + root.add_css_class("root"); + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); + toolbar.add_css_class("toolbar"); + + let back = tool_button("", "Go to parent folder"); + let back_icon = gtk::Image::from_icon_name("go-previous"); + back_icon.set_pixel_size(16); + back_icon.set_halign(gtk::Align::Center); + back_icon.set_valign(gtk::Align::Center); + back.set_child(Some(&back_icon)); + back.add_css_class("back-button"); + let refresh = tool_button("↻", "Reload this folder"); + let location_entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text("Enter a folder path") + .build(); + location_entry.add_css_class("location"); + let filter = gtk::SearchEntry::builder() + .placeholder_text("Filter") + .width_chars(18) + .build(); + toolbar.append(&back); + toolbar.append(&refresh); + toolbar.append(&location_entry); + toolbar.append(&filter); + root.append(&toolbar); + + let body = gtk::Box::new(gtk::Orientation::Horizontal, 0); + body.set_vexpand(true); + let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 2); + sidebar.set_width_request(168); + sidebar.add_css_class("sidebar"); + let places = [ + ("Desktop", home.join("Desktop")), + ("Documents", home.join("Documents")), + ("Downloads", home.join("Downloads")), + ("Pictures", home.join("Pictures")), + ]; + for (label, path) in places { + let button = gtk::Button::with_label(label); + button.set_halign(gtk::Align::Fill); + button.add_css_class("place"); + // Place actions are wired after the browser exists below. + button.set_tooltip_text(Some(&path.display().to_string())); + button.set_widget_name(&format!("place:{}", path.display())); + sidebar.append(&button); + } + body.append(&sidebar); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_hexpand(true); + content.set_vexpand(true); + let headings = gtk::Box::new(gtk::Orientation::Horizontal, 8); + headings.add_css_class("headings"); + headings.append(&heading("Name", true)); + headings.append(&heading("Size", false)); + headings.append(&heading("Modified", false)); + content.append(&headings); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::Single); + list.add_css_class("file-list"); + let scroll = gtk::ScrolledWindow::builder() + .child(&list) + .hexpand(true) + .vexpand(true) + .build(); + content.append(&scroll); + body.append(&content); + root.append(&body); + + let status = gtk::Label::new(None); + status.set_xalign(0.0); + status.add_css_class("status"); + status.set_margin_start(12); + status.set_margin_end(12); + status.set_margin_top(6); + status.set_margin_bottom(6); + root.append(&status); + window.set_child(Some(&root)); + + let browser = Browser { + location: Rc::new(RefCell::new(home)), + show_hidden: Rc::new(RefCell::new(false)), + list: list.clone(), + location_entry: location_entry.clone(), + status: status.clone(), + filter: filter.clone(), + }; + reload(&browser); + + { + let b = browser.clone(); + location_entry.connect_activate(move |entry| { + let path = PathBuf::from(entry.text().as_str()); + navigate(&b, path); + }); + } + { + let b = browser.clone(); + back.connect_clicked(move |_| { + let parent = b.location.borrow().parent().map(Path::to_path_buf); + if let Some(path) = parent { + navigate(&b, path); + } + }); + } + { + let b = browser.clone(); + refresh.connect_clicked(move |_| reload(&b)); + } + { + let b = browser.clone(); + filter.connect_search_changed(move |_| apply_filter(&b)); + } + { + let b = browser.clone(); + list.connect_row_activated(move |_, row| { + if let Some(path) = row.tooltip_text() { + navigate(&b, PathBuf::from(path.as_str())); + } + }); + } + // Sidebar uses the path stored on each button; this avoids hard-coded home assumptions. + let mut child = sidebar.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(button) = widget.clone().downcast::() { + let b = browser.clone(); + let path = button + .widget_name() + .strip_prefix("place:") + .map(PathBuf::from); + if let Some(path) = path { + button.connect_clicked(move |_| navigate(&b, path.clone())); + } + } + } + { + let b = browser.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, state| { + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l { + b.location_entry.grab_focus(); + b.location_entry.select_region(0, -1); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::F5 { + reload(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h { + let visible = !*b.show_hidden.borrow(); + *b.show_hidden.borrow_mut() = visible; + reload(&b); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::BackSpace { + if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) { + navigate(&b, parent); + } + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + window.add_controller(keys); + } + window.present(); +} + +fn navigate(browser: &Browser, path: PathBuf) { + if path.is_dir() { + *browser.location.borrow_mut() = path; + reload(browser); + } else if path.is_file() { + match open_file(&path) { + Ok(()) => browser.status.set_text(&format!( + "Opened {}", + path.file_name().unwrap_or_default().to_string_lossy() + )), + Err(error) => browser + .status + .set_text(&format!("Cannot open {}: {error}", path.display())), + } + } else { + browser + .status + .set_text("That path does not exist or cannot be opened."); + } +} + +fn open_file(path: &Path) -> Result<(), String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let text_extensions = [ + "txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh", + "bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts", + "xml", "csv", "lock", "desktop", "service", "lua", "nix", + ]; + let image_extensions = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif", + ]; + + let mut command = if text_extensions.contains(&extension.as_str()) { + let mut command = Command::new("foot"); + command.args(["-e", "nvim", "--"]).arg(path); + command + } else if image_extensions.contains(&extension.as_str()) { + let mut command = Command::new("imv"); + command.arg(path); + command + } else if fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + { + let mut command = Command::new("foot"); + command.arg("-e").arg(path); + command + } else { + let uri = gio::File::for_path(path).uri(); + return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>) + .map_err(|error| error.to_string()); + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not start the requested application: {error}")) +} + +fn reload(browser: &Browser) { + while let Some(row) = browser.list.first_child() { + browser.list.remove(&row); + } + let path = browser.location.borrow().clone(); + browser.location_entry.set_text(&path.display().to_string()); + let entries = match fs::read_dir(&path) { + Ok(items) => items, + Err(error) => { + browser + .status + .set_text(&format!("Cannot read {}: {error}", path.display())); + return; + } + }; + let mut rows = Vec::new(); + for item in entries.flatten() { + let path = item.path(); + let name = item.file_name().to_string_lossy().into_owned(); + if !*browser.show_hidden.borrow() && name.starts_with('.') { + continue; + } + let Ok(meta) = item.metadata() else { continue }; + let directory = meta.is_dir(); + let modified = meta + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "—".into()); + rows.push(Entry { + path, + name, + directory, + size: meta.len(), + modified, + }); + } + rows.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in rows { + let row = make_row(&entry); + row.set_selectable(true); + row.set_activatable(true); + row.set_tooltip_text(Some(&entry.path.to_string_lossy())); + browser.list.append(&row); + } + let count = browser.list.observe_children().n_items(); + browser + .status + .set_text(&format!("{count} items · {}", path.display())); + apply_filter(browser); +} + +fn make_row(entry: &Entry) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let line = gtk::Box::new(gtk::Orientation::Horizontal, 8); + line.set_margin_start(8); + line.set_margin_end(8); + line.set_margin_top(5); + line.set_margin_bottom(5); + let name = gtk::Label::new(None); + let display_name: String = entry + .name + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect(); + name.set_text(&if entry.directory { + format!("{display_name}/") + } else { + display_name + }); + name.set_xalign(0.0); + name.set_wrap(true); + name.set_wrap_mode(gtk::pango::WrapMode::WordChar); + name.set_valign(gtk::Align::Center); + name.set_hexpand(true); + name.add_css_class(if entry.directory { + "directory" + } else { + "filename" + }); + let size_text = if entry.directory { + "Folder".to_string() + } else { + format_size(entry.size) + }; + let size = metadata_value(&size_text, 72); + let modified = metadata_value(&entry.modified, 126); + line.append(&name); + line.append(&size); + line.append(&modified); + row.set_child(Some(&line)); + row +} + +fn heading(text: &str, expand: bool) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + if expand { + label.set_hexpand(true); + } else { + label.set_size_request(if text == "Size" { 72 } else { 126 }, -1); + } + label.add_css_class("column-heading"); + label +} + +fn metadata_value(text: &str, width: i32) -> gtk::Label { + let value = gtk::Label::new(Some(text)); + value.set_size_request(width, 24); + value.set_xalign(0.0); + value.set_valign(gtk::Align::Center); + value.set_ellipsize(gtk::pango::EllipsizeMode::End); + value.add_css_class("metadata"); + value +} + +fn tool_button(label: &str, tip: &str) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.set_tooltip_text(Some(tip)); + button.set_size_request(34, 34); + button.add_css_class("tool-button"); + button +} + +fn apply_filter(browser: &Browser) { + let needle = browser.filter.text().to_lowercase(); + let mut child = browser.list.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(row) = widget.clone().downcast::() { + let name = row + .child() + .and_then(|line| line.first_child()) + .and_then(|label| label.downcast::().ok()) + .map(|label| label.text().to_string().to_lowercase()) + .unwrap_or_default(); + row.set_visible(name.contains(&needle)); + } + } +} + +fn format_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_time(time: std::time::SystemTime) -> String { + // Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable. + match time.elapsed() { + Ok(age) if age.as_secs() < 60 => "Just now".into(), + Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60), + Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600), + Ok(age) => format!("{} days ago", age.as_secs() / 86400), + Err(_) => "Just now".into(), + } +} + +fn install_style() { + let provider = gtk::CssProvider::new(); + provider.load_from_data(r#" + * { font-family: "Maple Mono", "JetBrainsMono Nerd Font", monospace; font-size: 10pt; } + window, .root { background: #151515; color: #D0D0D0; } + .toolbar { background: #181818; padding: 8px 10px; } + entry, searchentry { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 7px 9px; min-height: 18px; } + entry:focus, searchentry:focus { border-color: #6C99BB; } + .tool-button { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 0; min-width: 34px; min-height: 34px; } + .back-button { min-width: 34px; min-height: 34px; } + .tool-button:hover { color: #F5F5F5; background: #303030; border-color: #454545; } + .tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: 1px solid #6C99BB; outline-offset: -2px; } + .sidebar { background: #181818; border-top: 2px solid #2F2F2F; border-right: 1px solid #2F2F2F; padding: 8px 6px; } + .place { color: #D0D0D0; background: transparent; border: 1px solid transparent; border-radius: 2px; padding: 7px 9px; min-height: 20px; text-align: left; } + .place:hover { color: #F5F5F5; background: #202020; border-color: #2F2F2F; } + .place:active { background: #303030; } + .headings { background: #181818; border-top: 2px solid #2F2F2F; border-bottom: 1px solid #2F2F2F; padding: 9px 8px; } + .column-heading { color: #757575; font-size: 9pt; } + .file-list { background: #151515; } + .file-list row { border-bottom: 1px solid #202020; min-height: 36px; } + .file-list row:hover { background: #202020; } + .file-list row:selected { background: #303030; outline: 1px solid #6C99BB; outline-offset: -1px; } + .directory { color: #8DB3D0; } + .filename { color: #D0D0D0; } + .metadata, .status { color: #858585; font-size: 10pt; } + .metadata { padding: 3px 0 5px; } + scrolledwindow { border: 0; } + scrollbar slider { background: #454545; min-width: 7px; min-height: 24px; border-radius: 2px; } + "#); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/src/main.rs.backup-20261018T134521Z b/src/main.rs.backup-20261018T134521Z new file mode 100644 index 0000000..35eaacf --- /dev/null +++ b/src/main.rs.backup-20261018T134521Z @@ -0,0 +1,929 @@ +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use std::cell::Cell; +use std::cell::RefCell; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Entry { + path: PathBuf, + name: String, + directory: bool, + size: u64, + modified: String, +} + +#[derive(Clone)] +struct Browser { + location: Rc>, + show_hidden: Rc>, + list: gtk::ListBox, + location_entry: gtk::Entry, + status: gtk::Label, + filter: gtk::SearchEntry, + preview_title: gtk::Label, + preview_details: gtk::Label, + preview_panel: gtk::Box, + preview_stack: gtk::Stack, + preview_text: gtk::TextView, + preview_picture: gtk::Picture, + preview_message: gtk::Label, + preview_request: Rc>, + preview_job: Rc>>, + preview_file: Rc>>, +} + +struct PreviewJob { + request: u64, + child: Child, + output: PathBuf, + started: Instant, +} + +fn main() { + let app = gtk::Application::builder() + .application_id("dev.wfalkowski.Wayfinder") + .flags(gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.connect_activate(build_ui); + app.run(); +} + +fn build_ui(app: >k::Application) { + install_style(); + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Wayfinder") + .default_width(1080) + .default_height(700) + .build(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + root.add_css_class("root"); + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); + toolbar.add_css_class("toolbar"); + + let back = tool_button("", "Go to parent folder"); + let back_icon = gtk::Image::from_icon_name("go-previous"); + back_icon.set_pixel_size(16); + back_icon.set_halign(gtk::Align::Center); + back_icon.set_valign(gtk::Align::Center); + back.set_child(Some(&back_icon)); + back.add_css_class("back-button"); + let refresh = tool_button("↻", "Reload this folder"); + let location_entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text("Enter a folder path") + .build(); + location_entry.add_css_class("location"); + let filter = gtk::SearchEntry::builder() + .placeholder_text("Filter") + .width_chars(18) + .build(); + toolbar.append(&back); + toolbar.append(&refresh); + toolbar.append(&location_entry); + toolbar.append(&filter); + root.append(&toolbar); + + let body = gtk::Box::new(gtk::Orientation::Horizontal, 0); + body.set_vexpand(true); + let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 2); + sidebar.set_width_request(168); + sidebar.set_hexpand(false); + sidebar.add_css_class("sidebar"); + let places = [ + ("Desktop", home.join("Desktop")), + ("Documents", home.join("Documents")), + ("Downloads", home.join("Downloads")), + ("Pictures", home.join("Pictures")), + ]; + for (label, path) in places { + let button = gtk::Button::with_label(label); + button.set_halign(gtk::Align::Fill); + if let Some(label) = button + .child() + .and_then(|child| child.downcast::().ok()) + { + label.set_xalign(0.0); + label.set_hexpand(true); + } + button.add_css_class("place"); + // Place actions are wired after the browser exists below. + button.set_tooltip_text(Some(&path.display().to_string())); + button.set_widget_name(&format!("place:{}", path.display())); + sidebar.append(&button); + } + body.append(&sidebar); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_hexpand(true); + content.set_vexpand(true); + let headings = gtk::Box::new(gtk::Orientation::Horizontal, 8); + headings.add_css_class("headings"); + headings.append(&heading("Name", true)); + headings.append(&heading("Size", false)); + headings.append(&heading("Modified", false)); + content.append(&headings); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::Single); + list.set_activate_on_single_click(false); + list.add_css_class("file-list"); + let scroll = gtk::ScrolledWindow::builder() + .child(&list) + .hexpand(true) + .vexpand(true) + .build(); + content.append(&scroll); + + let preview_heading = gtk::Label::new(Some("Preview")); + preview_heading.set_xalign(0.0); + preview_heading.add_css_class("preview-heading"); + let preview_title = gtk::Label::new(Some("Select a file")); + preview_title.set_xalign(0.0); + preview_title.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + preview_title.add_css_class("preview-title"); + let preview_details = gtk::Label::new(Some("Select a file to preview")); + preview_details.set_xalign(0.0); + preview_details.set_wrap(true); + preview_details.add_css_class("preview-details"); + + let preview_stack = gtk::Stack::new(); + preview_stack.set_hexpand(true); + preview_stack.set_vexpand(true); + preview_stack.set_visible_child_name("empty"); + let preview_empty = gtk::Label::new(Some("Select a file to preview")); + preview_empty.set_wrap(true); + preview_empty.add_css_class("preview-message"); + preview_stack.add_named(&preview_empty, Some("empty")); + + let preview_text = gtk::TextView::new(); + preview_text.set_editable(false); + preview_text.set_cursor_visible(false); + preview_text.set_monospace(true); + preview_text.set_wrap_mode(gtk::WrapMode::None); + preview_text.add_css_class("preview-text"); + let text_scroll = gtk::ScrolledWindow::builder() + .child(&preview_text) + .hexpand(true) + .vexpand(true) + .build(); + preview_stack.add_named(&text_scroll, Some("text")); + + let preview_picture = gtk::Picture::new(); + preview_picture.set_can_shrink(true); + preview_picture.set_keep_aspect_ratio(true); + preview_picture.set_halign(gtk::Align::Center); + preview_picture.set_valign(gtk::Align::Center); + preview_picture.set_hexpand(true); + preview_picture.set_vexpand(true); + preview_picture.add_css_class("preview-picture"); + preview_stack.add_named(&preview_picture, Some("image")); + + let preview_message = gtk::Label::new(None); + preview_message.set_wrap(true); + preview_message.set_justify(gtk::Justification::Center); + preview_message.add_css_class("preview-message"); + preview_stack.add_named(&preview_message, Some("message")); + + let preview = gtk::Box::new(gtk::Orientation::Vertical, 10); + preview.set_width_request(320); + preview.set_hexpand(false); + preview.set_vexpand(true); + preview.set_visible(false); + preview.add_css_class("preview-pane"); + preview.append(&preview_heading); + preview.append(&preview_title); + preview.append(&preview_details); + preview.append(&preview_stack); + + let workspace = gtk::Paned::new(gtk::Orientation::Horizontal); + workspace.set_start_child(Some(&content)); + workspace.set_end_child(Some(&preview)); + workspace.set_position(620); + workspace.set_hexpand(true); + workspace.set_vexpand(true); + workspace.set_resize_start_child(true); + workspace.set_resize_end_child(false); + workspace.set_shrink_start_child(false); + workspace.set_shrink_end_child(false); + workspace.add_css_class("workspace-split"); + body.append(&workspace); + root.append(&body); + + let status = gtk::Label::new(None); + status.set_xalign(0.0); + status.add_css_class("status"); + status.set_margin_start(12); + status.set_margin_end(12); + status.set_margin_top(6); + status.set_margin_bottom(6); + root.append(&status); + window.set_child(Some(&root)); + + let browser = Browser { + location: Rc::new(RefCell::new(home)), + show_hidden: Rc::new(RefCell::new(false)), + list: list.clone(), + location_entry: location_entry.clone(), + status: status.clone(), + filter: filter.clone(), + preview_title, + preview_details, + preview_panel: preview, + preview_stack, + preview_text, + preview_picture, + preview_message, + preview_request: Rc::new(Cell::new(0)), + preview_job: Rc::new(RefCell::new(None)), + preview_file: Rc::new(RefCell::new(None)), + }; + reload(&browser); + + { + let b = browser.clone(); + location_entry.connect_activate(move |entry| { + let path = PathBuf::from(entry.text().as_str()); + navigate(&b, path); + }); + } + { + let b = browser.clone(); + back.connect_clicked(move |_| { + let parent = b.location.borrow().parent().map(Path::to_path_buf); + if let Some(path) = parent { + navigate(&b, path); + } + }); + } + { + let b = browser.clone(); + refresh.connect_clicked(move |_| reload(&b)); + } + { + let b = browser.clone(); + filter.connect_search_changed(move |_| apply_filter(&b)); + } + { + let b = browser.clone(); + list.connect_row_selected(move |_, row| { + if let Some(path) = row + .and_then(|row| row.tooltip_text()) + .map(|path| PathBuf::from(path.as_str())) + { + show_preview(&b, path); + } else { + clear_preview(&b); + } + }); + } + { + let b = browser.clone(); + list.connect_row_activated(move |_, row| { + if let Some(path) = row.tooltip_text() { + navigate(&b, PathBuf::from(path.as_str())); + } + }); + } + // Sidebar uses the path stored on each button; this avoids hard-coded home assumptions. + let mut child = sidebar.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(button) = widget.clone().downcast::() { + let b = browser.clone(); + let path = button + .widget_name() + .strip_prefix("place:") + .map(PathBuf::from); + if let Some(path) = path { + button.connect_clicked(move |_| navigate(&b, path.clone())); + } + } + } + { + let b = browser.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, state| { + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l { + b.location_entry.grab_focus(); + b.location_entry.select_region(0, -1); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::F5 { + reload(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h { + let visible = !*b.show_hidden.borrow(); + *b.show_hidden.borrow_mut() = visible; + reload(&b); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::BackSpace { + if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) { + navigate(&b, parent); + } + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + window.add_controller(keys); + } + { + let b = browser.clone(); + window.connect_close_request(move |_| { + clear_preview(&b); + glib::Propagation::Proceed + }); + } + window.present(); +} + +fn navigate(browser: &Browser, path: PathBuf) { + if path.is_dir() { + *browser.location.borrow_mut() = path; + reload(browser); + } else if path.is_file() { + match open_file(&path) { + Ok(()) => browser.status.set_text(&format!( + "Opened {}", + path.file_name().unwrap_or_default().to_string_lossy() + )), + Err(error) => browser + .status + .set_text(&format!("Cannot open {}: {error}", path.display())), + } + } else { + browser + .status + .set_text("That path does not exist or cannot be opened."); + } +} + +fn clear_preview(browser: &Browser) { + cancel_preview_work(browser); + browser.preview_panel.set_visible(false); + browser.preview_title.set_text("Select a file"); + browser.preview_details.set_text("Select a file to preview"); + browser.preview_stack.set_visible_child_name("empty"); +} + +fn cancel_preview_work(browser: &Browser) -> u64 { + let request = browser.preview_request.get().wrapping_add(1); + browser.preview_request.set(request); + if let Some(mut job) = browser.preview_job.borrow_mut().take() { + let _ = job.child.kill(); + let _ = job.child.wait(); + let _ = fs::remove_file(job.output); + } + browser.preview_picture.set_filename(None::<&Path>); + if let Some(path) = browser.preview_file.borrow_mut().take() { + let _ = fs::remove_file(path); + } + request +} + +fn show_preview(browser: &Browser, path: PathBuf) { + if !supports_preview(&path) { + clear_preview(browser); + return; + } + let request = cancel_preview_work(browser); + browser.preview_panel.set_visible(true); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + browser.preview_title.set_text(&name); + + let details = fs::metadata(&path) + .map(|metadata| { + let modified = metadata + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "Unknown date".into()); + format!("{} · {modified}", format_size(metadata.len())) + }) + .unwrap_or_else(|_| path.display().to_string()); + browser.preview_details.set_text(&details); + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if is_preview_text(&extension) { + show_text_preview(browser, &path); + } else if is_preview_image(&extension) { + start_image_preview(browser, path, request, details); + } +} + +fn supports_preview(path: &Path) -> bool { + if path.is_dir() { + return false; + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + is_preview_text(&extension) || is_preview_image(&extension) +} + +fn set_preview_message(browser: &Browser, message: &str) { + browser.preview_message.set_text(message); + browser.preview_stack.set_visible_child_name("message"); +} + +fn is_preview_text(extension: &str) -> bool { + matches!( + extension, + "txt" + | "md" + | "markdown" + | "rs" + | "toml" + | "json" + | "yaml" + | "yml" + | "ini" + | "conf" + | "log" + | "sh" + | "bash" + | "zsh" + | "fish" + | "c" + | "h" + | "cc" + | "cpp" + | "hpp" + | "py" + | "html" + | "css" + | "js" + | "ts" + | "xml" + | "csv" + | "lock" + | "desktop" + | "service" + | "lua" + | "nix" + ) +} + +fn is_preview_image(extension: &str) -> bool { + matches!( + extension, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "avif" | "ico" + ) +} + +fn show_text_preview(browser: &Browser, path: &Path) { + const PREVIEW_LIMIT: usize = 64 * 1024; + let mut bytes = Vec::with_capacity(PREVIEW_LIMIT + 1); + let result = fs::File::open(path).and_then(|file| { + file.take((PREVIEW_LIMIT + 1) as u64) + .read_to_end(&mut bytes) + }); + if let Err(error) = result { + set_preview_message(browser, &format!("Cannot read this file: {error}")); + return; + } + if bytes.contains(&0) { + set_preview_message( + browser, + "This file contains binary data and cannot be previewed as text.", + ); + return; + } + let truncated = bytes.len() > PREVIEW_LIMIT; + bytes.truncate(PREVIEW_LIMIT); + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + text.push_str("\n\n[Preview limited to the first 64 KiB]"); + } + browser.preview_text.buffer().set_text(&text); + browser.preview_stack.set_visible_child_name("text"); +} + +fn start_image_preview(browser: &Browser, path: PathBuf, request: u64, details: String) { + let source = match fs::canonicalize(&path) { + Ok(source) => source, + Err(error) => { + set_preview_message(browser, &format!("Cannot open this image: {error}")); + return; + } + }; + let output_dir = std::env::temp_dir().join(format!("wayfinder-preview-{}", std::process::id())); + if let Err(error) = fs::create_dir_all(&output_dir) { + set_preview_message(browser, &format!("Cannot create preview cache: {error}")); + return; + } + if let Err(error) = fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o700)) { + set_preview_message(browser, &format!("Cannot secure preview cache: {error}")); + return; + } + let output = output_dir.join(format!("preview-{request}.png")); + let _ = fs::remove_file(&output); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let sandbox_input = format!("/wayfinder-input/preview.{extension}"); + let mut command = Command::new("bwrap"); + command + .args([ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/etc", + "/etc", + "--ro-bind-try", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--dir", + "/wayfinder-input", + "--ro-bind", + ]) + .arg(&source) + .arg(&sandbox_input) + .arg("--bind") + .arg(&output_dir) + .args([ + "/wayfinder-output", + "--chdir", + "/", + "--setenv", + "HOME", + "/tmp", + "--", + "/usr/bin/gdk-pixbuf-thumbnailer", + "--size", + "512", + ]) + .arg(&sandbox_input) + .arg(format!("/wayfinder-output/preview-{request}.png")); + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::null()); + + match command.spawn() { + Ok(child) => { + browser + .preview_details + .set_text("Generating image preview…"); + *browser.preview_job.borrow_mut() = Some(PreviewJob { + request, + child, + output: output.clone(), + started: Instant::now(), + }); + let browser = browser.clone(); + glib::timeout_add_local(Duration::from_millis(80), move || { + if browser.preview_request.get() != request { + return glib::ControlFlow::Break; + } + let completion = { + let mut pending = browser.preview_job.borrow_mut(); + let Some(job) = pending.as_mut() else { + return glib::ControlFlow::Break; + }; + if job.request != request { + return glib::ControlFlow::Break; + } + match job.child.try_wait() { + Ok(None) if job.started.elapsed() < Duration::from_secs(8) => None, + Ok(None) => { + let _ = job.child.kill(); + let _ = job.child.wait(); + Some(false) + } + Ok(Some(status)) => Some(status.success()), + Err(_) => Some(false), + } + .map(|success| { + let job = pending.take().expect("preview job exists"); + (success, job.output) + }) + }; + let Some((success, output)) = completion else { + return glib::ControlFlow::Continue; + }; + if success && output.is_file() { + browser.preview_picture.set_filename(Some(&output)); + browser.preview_details.set_text(&details); + *browser.preview_file.borrow_mut() = Some(output); + browser.preview_stack.set_visible_child_name("image"); + } else { + let _ = fs::remove_file(output); + set_preview_message( + &browser, + "Image preview failed. The format may be unsupported, or the sandboxed thumbnailer may be unavailable.", + ); + } + glib::ControlFlow::Break + }); + } + Err(error) => set_preview_message( + browser, + &format!("Could not start the sandboxed image preview: {error}"), + ), + } +} + +fn open_file(path: &Path) -> Result<(), String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let text_extensions = [ + "txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh", + "bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts", + "xml", "csv", "lock", "desktop", "service", "lua", "nix", + ]; + let image_extensions = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif", + ]; + + let mut command = if text_extensions.contains(&extension.as_str()) { + let mut command = Command::new("foot"); + command.args(["-e", "nvim", "--"]).arg(path); + command + } else if image_extensions.contains(&extension.as_str()) { + let mut command = Command::new("imv"); + command.arg(path); + command + } else if fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + { + let mut command = Command::new("foot"); + command.arg("-e").arg(path); + command + } else { + let uri = gio::File::for_path(path).uri(); + return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>) + .map_err(|error| error.to_string()); + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not start the requested application: {error}")) +} + +fn reload(browser: &Browser) { + clear_preview(browser); + while let Some(row) = browser.list.first_child() { + browser.list.remove(&row); + } + let path = browser.location.borrow().clone(); + browser.location_entry.set_text(&path.display().to_string()); + let entries = match fs::read_dir(&path) { + Ok(items) => items, + Err(error) => { + browser + .status + .set_text(&format!("Cannot read {}: {error}", path.display())); + return; + } + }; + let mut rows = Vec::new(); + for item in entries.flatten() { + let path = item.path(); + let name = item.file_name().to_string_lossy().into_owned(); + if !*browser.show_hidden.borrow() && name.starts_with('.') { + continue; + } + let Ok(meta) = item.metadata() else { continue }; + let directory = meta.is_dir(); + let modified = meta + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "—".into()); + rows.push(Entry { + path, + name, + directory, + size: meta.len(), + modified, + }); + } + rows.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in rows { + let row = make_row(&entry); + row.set_selectable(true); + row.set_activatable(true); + row.set_tooltip_text(Some(&entry.path.to_string_lossy())); + browser.list.append(&row); + } + let count = browser.list.observe_children().n_items(); + browser + .status + .set_text(&format!("{count} items · {}", path.display())); + apply_filter(browser); +} + +fn make_row(entry: &Entry) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let line = gtk::Box::new(gtk::Orientation::Horizontal, 8); + line.set_margin_start(8); + line.set_margin_end(8); + line.set_margin_top(5); + line.set_margin_bottom(5); + let name = gtk::Label::new(None); + let display_name: String = entry + .name + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect(); + name.set_text(&if entry.directory { + format!("{display_name}/") + } else { + display_name + }); + name.set_xalign(0.0); + name.set_wrap(true); + name.set_wrap_mode(gtk::pango::WrapMode::WordChar); + name.set_valign(gtk::Align::Center); + name.set_hexpand(true); + name.add_css_class(if entry.directory { + "directory" + } else { + "filename" + }); + let size_text = if entry.directory { + "Folder".to_string() + } else { + format_size(entry.size) + }; + let size = metadata_value(&size_text, 72); + let modified = metadata_value(&entry.modified, 126); + line.append(&name); + line.append(&size); + line.append(&modified); + row.set_child(Some(&line)); + row +} + +fn heading(text: &str, expand: bool) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + if expand { + label.set_hexpand(true); + } else { + label.set_size_request(if text == "Size" { 72 } else { 126 }, -1); + } + label.add_css_class("column-heading"); + label +} + +fn metadata_value(text: &str, width: i32) -> gtk::Label { + let value = gtk::Label::new(Some(text)); + value.set_size_request(width, 24); + value.set_xalign(0.0); + value.set_valign(gtk::Align::Center); + value.set_ellipsize(gtk::pango::EllipsizeMode::End); + value.add_css_class("metadata"); + value +} + +fn tool_button(label: &str, tip: &str) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.set_tooltip_text(Some(tip)); + button.set_size_request(34, 34); + button.add_css_class("tool-button"); + button +} + +fn apply_filter(browser: &Browser) { + let needle = browser.filter.text().to_lowercase(); + let mut child = browser.list.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(row) = widget.clone().downcast::() { + let name = row + .child() + .and_then(|line| line.first_child()) + .and_then(|label| label.downcast::().ok()) + .map(|label| label.text().to_string().to_lowercase()) + .unwrap_or_default(); + row.set_visible(name.contains(&needle)); + } + } +} + +fn format_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_time(time: std::time::SystemTime) -> String { + // Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable. + match time.elapsed() { + Ok(age) if age.as_secs() < 60 => "Just now".into(), + Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60), + Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600), + Ok(age) => format!("{} days ago", age.as_secs() / 86400), + Err(_) => "Just now".into(), + } +} + +fn install_style() { + let provider = gtk::CssProvider::new(); + provider.load_from_data(r#" + * { font-family: "Maple Mono", "JetBrainsMono Nerd Font", monospace; font-size: 10pt; } + window, .root { background: #151515; color: #D0D0D0; } + .toolbar { background: #181818; padding: 8px 10px; } + entry, searchentry { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 7px 9px; min-height: 18px; } + entry:focus, searchentry:focus { border-color: #6C99BB; } + .tool-button { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 0; min-width: 34px; min-height: 34px; } + .back-button { min-width: 34px; min-height: 34px; } + .tool-button:hover { color: #F5F5F5; background: #303030; border-color: #454545; } + .tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: 1px solid #6C99BB; outline-offset: -2px; } + .sidebar { background: #181818; border-top: 2px solid #2F2F2F; border-right: 1px solid #2F2F2F; padding: 8px 6px; min-width: 168px; max-width: 168px; } + .place { color: #D0D0D0; background: transparent; border: 1px solid transparent; border-radius: 2px; padding: 7px 9px; min-height: 20px; } + .place:hover { color: #F5F5F5; background: #202020; border-color: #2F2F2F; } + .place:active { background: #303030; } + .headings { background: #181818; border-top: 2px solid #2F2F2F; border-bottom: 1px solid #2F2F2F; padding: 9px 8px; } + .preview-pane { background: #181818; border-left: 1px solid #2F2F2F; padding: 12px; min-width: 320px; max-width: 320px; } + .preview-heading { color: #F5F5F5; font-size: 11pt; } + .preview-title { color: #D0D0D0; font-size: 10pt; } + .preview-details { color: #757575; font-size: 9pt; } + .preview-message { color: #858585; padding: 12px; } + .preview-text { background: #151515; color: #D0D0D0; padding: 10px; } + .preview-picture { background: #151515; } + .workspace-split > separator { background: #2F2F2F; min-width: 1px; } + .column-heading { color: #757575; font-size: 9pt; } + .file-list { background: #151515; } + .file-list row { border-bottom: 1px solid #202020; min-height: 36px; } + .file-list row:hover { background: #202020; } + .file-list row:selected { background: #303030; outline: 1px solid #6C99BB; outline-offset: -1px; } + .directory { color: #8DB3D0; } + .filename { color: #D0D0D0; } + .metadata, .status { color: #858585; font-size: 10pt; } + .metadata { padding: 3px 0 5px; } + scrolledwindow { border: 0; } + scrollbar slider { background: #454545; min-width: 7px; min-height: 24px; border-radius: 2px; } + "#); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/src/main.rs.backup-20261018T135128Z b/src/main.rs.backup-20261018T135128Z new file mode 100644 index 0000000..d2cd8fa --- /dev/null +++ b/src/main.rs.backup-20261018T135128Z @@ -0,0 +1,1102 @@ +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use std::cell::Cell; +use std::cell::RefCell; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Entry { + path: PathBuf, + name: String, + directory: bool, + size: u64, + modified: String, +} + +#[derive(Clone)] +struct AppConfig { + sidebar_width: i32, + places: Vec<(String, PathBuf)>, + colors: Palette, +} + +#[derive(Clone)] +struct Palette { + background: String, + panel: String, + text: String, + muted: String, + accent: String, + border: String, +} + +impl Default for Palette { + fn default() -> Self { + Self { + background: "#151515".into(), + panel: "#181818".into(), + text: "#D0D0D0".into(), + muted: "#858585".into(), + accent: "#6C99BB".into(), + border: "#2F2F2F".into(), + } + } +} + +fn load_config(home: &Path) -> AppConfig { + #[derive(Clone, Copy, PartialEq)] + enum Section { + Root, + Places, + Colors, + } + + let mut config = AppConfig { + sidebar_width: 136, + places: ["Desktop", "Documents", "Downloads", "Pictures"] + .into_iter() + .map(|name| (name.to_string(), home.join(name))) + .collect(), + colors: Palette::default(), + }; + let file = home.join(".config/wayfinder/config.yml"); + let Ok(contents) = fs::read_to_string(&file) else { + return config; + }; + + let mut section = Section::Root; + let mut places = Vec::<(String, PathBuf)>::new(); + let mut pending_place: Option<(String, Option)> = None; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line == "---" { + continue; + } + if !raw_line.chars().next().is_some_and(char::is_whitespace) { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let key = key.trim(); + let value = unquote(value.trim()); + match key { + "places" => section = Section::Places, + "colors" => section = Section::Colors, + "sidebar_width" => { + if let Ok(width) = value.parse::() { + config.sidebar_width = width.clamp(96, 240); + } + section = Section::Root; + } + _ => section = Section::Root, + } + continue; + } + + match section { + Section::Places => { + let item = line.strip_prefix('-').map(str::trim); + if let Some(item) = item { + if let Some((name, Some(path))) = pending_place.take() { + places.push((name, path)); + } + pending_place = Some((String::new(), None)); + if !item.is_empty() { + set_place_field(&mut pending_place, item, home); + } + } else if pending_place.is_some() { + set_place_field(&mut pending_place, line, home); + } + } + Section::Colors => { + if let Some((key, value)) = line.split_once(':') { + let value = unquote(value.trim()); + if !valid_color(&value) { + continue; + } + match key.trim() { + "background" => config.colors.background = value, + "panel" => config.colors.panel = value, + "text" => config.colors.text = value, + "muted" => config.colors.muted = value, + "accent" => config.colors.accent = value, + "border" => config.colors.border = value, + _ => {} + } + } + } + Section::Root => {} + } + } + if let Some((name, Some(path))) = pending_place { + places.push((name, path)); + } + if !places.is_empty() { + config.places = places; + } + config +} + +fn set_place_field(place: &mut Option<(String, Option)>, line: &str, home: &Path) { + let Some((key, value)) = line.split_once(':') else { + return; + }; + let value = unquote(value.trim()); + let Some((name, path)) = place.as_mut() else { + return; + }; + match key.trim() { + "name" => *name = value, + "path" => { + let expanded = if value == "~" { + home.to_path_buf() + } else if let Some(rest) = value.strip_prefix("~/") { + home.join(rest) + } else { + PathBuf::from(value) + }; + *path = Some(expanded); + } + _ => {} + } +} + +fn unquote(value: &str) -> String { + value.trim_matches(|ch| ch == '\'' || ch == '"').to_string() +} + +fn valid_color(value: &str) -> bool { + matches!(value.len(), 4 | 7 | 9) + && value.starts_with('#') + && value[1..].chars().all(|ch| ch.is_ascii_hexdigit()) +} + +#[derive(Clone)] +struct Browser { + location: Rc>, + show_hidden: Rc>, + list: gtk::ListBox, + location_entry: gtk::Entry, + status: gtk::Label, + filter: gtk::SearchEntry, + preview_title: gtk::Label, + preview_details: gtk::Label, + preview_panel: gtk::Box, + preview_stack: gtk::Stack, + preview_text: gtk::TextView, + preview_picture: gtk::Picture, + preview_message: gtk::Label, + preview_request: Rc>, + preview_job: Rc>>, + preview_file: Rc>>, +} + +struct PreviewJob { + request: u64, + child: Child, + output: PathBuf, + started: Instant, +} + +fn main() { + let app = gtk::Application::builder() + .application_id("dev.wfalkowski.Wayfinder") + .flags(gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.connect_activate(build_ui); + app.run(); +} + +fn build_ui(app: >k::Application) { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let config = load_config(&home); + install_style(&config); + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Wayfinder") + .default_width(1080) + .default_height(700) + .build(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + root.add_css_class("root"); + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); + toolbar.add_css_class("toolbar"); + + let back = tool_button("", "Go to parent folder"); + let back_icon = gtk::Image::from_icon_name("go-previous"); + back_icon.set_pixel_size(16); + back_icon.set_halign(gtk::Align::Center); + back_icon.set_valign(gtk::Align::Center); + back.set_child(Some(&back_icon)); + back.add_css_class("back-button"); + let refresh = tool_button("↻", "Reload this folder"); + let location_entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text("Enter a folder path") + .build(); + location_entry.add_css_class("location"); + let filter = gtk::SearchEntry::builder() + .placeholder_text("Filter") + .width_chars(18) + .build(); + toolbar.append(&back); + toolbar.append(&refresh); + toolbar.append(&location_entry); + toolbar.append(&filter); + root.append(&toolbar); + + let body = gtk::Box::new(gtk::Orientation::Horizontal, 0); + body.set_vexpand(true); + let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 2); + sidebar.set_width_request(config.sidebar_width); + sidebar.set_hexpand(false); + sidebar.add_css_class("sidebar"); + for (name, path) in &config.places { + let button = gtk::Button::with_label(name); + button.set_halign(gtk::Align::Fill); + if let Some(label) = button + .child() + .and_then(|child| child.downcast::().ok()) + { + label.set_xalign(0.0); + label.set_hexpand(true); + } + button.add_css_class("place"); + // Place actions are wired after the browser exists below. + button.set_tooltip_text(Some(&path.display().to_string())); + button.set_widget_name(&format!("place:{}", path.display())); + sidebar.append(&button); + } + body.append(&sidebar); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_hexpand(true); + content.set_vexpand(true); + let headings = gtk::Box::new(gtk::Orientation::Horizontal, 8); + headings.add_css_class("headings"); + headings.append(&heading("Name", true)); + headings.append(&heading("Size", false)); + headings.append(&heading("Modified", false)); + content.append(&headings); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::Single); + list.set_activate_on_single_click(false); + list.add_css_class("file-list"); + let scroll = gtk::ScrolledWindow::builder() + .child(&list) + .hexpand(true) + .vexpand(true) + .build(); + content.append(&scroll); + + let preview_heading = gtk::Label::new(Some("Preview")); + preview_heading.set_xalign(0.0); + preview_heading.add_css_class("preview-heading"); + let preview_title = gtk::Label::new(Some("Select a file")); + preview_title.set_xalign(0.0); + preview_title.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + preview_title.add_css_class("preview-title"); + let preview_details = gtk::Label::new(Some("Select a file to preview")); + preview_details.set_xalign(0.0); + preview_details.set_wrap(true); + preview_details.add_css_class("preview-details"); + + let preview_stack = gtk::Stack::new(); + preview_stack.set_hexpand(true); + preview_stack.set_vexpand(true); + preview_stack.set_visible_child_name("empty"); + let preview_empty = gtk::Label::new(Some("Select a file to preview")); + preview_empty.set_wrap(true); + preview_empty.add_css_class("preview-message"); + preview_stack.add_named(&preview_empty, Some("empty")); + + let preview_text = gtk::TextView::new(); + preview_text.set_editable(false); + preview_text.set_cursor_visible(false); + preview_text.set_monospace(true); + preview_text.set_wrap_mode(gtk::WrapMode::None); + preview_text.add_css_class("preview-text"); + let text_scroll = gtk::ScrolledWindow::builder() + .child(&preview_text) + .hexpand(true) + .vexpand(true) + .build(); + preview_stack.add_named(&text_scroll, Some("text")); + + let preview_picture = gtk::Picture::new(); + preview_picture.set_can_shrink(true); + preview_picture.set_keep_aspect_ratio(true); + preview_picture.set_halign(gtk::Align::Center); + preview_picture.set_valign(gtk::Align::Center); + preview_picture.set_hexpand(true); + preview_picture.set_vexpand(true); + preview_picture.add_css_class("preview-picture"); + preview_stack.add_named(&preview_picture, Some("image")); + + let preview_message = gtk::Label::new(None); + preview_message.set_wrap(true); + preview_message.set_justify(gtk::Justification::Center); + preview_message.add_css_class("preview-message"); + preview_stack.add_named(&preview_message, Some("message")); + + let preview = gtk::Box::new(gtk::Orientation::Vertical, 10); + preview.set_width_request(320); + preview.set_hexpand(false); + preview.set_vexpand(true); + preview.set_visible(false); + preview.add_css_class("preview-pane"); + preview.append(&preview_heading); + preview.append(&preview_title); + preview.append(&preview_details); + preview.append(&preview_stack); + + let workspace = gtk::Paned::new(gtk::Orientation::Horizontal); + workspace.set_start_child(Some(&content)); + workspace.set_end_child(Some(&preview)); + workspace.set_position(620); + workspace.set_hexpand(true); + workspace.set_vexpand(true); + workspace.set_resize_start_child(true); + workspace.set_resize_end_child(false); + workspace.set_shrink_start_child(false); + workspace.set_shrink_end_child(false); + workspace.add_css_class("workspace-split"); + body.append(&workspace); + root.append(&body); + + let status = gtk::Label::new(None); + status.set_xalign(0.0); + status.add_css_class("status"); + status.set_margin_start(12); + status.set_margin_end(12); + status.set_margin_top(6); + status.set_margin_bottom(6); + root.append(&status); + window.set_child(Some(&root)); + + let browser = Browser { + location: Rc::new(RefCell::new(home)), + show_hidden: Rc::new(RefCell::new(false)), + list: list.clone(), + location_entry: location_entry.clone(), + status: status.clone(), + filter: filter.clone(), + preview_title, + preview_details, + preview_panel: preview, + preview_stack, + preview_text, + preview_picture, + preview_message, + preview_request: Rc::new(Cell::new(0)), + preview_job: Rc::new(RefCell::new(None)), + preview_file: Rc::new(RefCell::new(None)), + }; + reload(&browser); + + { + let b = browser.clone(); + location_entry.connect_activate(move |entry| { + let path = PathBuf::from(entry.text().as_str()); + navigate(&b, path); + }); + } + { + let b = browser.clone(); + back.connect_clicked(move |_| { + let parent = b.location.borrow().parent().map(Path::to_path_buf); + if let Some(path) = parent { + navigate(&b, path); + } + }); + } + { + let b = browser.clone(); + refresh.connect_clicked(move |_| reload(&b)); + } + { + let b = browser.clone(); + filter.connect_search_changed(move |_| apply_filter(&b)); + } + { + let b = browser.clone(); + list.connect_row_selected(move |_, row| { + if let Some(path) = row + .and_then(|row| row.tooltip_text()) + .map(|path| PathBuf::from(path.as_str())) + { + show_preview(&b, path); + } else { + clear_preview(&b); + } + }); + } + { + let b = browser.clone(); + list.connect_row_activated(move |_, row| { + if let Some(path) = row.tooltip_text() { + navigate(&b, PathBuf::from(path.as_str())); + } + }); + } + // Sidebar uses the path stored on each button; this avoids hard-coded home assumptions. + let mut child = sidebar.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(button) = widget.clone().downcast::() { + let b = browser.clone(); + let path = button + .widget_name() + .strip_prefix("place:") + .map(PathBuf::from); + if let Some(path) = path { + button.connect_clicked(move |_| navigate(&b, path.clone())); + } + } + } + { + let b = browser.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, state| { + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l { + b.location_entry.grab_focus(); + b.location_entry.select_region(0, -1); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::F5 { + reload(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h { + let visible = !*b.show_hidden.borrow(); + *b.show_hidden.borrow_mut() = visible; + reload(&b); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::BackSpace { + if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) { + navigate(&b, parent); + } + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + window.add_controller(keys); + } + { + let b = browser.clone(); + window.connect_close_request(move |_| { + clear_preview(&b); + glib::Propagation::Proceed + }); + } + window.present(); +} + +fn navigate(browser: &Browser, path: PathBuf) { + if path.is_dir() { + *browser.location.borrow_mut() = path; + reload(browser); + } else if path.is_file() { + match open_file(&path) { + Ok(()) => browser.status.set_text(&format!( + "Opened {}", + path.file_name().unwrap_or_default().to_string_lossy() + )), + Err(error) => browser + .status + .set_text(&format!("Cannot open {}: {error}", path.display())), + } + } else { + browser + .status + .set_text("That path does not exist or cannot be opened."); + } +} + +fn clear_preview(browser: &Browser) { + cancel_preview_work(browser); + browser.preview_panel.set_visible(false); + browser.preview_title.set_text("Select a file"); + browser.preview_details.set_text("Select a file to preview"); + browser.preview_stack.set_visible_child_name("empty"); +} + +fn cancel_preview_work(browser: &Browser) -> u64 { + let request = browser.preview_request.get().wrapping_add(1); + browser.preview_request.set(request); + if let Some(mut job) = browser.preview_job.borrow_mut().take() { + let _ = job.child.kill(); + let _ = job.child.wait(); + let _ = fs::remove_file(job.output); + } + browser.preview_picture.set_filename(None::<&Path>); + if let Some(path) = browser.preview_file.borrow_mut().take() { + let _ = fs::remove_file(path); + } + request +} + +fn show_preview(browser: &Browser, path: PathBuf) { + if !supports_preview(&path) { + clear_preview(browser); + return; + } + let request = cancel_preview_work(browser); + browser.preview_panel.set_visible(true); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + browser.preview_title.set_text(&name); + + let details = fs::metadata(&path) + .map(|metadata| { + let modified = metadata + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "Unknown date".into()); + format!("{} · {modified}", format_size(metadata.len())) + }) + .unwrap_or_else(|_| path.display().to_string()); + browser.preview_details.set_text(&details); + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if is_preview_text(&extension) { + show_text_preview(browser, &path); + } else if is_preview_image(&extension) { + start_image_preview(browser, path, request, details); + } +} + +fn supports_preview(path: &Path) -> bool { + if path.is_dir() { + return false; + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + is_preview_text(&extension) || is_preview_image(&extension) +} + +fn set_preview_message(browser: &Browser, message: &str) { + browser.preview_message.set_text(message); + browser.preview_stack.set_visible_child_name("message"); +} + +fn is_preview_text(extension: &str) -> bool { + matches!( + extension, + "txt" + | "md" + | "markdown" + | "rs" + | "toml" + | "json" + | "yaml" + | "yml" + | "ini" + | "conf" + | "log" + | "sh" + | "bash" + | "zsh" + | "fish" + | "c" + | "h" + | "cc" + | "cpp" + | "hpp" + | "py" + | "html" + | "css" + | "js" + | "ts" + | "xml" + | "csv" + | "lock" + | "desktop" + | "service" + | "lua" + | "nix" + ) +} + +fn is_preview_image(extension: &str) -> bool { + matches!( + extension, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "avif" | "ico" + ) +} + +fn show_text_preview(browser: &Browser, path: &Path) { + const PREVIEW_LIMIT: usize = 64 * 1024; + let mut bytes = Vec::with_capacity(PREVIEW_LIMIT + 1); + let result = fs::File::open(path).and_then(|file| { + file.take((PREVIEW_LIMIT + 1) as u64) + .read_to_end(&mut bytes) + }); + if let Err(error) = result { + set_preview_message(browser, &format!("Cannot read this file: {error}")); + return; + } + if bytes.contains(&0) { + set_preview_message( + browser, + "This file contains binary data and cannot be previewed as text.", + ); + return; + } + let truncated = bytes.len() > PREVIEW_LIMIT; + bytes.truncate(PREVIEW_LIMIT); + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + text.push_str("\n\n[Preview limited to the first 64 KiB]"); + } + browser.preview_text.buffer().set_text(&text); + browser.preview_stack.set_visible_child_name("text"); +} + +fn start_image_preview(browser: &Browser, path: PathBuf, request: u64, details: String) { + let source = match fs::canonicalize(&path) { + Ok(source) => source, + Err(error) => { + set_preview_message(browser, &format!("Cannot open this image: {error}")); + return; + } + }; + let output_dir = std::env::temp_dir().join(format!("wayfinder-preview-{}", std::process::id())); + if let Err(error) = fs::create_dir_all(&output_dir) { + set_preview_message(browser, &format!("Cannot create preview cache: {error}")); + return; + } + if let Err(error) = fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o700)) { + set_preview_message(browser, &format!("Cannot secure preview cache: {error}")); + return; + } + let output = output_dir.join(format!("preview-{request}.png")); + let _ = fs::remove_file(&output); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let is_jpeg = matches!(extension.to_ascii_lowercase().as_str(), "jpg" | "jpeg"); + let sandbox_input = format!("/wayfinder-input/preview.{extension}"); + let mut command = Command::new("bwrap"); + command + .args([ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/etc", + "/etc", + "--ro-bind-try", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--dir", + "/wayfinder-input", + "--ro-bind", + ]) + .arg(&source) + .arg(&sandbox_input) + .arg("--bind") + .arg(&output_dir) + .args([ + "/wayfinder-output", + "--chdir", + "/", + "--setenv", + "HOME", + "/tmp", + "--", + ]); + let sandbox_output = format!("/wayfinder-output/preview-{request}.png"); + if is_jpeg { + command + .args([ + "/usr/bin/python3", + "-c", + "from PIL import Image; import sys; Image.MAX_IMAGE_PIXELS = 100000000; im = Image.open(sys.argv[1]); im.thumbnail((512, 512), Image.Resampling.LANCZOS); im.convert('RGBA' if 'A' in im.getbands() else 'RGB').save(sys.argv[2], format='PNG')", + ]) + .arg(&sandbox_input) + .arg(&sandbox_output); + } else { + command + .args(["/usr/bin/gdk-pixbuf-thumbnailer", "--size", "512"]) + .arg(&sandbox_input) + .arg(&sandbox_output); + } + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::null()); + + match command.spawn() { + Ok(child) => { + browser + .preview_details + .set_text("Generating image preview…"); + *browser.preview_job.borrow_mut() = Some(PreviewJob { + request, + child, + output: output.clone(), + started: Instant::now(), + }); + let browser = browser.clone(); + glib::timeout_add_local(Duration::from_millis(80), move || { + if browser.preview_request.get() != request { + return glib::ControlFlow::Break; + } + let completion = { + let mut pending = browser.preview_job.borrow_mut(); + let Some(job) = pending.as_mut() else { + return glib::ControlFlow::Break; + }; + if job.request != request { + return glib::ControlFlow::Break; + } + match job.child.try_wait() { + Ok(None) if job.started.elapsed() < Duration::from_secs(8) => None, + Ok(None) => { + let _ = job.child.kill(); + let _ = job.child.wait(); + Some(false) + } + Ok(Some(status)) => Some(status.success()), + Err(_) => Some(false), + } + .map(|success| { + let job = pending.take().expect("preview job exists"); + (success, job.output) + }) + }; + let Some((success, output)) = completion else { + return glib::ControlFlow::Continue; + }; + if success && output.is_file() { + browser.preview_picture.set_filename(Some(&output)); + browser.preview_details.set_text(&details); + *browser.preview_file.borrow_mut() = Some(output); + browser.preview_stack.set_visible_child_name("image"); + } else { + let _ = fs::remove_file(output); + set_preview_message( + &browser, + "Image preview failed. The format may be unsupported, or the sandboxed thumbnailer may be unavailable.", + ); + } + glib::ControlFlow::Break + }); + } + Err(error) => set_preview_message( + browser, + &format!("Could not start the sandboxed image preview: {error}"), + ), + } +} + +fn open_file(path: &Path) -> Result<(), String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let text_extensions = [ + "txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh", + "bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts", + "xml", "csv", "lock", "desktop", "service", "lua", "nix", + ]; + let image_extensions = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif", + ]; + + let mut command = if text_extensions.contains(&extension.as_str()) { + let mut command = Command::new("foot"); + command.args(["-e", "nvim", "--"]).arg(path); + command + } else if image_extensions.contains(&extension.as_str()) { + let mut command = Command::new("imv"); + command.arg(path); + command + } else if fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + { + let mut command = Command::new("foot"); + command.arg("-e").arg(path); + command + } else { + let uri = gio::File::for_path(path).uri(); + return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>) + .map_err(|error| error.to_string()); + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not start the requested application: {error}")) +} + +fn reload(browser: &Browser) { + clear_preview(browser); + while let Some(row) = browser.list.first_child() { + browser.list.remove(&row); + } + let path = browser.location.borrow().clone(); + browser.location_entry.set_text(&path.display().to_string()); + let entries = match fs::read_dir(&path) { + Ok(items) => items, + Err(error) => { + browser + .status + .set_text(&format!("Cannot read {}: {error}", path.display())); + return; + } + }; + let mut rows = Vec::new(); + for item in entries.flatten() { + let path = item.path(); + let name = item.file_name().to_string_lossy().into_owned(); + if !*browser.show_hidden.borrow() && name.starts_with('.') { + continue; + } + let Ok(meta) = item.metadata() else { continue }; + let directory = meta.is_dir(); + let modified = meta + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "—".into()); + rows.push(Entry { + path, + name, + directory, + size: meta.len(), + modified, + }); + } + rows.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in rows { + let row = make_row(&entry); + row.set_selectable(true); + row.set_activatable(true); + row.set_tooltip_text(Some(&entry.path.to_string_lossy())); + browser.list.append(&row); + } + let count = browser.list.observe_children().n_items(); + browser + .status + .set_text(&format!("{count} items · {}", path.display())); + apply_filter(browser); +} + +fn make_row(entry: &Entry) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let line = gtk::Box::new(gtk::Orientation::Horizontal, 8); + line.set_margin_start(8); + line.set_margin_end(8); + line.set_margin_top(5); + line.set_margin_bottom(5); + let name = gtk::Label::new(None); + let display_name: String = entry + .name + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect(); + name.set_text(&if entry.directory { + format!("{display_name}/") + } else { + display_name + }); + name.set_xalign(0.0); + name.set_wrap(true); + name.set_wrap_mode(gtk::pango::WrapMode::WordChar); + name.set_valign(gtk::Align::Center); + name.set_hexpand(true); + name.add_css_class(if entry.directory { + "directory" + } else { + "filename" + }); + let size_text = if entry.directory { + "Folder".to_string() + } else { + format_size(entry.size) + }; + let size = metadata_value(&size_text, 72); + let modified = metadata_value(&entry.modified, 126); + line.append(&name); + line.append(&size); + line.append(&modified); + row.set_child(Some(&line)); + row +} + +fn heading(text: &str, expand: bool) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + if expand { + label.set_hexpand(true); + } else { + label.set_size_request(if text == "Size" { 72 } else { 126 }, -1); + } + label.add_css_class("column-heading"); + label +} + +fn metadata_value(text: &str, width: i32) -> gtk::Label { + let value = gtk::Label::new(Some(text)); + value.set_size_request(width, 24); + value.set_xalign(0.0); + value.set_valign(gtk::Align::Center); + value.set_ellipsize(gtk::pango::EllipsizeMode::End); + value.add_css_class("metadata"); + value +} + +fn tool_button(label: &str, tip: &str) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.set_tooltip_text(Some(tip)); + button.set_size_request(34, 34); + button.add_css_class("tool-button"); + button +} + +fn apply_filter(browser: &Browser) { + let needle = browser.filter.text().to_lowercase(); + let mut child = browser.list.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(row) = widget.clone().downcast::() { + let name = row + .child() + .and_then(|line| line.first_child()) + .and_then(|label| label.downcast::().ok()) + .map(|label| label.text().to_string().to_lowercase()) + .unwrap_or_default(); + row.set_visible(name.contains(&needle)); + } + } +} + +fn format_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_time(time: std::time::SystemTime) -> String { + // Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable. + match time.elapsed() { + Ok(age) if age.as_secs() < 60 => "Just now".into(), + Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60), + Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600), + Ok(age) => format!("{} days ago", age.as_secs() / 86400), + Err(_) => "Just now".into(), + } +} + +fn install_style(config: &AppConfig) { + let provider = gtk::CssProvider::new(); + let css = r#" + * { font-family: "Maple Mono", "JetBrainsMono Nerd Font", monospace; font-size: 10pt; } + window, .root { background: @background@; color: @text@; } + .toolbar { background: @panel@; padding: 8px 10px; } + entry, searchentry { color: @text@; background: #202020; border: 1px solid @border@; border-radius: 2px; padding: 7px 9px; min-height: 18px; } + entry:focus, searchentry:focus { border-color: @accent@; } + .tool-button { color: @text@; background: #202020; border: 1px solid @border@; border-radius: 2px; padding: 0; min-width: 34px; min-height: 34px; } + .back-button { min-width: 34px; min-height: 34px; } + .tool-button:hover { color: @text@; background: #303030; border-color: @accent@; } + .tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: 1px solid #6C99BB; outline-offset: -2px; } + .sidebar { background: @panel@; border-top: 2px solid @border@; border-right: 1px solid @border@; padding: 8px 6px; min-width: @sidebar_width@px; max-width: @sidebar_width@px; } + .place { color: @text@; background: transparent; border: 1px solid transparent; border-radius: 2px; padding: 7px 9px; min-height: 20px; } + .place:hover { color: @text@; background: #202020; border-color: @border@; } + .place:active { background: #303030; } + .headings { background: @panel@; border-top: 2px solid @border@; border-bottom: 1px solid @border@; padding: 9px 8px; } + .preview-pane { background: @panel@; border-left: 1px solid @border@; padding: 12px; min-width: 320px; max-width: 320px; } + .preview-heading { color: @text@; font-size: 11pt; } + .preview-title { color: @text@; font-size: 10pt; padding: 2px 0; } + .preview-details { color: @muted@; font-size: 9pt; padding: 2px 0; } + .preview-message { color: @muted@; padding: 12px; } + .preview-text { background: @background@; color: @text@; padding: 10px; } + .preview-picture { background: @background@; } + .workspace-split > separator { background: @border@; min-width: 1px; } + .column-heading { color: @muted@; font-size: 9pt; } + .file-list { background: @background@; } + .file-list row { border-bottom: 1px solid @border@; min-height: 36px; } + .file-list row:hover { background: #202020; } + .file-list row:selected { background: #303030; outline: 1px solid @accent@; outline-offset: -1px; } + .directory { color: @accent@; } + .filename { color: @text@; } + .metadata, .status { color: @muted@; font-size: 10pt; } + .metadata { padding: 3px 0 5px; } + scrolledwindow { border: 0; } + scrollbar slider { background: #454545; min-width: 7px; min-height: 24px; border-radius: 2px; } + "#; + let css = css + .replace("@background@", &config.colors.background) + .replace("@panel@", &config.colors.panel) + .replace("@text@", &config.colors.text) + .replace("@muted@", &config.colors.muted) + .replace("@accent@", &config.colors.accent) + .replace("@border@", &config.colors.border) + .replace("@sidebar_width@", &config.sidebar_width.to_string()); + provider.load_from_data(&css); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/src/main.rs.backup-code-preview-20261018T140500Z b/src/main.rs.backup-code-preview-20261018T140500Z new file mode 100644 index 0000000..2d147bb --- /dev/null +++ b/src/main.rs.backup-code-preview-20261018T140500Z @@ -0,0 +1,1480 @@ +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use std::cell::Cell; +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Entry { + path: PathBuf, + name: String, + directory: bool, + size: u64, + modified: String, +} + +#[derive(Clone)] +struct AppConfig { + sidebar_width: i32, + preview_width: i32, + window_width: i32, + window_height: i32, + text_preview_limit_kib: usize, + image_preview_size: i32, + places: Vec<(String, PathBuf)>, + colors: Palette, + style_values: HashMap, + settings: HashMap, +} + +#[derive(Clone)] +struct Palette(HashMap); + +impl Default for Palette { + fn default() -> Self { + let values = [ + ("background", "#151515"), + ("panel", "#181818"), + ("surface", "#202020"), + ("hover", "#282828"), + ("selected", "#303030"), + ("border", "#2F2F2F"), + ("focus_border", "#454545"), + ("text", "#D0D0D0"), + ("muted", "#858585"), + ("dim", "#505050"), + ("emphasis", "#F5F5F5"), + ("accent", "#6C99BB"), + ("accent_secondary", "#547C99"), + ("directory", "#6C99BB"), + ("success", "#7B9246"), + ("warning", "#D3A04D"), + ("danger", "#A53C23"), + ("teal", "#5EA8A2"), + ]; + Self( + values + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(), + ) + } +} + +#[derive(Clone)] +struct IniEntry { + section: String, + key: String, + value: String, +} + +fn default_style_values() -> HashMap { + [ + ( + "font_family", + "Maple Mono, \"JetBrainsMono Nerd Font\", monospace", + ), + ("font_size", "10pt"), + ("toolbar_pad_x", "10px"), + ("toolbar_pad_y", "8px"), + ("toolbar_gap", "6px"), + ("control_pad_x", "9px"), + ("control_pad_y", "7px"), + ("control_min_height", "18px"), + ("button_size", "34px"), + ("icon_size", "16px"), + ("corner_radius", "2px"), + ("sidebar_pad_x", "6px"), + ("sidebar_pad_y", "8px"), + ("place_pad_x", "9px"), + ("place_pad_y", "7px"), + ("place_min_height", "20px"), + ("heading_pad_x", "8px"), + ("heading_pad_y", "9px"), + ("preview_pad", "12px"), + ("preview_spacing", "10px"), + ("preview_heading_size", "11pt"), + ("preview_title_size", "10pt"), + ("preview_details_size", "9pt"), + ("preview_title_pad", "2px"), + ("preview_message_pad", "12px"), + ("preview_text_pad", "10px"), + ("top_border_width", "2px"), + ("border_width", "1px"), + ("row_height", "36px"), + ("row_padding_x", "8px"), + ("row_padding_y", "5px"), + ("size_column_width", "72px"), + ("modified_column_width", "126px"), + ("metadata_value_height", "24px"), + ("place_gap", "2px"), + ("status_margin_x", "12px"), + ("status_margin_y", "6px"), + ("metadata_font_size", "10pt"), + ("metadata_pad_top", "3px"), + ("metadata_pad_bottom", "5px"), + ("scrollbar_width", "6px"), + ("scrollbar_thumb_width", "4px"), + ("scrollbar_thumb_radius", "0px"), + ("column_gap", "8px"), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect() +} + +fn default_settings() -> HashMap { + [ + ("compact_columns_width", "520"), + ("filter_width_ratio", "30"), + ("filter_width_min", "84"), + ("filter_width_max", "180"), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect() +} + +fn load_config(home: &Path) -> AppConfig { + let mut config = AppConfig { + sidebar_width: 136, + preview_width: 320, + window_width: 1080, + window_height: 700, + text_preview_limit_kib: 64, + image_preview_size: 512, + places: ["Desktop", "Documents", "Downloads", "Pictures"] + .into_iter() + .map(|name| (name.to_string(), home.join(name))) + .collect(), + colors: Palette::default(), + style_values: default_style_values(), + settings: default_settings(), + }; + let config_path = home.join(".config/wayfinder/config.ini"); + let mut entries = Vec::new(); + load_ini_recursive(&config_path, &mut Vec::new(), &mut entries); + + config.sidebar_width = ini_number(&entries, "general", "sidebar_width", 136, 96, 240); + config.preview_width = ini_number(&entries, "general", "preview_width", 320, 240, 720); + config.window_width = ini_number(&entries, "general", "window_width", 1080, 480, 4096); + config.window_height = ini_number(&entries, "general", "window_height", 700, 320, 4096); + config.text_preview_limit_kib = + ini_number(&entries, "general", "text_preview_limit_kib", 64, 1, 4096) as usize; + config.image_preview_size = + ini_number(&entries, "general", "image_preview_size", 512, 64, 2048); + let configured_places: Vec<_> = entries + .iter() + .filter(|entry| entry.section.eq_ignore_ascii_case("places")) + .map(|entry| (entry.key.clone(), expand_home_path(&entry.value, home))) + .collect(); + if !configured_places.is_empty() { + config.places = configured_places; + } + for entry in entries + .iter() + .filter(|entry| entry.section.eq_ignore_ascii_case("general")) + { + let key = entry.key.to_ascii_lowercase(); + if let Some(current) = config.settings.get_mut(&key) + && entry.value.parse::().is_ok() + { + *current = entry.value.clone(); + } + if let Some(current) = config.style_values.get_mut(&key) { + if key == "font_family" { + if entry.value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, ' ' | ',' | '"' | '-' | '_' | '.') + }) { + *current = entry.value.clone(); + } + } else if let Ok(value) = entry.value.parse::() { + let value = if key == "font_size" { + value.clamp(7.0, 24.0) + } else { + value.clamp(0.0, 64.0) + }; + let unit = if key == "font_size" + || (key.ends_with("_size") && key != "button_size") + || key == "metadata_font_size" + { + "pt" + } else { + "px" + }; + *current = format!("{value}{unit}"); + } + } + } + + for entry in entries + .iter() + .filter(|entry| entry.section.eq_ignore_ascii_case("colors")) + { + let key = entry.key.to_ascii_lowercase(); + if valid_color(&entry.value) { + config.colors.0.insert(key, entry.value.clone()); + } + } + config +} + +fn ini_number_from_config( + config: &AppConfig, + key: &str, + default: T, + min: T, + max: T, +) -> T { + config + .settings + .get(key) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) + .clamp(min, max) +} + +fn style_int(config: &AppConfig, key: &str, default: i32) -> i32 { + config + .style_values + .get(key) + .and_then(|value| style_value_int(value)) + .unwrap_or(default) +} + +fn style_value_int(value: &str) -> Option { + value.trim_end_matches("px").parse().ok() +} + +fn load_ini_recursive(path: &Path, stack: &mut Vec, merged: &mut Vec) { + let Ok(path) = fs::canonicalize(path) else { + return; + }; + if stack.contains(&path) { + eprintln!("Wayfinder config include cycle at {}", path.display()); + return; + } + let Ok(contents) = fs::read_to_string(&path) else { + return; + }; + let entries = parse_ini(&contents); + stack.push(path.clone()); + for include in entries.iter().filter(|entry| { + (entry.section.eq_ignore_ascii_case("includes") + && (entry.key.eq_ignore_ascii_case("file") || entry.key.eq_ignore_ascii_case("files"))) + || (entry.section.eq_ignore_ascii_case("general") + && entry.key.eq_ignore_ascii_case("include")) + }) { + for name in include + .value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let include_path = expand_home_path(name, &home); + let include_path = if include_path.is_absolute() { + include_path + } else { + path.parent().unwrap_or(Path::new(".")).join(include_path) + }; + load_ini_recursive(&include_path, stack, merged); + } + } + stack.pop(); + + for entry in entries.into_iter().filter(|entry| { + !entry.section.eq_ignore_ascii_case("includes") + && !(entry.section.eq_ignore_ascii_case("general") + && entry.key.eq_ignore_ascii_case("include")) + }) { + if let Some(existing) = merged.iter_mut().find(|existing| { + existing.section.eq_ignore_ascii_case(&entry.section) + && existing.key.eq_ignore_ascii_case(&entry.key) + }) { + existing.value = entry.value; + } else { + merged.push(entry); + } + } +} + +fn parse_ini(contents: &str) -> Vec { + let mut section = String::new(); + let mut entries = Vec::new(); + for raw in contents.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + section = line[1..line.len() - 1].trim().to_ascii_lowercase(); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + entries.push(IniEntry { + section: section.clone(), + key: key.to_string(), + value: ini_unquote(value.trim()), + }); + } + entries +} + +fn ini_unquote(value: &str) -> String { + let value = value.trim(); + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + value[1..value.len() - 1].to_string() + } else { + value.to_string() + } +} + +fn ini_value(entries: &[IniEntry], section: &str, key: &str) -> Option { + entries + .iter() + .rev() + .find(|entry| { + entry.section.eq_ignore_ascii_case(section) && entry.key.eq_ignore_ascii_case(key) + }) + .map(|entry| entry.value.clone()) +} + +fn ini_number( + entries: &[IniEntry], + section: &str, + key: &str, + default: T, + min: T, + max: T, +) -> T { + ini_value(entries, section, key) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) + .clamp(min, max) +} + +fn expand_home_path(value: &str, home: &Path) -> PathBuf { + if value == "~" { + home.to_path_buf() + } else if let Some(rest) = value.strip_prefix("~/") { + home.join(rest) + } else { + PathBuf::from(value) + } +} + +fn valid_color(value: &str) -> bool { + matches!(value.len(), 4 | 7 | 9) + && value.starts_with('#') + && value[1..].chars().all(|ch| ch.is_ascii_hexdigit()) +} + +#[derive(Clone)] +struct Browser { + location: Rc>, + show_hidden: Rc>, + list: gtk::ListBox, + location_entry: gtk::Entry, + status: gtk::Label, + filter: gtk::SearchEntry, + preview_title: gtk::Label, + preview_details: gtk::Label, + preview_panel: gtk::Box, + preview_stack: gtk::Stack, + preview_text: gtk::TextView, + preview_picture: gtk::Picture, + preview_message: gtk::Label, + preview_request: Rc>, + preview_job: Rc>>, + preview_file: Rc>>, + size_heading: gtk::Label, + modified_heading: gtk::Label, + available_width: Rc>, + sidebar_width: i32, + preview_width: i32, + text_preview_limit_kib: usize, + image_preview_size: i32, + compact_columns_width: i32, + filter_width_ratio: i32, + filter_width_min: i32, + filter_width_max: i32, + style_values: HashMap, +} + +struct PreviewJob { + request: u64, + child: Child, + output: PathBuf, + started: Instant, +} + +fn main() { + let app = gtk::Application::builder() + .application_id("dev.wfalkowski.Wayfinder") + .flags(gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.connect_activate(build_ui); + app.run(); +} + +fn build_ui(app: >k::Application) { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let config = load_config(&home); + install_style(&config); + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Wayfinder") + .default_width(config.window_width) + .default_height(config.window_height) + .build(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + root.add_css_class("root"); + let toolbar = gtk::Box::new( + gtk::Orientation::Horizontal, + style_int(&config, "toolbar_gap", 6), + ); + toolbar.add_css_class("toolbar"); + + let button_size = config.style_values["button_size"] + .trim_end_matches("px") + .parse() + .unwrap_or(34); + let back = tool_button("", "Go to parent folder", button_size); + let back_icon = gtk::Image::from_icon_name("go-previous"); + back_icon.set_pixel_size(style_int(&config, "icon_size", 16)); + back_icon.set_halign(gtk::Align::Center); + back_icon.set_valign(gtk::Align::Center); + back.set_child(Some(&back_icon)); + back.add_css_class("back-button"); + let refresh = tool_button("↻", "Reload this folder", button_size); + let location_entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text("Enter a folder path") + .build(); + location_entry.add_css_class("location"); + let filter = gtk::SearchEntry::builder() + .placeholder_text("Filter") + .width_chars(1) + .build(); + toolbar.append(&back); + toolbar.append(&refresh); + toolbar.append(&location_entry); + toolbar.append(&filter); + root.append(&toolbar); + + let body = gtk::Box::new(gtk::Orientation::Horizontal, 0); + body.set_vexpand(true); + let sidebar = gtk::Box::new( + gtk::Orientation::Vertical, + style_int(&config, "place_gap", 2), + ); + sidebar.set_width_request(config.sidebar_width); + sidebar.set_hexpand(false); + sidebar.add_css_class("sidebar"); + for (name, path) in &config.places { + let button = gtk::Button::with_label(name); + button.set_halign(gtk::Align::Fill); + if let Some(label) = button + .child() + .and_then(|child| child.downcast::().ok()) + { + label.set_xalign(0.0); + label.set_hexpand(true); + } + button.add_css_class("place"); + // Place actions are wired after the browser exists below. + button.set_tooltip_text(Some(&path.display().to_string())); + button.set_widget_name(&format!("place:{}", path.display())); + sidebar.append(&button); + } + body.append(&sidebar); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_hexpand(true); + content.set_vexpand(true); + let headings = gtk::Box::new( + gtk::Orientation::Horizontal, + style_int(&config, "column_gap", 8), + ); + headings.add_css_class("headings"); + headings.append(&heading("Name", true)); + let size_heading = heading("Size", false); + let modified_heading = heading("Modified", false); + size_heading.set_size_request(style_int(&config, "size_column_width", 72), -1); + modified_heading.set_size_request(style_int(&config, "modified_column_width", 126), -1); + headings.append(&size_heading); + headings.append(&modified_heading); + content.append(&headings); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::Single); + list.set_activate_on_single_click(false); + list.add_css_class("file-list"); + let scroll = gtk::ScrolledWindow::builder() + .child(&list) + .hexpand(true) + .vexpand(true) + .build(); + scroll.set_kinetic_scrolling(false); + content.append(&scroll); + + let preview_heading = gtk::Label::new(Some("Preview")); + preview_heading.set_xalign(0.0); + preview_heading.add_css_class("preview-heading"); + let preview_title = gtk::Label::new(Some("Select a file")); + preview_title.set_xalign(0.0); + preview_title.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + preview_title.add_css_class("preview-title"); + let preview_details = gtk::Label::new(Some("Select a file to preview")); + preview_details.set_xalign(0.0); + preview_details.set_wrap(true); + preview_details.add_css_class("preview-details"); + + let preview_stack = gtk::Stack::new(); + preview_stack.set_hexpand(true); + preview_stack.set_vexpand(true); + preview_stack.set_visible_child_name("empty"); + let preview_empty = gtk::Label::new(Some("Select a file to preview")); + preview_empty.set_wrap(true); + preview_empty.add_css_class("preview-message"); + preview_stack.add_named(&preview_empty, Some("empty")); + + let preview_text = gtk::TextView::new(); + preview_text.set_editable(false); + preview_text.set_cursor_visible(false); + preview_text.set_monospace(true); + preview_text.set_wrap_mode(gtk::WrapMode::None); + preview_text.add_css_class("preview-text"); + let text_scroll = gtk::ScrolledWindow::builder() + .child(&preview_text) + .hexpand(true) + .vexpand(true) + .build(); + text_scroll.set_kinetic_scrolling(false); + preview_stack.add_named(&text_scroll, Some("text")); + + let preview_picture = gtk::Picture::new(); + preview_picture.set_can_shrink(true); + preview_picture.set_keep_aspect_ratio(true); + preview_picture.set_halign(gtk::Align::Center); + preview_picture.set_valign(gtk::Align::Center); + preview_picture.set_hexpand(true); + preview_picture.set_vexpand(true); + preview_picture.add_css_class("preview-picture"); + preview_stack.add_named(&preview_picture, Some("image")); + + let preview_message = gtk::Label::new(None); + preview_message.set_wrap(true); + preview_message.set_justify(gtk::Justification::Center); + preview_message.add_css_class("preview-message"); + preview_stack.add_named(&preview_message, Some("message")); + + let preview = gtk::Box::new( + gtk::Orientation::Vertical, + style_int(&config, "preview_spacing", 10), + ); + preview.set_width_request(config.preview_width); + preview.set_hexpand(false); + preview.set_vexpand(true); + preview.set_visible(false); + preview.add_css_class("preview-pane"); + preview.append(&preview_heading); + preview.append(&preview_title); + preview.append(&preview_details); + preview.append(&preview_stack); + + let workspace = gtk::Paned::new(gtk::Orientation::Horizontal); + workspace.set_start_child(Some(&content)); + workspace.set_end_child(Some(&preview)); + workspace.set_position(620); + workspace.set_hexpand(true); + workspace.set_vexpand(true); + workspace.set_resize_start_child(true); + workspace.set_resize_end_child(false); + workspace.set_shrink_start_child(false); + workspace.set_shrink_end_child(false); + workspace.add_css_class("workspace-split"); + body.append(&workspace); + root.append(&body); + + let status = gtk::Label::new(None); + status.set_xalign(0.0); + status.add_css_class("status"); + status.set_margin_start(style_int(&config, "status_margin_x", 12)); + status.set_margin_end(style_int(&config, "status_margin_x", 12)); + status.set_margin_top(style_int(&config, "status_margin_y", 6)); + status.set_margin_bottom(style_int(&config, "status_margin_y", 6)); + root.append(&status); + let size_observer = gtk::DrawingArea::new(); + size_observer.set_hexpand(true); + size_observer.set_vexpand(true); + size_observer.set_halign(gtk::Align::Fill); + size_observer.set_valign(gtk::Align::Fill); + size_observer.set_can_target(false); + size_observer.set_opacity(0.0); + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&root)); + overlay.add_overlay(&size_observer); + window.set_child(Some(&overlay)); + + let browser = Browser { + location: Rc::new(RefCell::new(home)), + show_hidden: Rc::new(RefCell::new(false)), + list: list.clone(), + location_entry: location_entry.clone(), + status: status.clone(), + filter: filter.clone(), + preview_title, + preview_details, + preview_panel: preview, + preview_stack, + preview_text, + preview_picture, + preview_message, + preview_request: Rc::new(Cell::new(0)), + preview_job: Rc::new(RefCell::new(None)), + preview_file: Rc::new(RefCell::new(None)), + size_heading, + modified_heading, + available_width: Rc::new(Cell::new(window.default_width())), + sidebar_width: config.sidebar_width, + preview_width: config.preview_width, + text_preview_limit_kib: config.text_preview_limit_kib, + image_preview_size: config.image_preview_size, + compact_columns_width: ini_number_from_config( + &config, + "compact_columns_width", + 520, + 320, + 900, + ), + filter_width_ratio: ini_number_from_config(&config, "filter_width_ratio", 30, 20, 45), + filter_width_min: ini_number_from_config(&config, "filter_width_min", 84, 64, 240), + filter_width_max: ini_number_from_config(&config, "filter_width_max", 180, 100, 360), + style_values: config.style_values.clone(), + }; + { + let b = browser.clone(); + let back = back.clone(); + let refresh = refresh.clone(); + let location_entry = location_entry.clone(); + let filter = filter.clone(); + let toolbar = toolbar.clone(); + size_observer.connect_resize(move |_, width, _| { + b.available_width.set(width); + update_toolbar_widths( + &toolbar, + &back, + &refresh, + &location_entry, + &filter, + b.filter_width_ratio, + b.filter_width_min, + b.filter_width_max, + ); + update_metadata_columns(&b); + }); + } + reload(&browser); + + { + let b = browser.clone(); + location_entry.connect_activate(move |entry| { + let path = PathBuf::from(entry.text().as_str()); + navigate(&b, path); + }); + } + { + let b = browser.clone(); + back.connect_clicked(move |_| { + let parent = b.location.borrow().parent().map(Path::to_path_buf); + if let Some(path) = parent { + navigate(&b, path); + } + }); + } + { + let b = browser.clone(); + refresh.connect_clicked(move |_| reload(&b)); + } + { + let b = browser.clone(); + filter.connect_search_changed(move |_| apply_filter(&b)); + } + { + let b = browser.clone(); + list.connect_row_selected(move |_, row| { + if let Some(path) = row + .and_then(|row| row.tooltip_text()) + .map(|path| PathBuf::from(path.as_str())) + { + show_preview(&b, path); + } else { + clear_preview(&b); + } + }); + } + { + let b = browser.clone(); + list.connect_row_activated(move |_, row| { + if let Some(path) = row.tooltip_text() { + navigate(&b, PathBuf::from(path.as_str())); + } + }); + } + // Sidebar uses the path stored on each button; this avoids hard-coded home assumptions. + let mut child = sidebar.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(button) = widget.clone().downcast::() { + let b = browser.clone(); + let path = button + .widget_name() + .strip_prefix("place:") + .map(PathBuf::from); + if let Some(path) = path { + button.connect_clicked(move |_| navigate(&b, path.clone())); + } + } + } + { + let b = browser.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, state| { + if key == gtk::gdk::Key::Escape && b.preview_panel.is_visible() { + clear_preview(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l { + b.location_entry.grab_focus(); + b.location_entry.select_region(0, -1); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::F5 { + reload(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h { + let visible = !*b.show_hidden.borrow(); + *b.show_hidden.borrow_mut() = visible; + reload(&b); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::BackSpace { + if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) { + navigate(&b, parent); + } + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + window.add_controller(keys); + } + { + let b = browser.clone(); + window.connect_close_request(move |_| { + clear_preview(&b); + glib::Propagation::Proceed + }); + } + window.present(); +} + +fn navigate(browser: &Browser, path: PathBuf) { + if path.is_dir() { + *browser.location.borrow_mut() = path; + reload(browser); + } else if path.is_file() { + match open_file(&path) { + Ok(()) => browser.status.set_text(&format!( + "Opened {}", + path.file_name().unwrap_or_default().to_string_lossy() + )), + Err(error) => browser + .status + .set_text(&format!("Cannot open {}: {error}", path.display())), + } + } else { + browser + .status + .set_text("That path does not exist or cannot be opened."); + } +} + +fn clear_preview(browser: &Browser) { + cancel_preview_work(browser); + browser.preview_panel.set_visible(false); + update_metadata_columns(browser); + browser.preview_title.set_text("Select a file"); + browser.preview_details.set_text("Select a file to preview"); + browser.preview_stack.set_visible_child_name("empty"); +} + +fn cancel_preview_work(browser: &Browser) -> u64 { + let request = browser.preview_request.get().wrapping_add(1); + browser.preview_request.set(request); + if let Some(mut job) = browser.preview_job.borrow_mut().take() { + let _ = job.child.kill(); + let _ = job.child.wait(); + let _ = fs::remove_file(job.output); + } + browser.preview_picture.set_filename(None::<&Path>); + if let Some(path) = browser.preview_file.borrow_mut().take() { + let _ = fs::remove_file(path); + } + request +} + +fn show_preview(browser: &Browser, path: PathBuf) { + if !supports_preview(&path) { + clear_preview(browser); + return; + } + let request = cancel_preview_work(browser); + browser.preview_panel.set_visible(true); + update_metadata_columns(browser); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + browser.preview_title.set_text(&name); + + let details = fs::metadata(&path) + .map(|metadata| { + let modified = metadata + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "Unknown date".into()); + format!("{} · {modified}", format_size(metadata.len())) + }) + .unwrap_or_else(|_| path.display().to_string()); + browser.preview_details.set_text(&details); + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if is_preview_text(&extension) { + show_text_preview(browser, &path); + } else if is_preview_image(&extension) { + start_image_preview(browser, path, request, details); + } +} + +fn update_toolbar_widths( + toolbar: >k::Box, + back: >k::Button, + refresh: >k::Button, + location: >k::Entry, + filter: >k::SearchEntry, + filter_ratio: i32, + filter_min: i32, + filter_max: i32, +) { + let controls = back.width().max(34) + refresh.width().max(34) + 3 * 6 + 20; + let available = (toolbar.width() - controls).max(168); + let filter_outer = (available * filter_ratio / 100).clamp(filter_min, filter_max); + let location_outer = (available - filter_outer).max(84); + // Requests are content widths; the toolbar inputs add 20 px of CSS padding and border. + filter.set_width_request((filter_outer - 20).max(64)); + location.set_width_request((location_outer - 20).max(64)); +} + +fn update_metadata_columns(browser: &Browser) { + let preview_width = if browser.preview_panel.is_visible() { + browser.preview_width + 24 + } else { + 0 + }; + let file_area_width = browser.available_width.get() - browser.sidebar_width - preview_width; + let visible = + !browser.preview_panel.is_visible() || file_area_width >= browser.compact_columns_width; + browser.size_heading.set_visible(visible); + browser.modified_heading.set_visible(visible); + + let mut next = browser.list.first_child(); + while let Some(row) = next { + next = row.next_sibling(); + let Some(line) = row.first_child() else { + continue; + }; + if let Some(size) = line.first_child().and_then(|name| name.next_sibling()) { + size.set_visible(visible); + if let Some(modified) = size.next_sibling() { + modified.set_visible(visible); + } + } + } +} + +fn supports_preview(path: &Path) -> bool { + if path.is_dir() { + return false; + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + is_preview_text(&extension) || is_preview_image(&extension) +} + +fn set_preview_message(browser: &Browser, message: &str) { + browser.preview_message.set_text(message); + browser.preview_stack.set_visible_child_name("message"); +} + +fn is_preview_text(extension: &str) -> bool { + matches!( + extension, + "txt" + | "md" + | "markdown" + | "rs" + | "toml" + | "json" + | "yaml" + | "yml" + | "ini" + | "conf" + | "log" + | "sh" + | "bash" + | "zsh" + | "fish" + | "c" + | "h" + | "cc" + | "cpp" + | "hpp" + | "py" + | "html" + | "css" + | "js" + | "ts" + | "xml" + | "csv" + | "lock" + | "desktop" + | "service" + | "lua" + | "nix" + ) +} + +fn is_preview_image(extension: &str) -> bool { + matches!( + extension, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "avif" | "ico" + ) +} + +fn show_text_preview(browser: &Browser, path: &Path) { + let preview_limit = browser.text_preview_limit_kib * 1024; + let mut bytes = Vec::with_capacity(preview_limit + 1); + let result = fs::File::open(path).and_then(|file| { + file.take((preview_limit + 1) as u64) + .read_to_end(&mut bytes) + }); + if let Err(error) = result { + set_preview_message(browser, &format!("Cannot read this file: {error}")); + return; + } + if bytes.contains(&0) { + set_preview_message( + browser, + "This file contains binary data and cannot be previewed as text.", + ); + return; + } + let truncated = bytes.len() > preview_limit; + bytes.truncate(preview_limit); + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + text.push_str(&format!( + "\n\n[Preview limited to the first {} KiB]", + browser.text_preview_limit_kib + )); + } + browser.preview_text.buffer().set_text(&text); + browser.preview_stack.set_visible_child_name("text"); +} + +fn start_image_preview(browser: &Browser, path: PathBuf, request: u64, details: String) { + let source = match fs::canonicalize(&path) { + Ok(source) => source, + Err(error) => { + set_preview_message(browser, &format!("Cannot open this image: {error}")); + return; + } + }; + let output_dir = std::env::temp_dir().join(format!("wayfinder-preview-{}", std::process::id())); + if let Err(error) = fs::create_dir_all(&output_dir) { + set_preview_message(browser, &format!("Cannot create preview cache: {error}")); + return; + } + if let Err(error) = fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o700)) { + set_preview_message(browser, &format!("Cannot secure preview cache: {error}")); + return; + } + let output = output_dir.join(format!("preview-{request}.png")); + let _ = fs::remove_file(&output); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let is_jpeg = matches!(extension.to_ascii_lowercase().as_str(), "jpg" | "jpeg"); + let thumbnail_size = browser.image_preview_size; + let sandbox_input = format!("/wayfinder-input/preview.{extension}"); + let mut command = Command::new("bwrap"); + command + .args([ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/etc", + "/etc", + "--ro-bind-try", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--dir", + "/wayfinder-input", + "--ro-bind", + ]) + .arg(&source) + .arg(&sandbox_input) + .arg("--bind") + .arg(&output_dir) + .args([ + "/wayfinder-output", + "--chdir", + "/", + "--setenv", + "HOME", + "/tmp", + "--", + ]); + let sandbox_output = format!("/wayfinder-output/preview-{request}.png"); + if is_jpeg { + let script = format!( + "from PIL import Image; import sys; Image.MAX_IMAGE_PIXELS = 100000000; im = Image.open(sys.argv[1]); im.thumbnail(({thumbnail_size}, {thumbnail_size}), Image.Resampling.LANCZOS); im.convert('RGBA' if 'A' in im.getbands() else 'RGB').save(sys.argv[2], format='PNG')" + ); + command + .args(["/usr/bin/python3", "-c", &script]) + .arg(&sandbox_input) + .arg(&sandbox_output); + } else { + command + .arg("/usr/bin/gdk-pixbuf-thumbnailer") + .arg("--size") + .arg(thumbnail_size.to_string()) + .arg(&sandbox_input) + .arg(&sandbox_output); + } + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::null()); + + match command.spawn() { + Ok(child) => { + browser + .preview_details + .set_text("Generating image preview…"); + *browser.preview_job.borrow_mut() = Some(PreviewJob { + request, + child, + output: output.clone(), + started: Instant::now(), + }); + let browser = browser.clone(); + glib::timeout_add_local(Duration::from_millis(80), move || { + if browser.preview_request.get() != request { + return glib::ControlFlow::Break; + } + let completion = { + let mut pending = browser.preview_job.borrow_mut(); + let Some(job) = pending.as_mut() else { + return glib::ControlFlow::Break; + }; + if job.request != request { + return glib::ControlFlow::Break; + } + match job.child.try_wait() { + Ok(None) if job.started.elapsed() < Duration::from_secs(8) => None, + Ok(None) => { + let _ = job.child.kill(); + let _ = job.child.wait(); + Some(false) + } + Ok(Some(status)) => Some(status.success()), + Err(_) => Some(false), + } + .map(|success| { + let job = pending.take().expect("preview job exists"); + (success, job.output) + }) + }; + let Some((success, output)) = completion else { + return glib::ControlFlow::Continue; + }; + if success && output.is_file() { + browser.preview_picture.set_filename(Some(&output)); + browser.preview_details.set_text(&details); + *browser.preview_file.borrow_mut() = Some(output); + browser.preview_stack.set_visible_child_name("image"); + } else { + let _ = fs::remove_file(output); + set_preview_message( + &browser, + "Image preview failed. The format may be unsupported, or the sandboxed thumbnailer may be unavailable.", + ); + } + glib::ControlFlow::Break + }); + } + Err(error) => set_preview_message( + browser, + &format!("Could not start the sandboxed image preview: {error}"), + ), + } +} + +fn open_file(path: &Path) -> Result<(), String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let text_extensions = [ + "txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh", + "bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts", + "xml", "csv", "lock", "desktop", "service", "lua", "nix", + ]; + let image_extensions = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif", + ]; + + let mut command = if text_extensions.contains(&extension.as_str()) { + let mut command = Command::new("foot"); + command.args(["-e", "nvim", "--"]).arg(path); + command + } else if image_extensions.contains(&extension.as_str()) { + let mut command = Command::new("imv"); + command.arg(path); + command + } else if fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + { + let mut command = Command::new("foot"); + command.arg("-e").arg(path); + command + } else { + let uri = gio::File::for_path(path).uri(); + return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>) + .map_err(|error| error.to_string()); + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not start the requested application: {error}")) +} + +fn reload(browser: &Browser) { + clear_preview(browser); + while let Some(row) = browser.list.first_child() { + browser.list.remove(&row); + } + let path = browser.location.borrow().clone(); + browser.location_entry.set_text(&path.display().to_string()); + let entries = match fs::read_dir(&path) { + Ok(items) => items, + Err(error) => { + browser + .status + .set_text(&format!("Cannot read {}: {error}", path.display())); + return; + } + }; + let mut rows = Vec::new(); + for item in entries.flatten() { + let path = item.path(); + let name = item.file_name().to_string_lossy().into_owned(); + if !*browser.show_hidden.borrow() && name.starts_with('.') { + continue; + } + let Ok(meta) = item.metadata() else { continue }; + let directory = meta.is_dir(); + let modified = meta + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "—".into()); + rows.push(Entry { + path, + name, + directory, + size: meta.len(), + modified, + }); + } + rows.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in rows { + let row = make_row(&entry, &browser.style_values); + row.set_selectable(true); + row.set_activatable(true); + row.set_tooltip_text(Some(&entry.path.to_string_lossy())); + browser.list.append(&row); + } + let count = browser.list.observe_children().n_items(); + browser + .status + .set_text(&format!("{count} items · {}", path.display())); + apply_filter(browser); +} + +fn make_row(entry: &Entry, style_values: &HashMap) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let get_int = |key: &str, default| { + style_values + .get(key) + .and_then(|value| style_value_int(value)) + .unwrap_or(default) + }; + let line = gtk::Box::new(gtk::Orientation::Horizontal, get_int("column_gap", 8)); + line.set_margin_start(get_int("row_padding_x", 8)); + line.set_margin_end(get_int("row_padding_x", 8)); + line.set_margin_top(get_int("row_padding_y", 5)); + line.set_margin_bottom(get_int("row_padding_y", 5)); + let name = gtk::Label::new(None); + let display_name: String = entry + .name + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect(); + name.set_text(&if entry.directory { + format!("{display_name}/") + } else { + display_name + }); + name.set_xalign(0.0); + name.set_wrap(true); + name.set_wrap_mode(gtk::pango::WrapMode::WordChar); + name.set_valign(gtk::Align::Center); + name.set_hexpand(true); + name.add_css_class(if entry.directory { + "directory" + } else { + "filename" + }); + let size_text = if entry.directory { + "Folder".to_string() + } else { + format_size(entry.size) + }; + let size = metadata_value( + &size_text, + get_int("size_column_width", 72), + get_int("metadata_value_height", 24), + ); + let modified = metadata_value( + &entry.modified, + get_int("modified_column_width", 126), + get_int("metadata_value_height", 24), + ); + line.append(&name); + line.append(&size); + line.append(&modified); + row.set_child(Some(&line)); + row +} + +fn heading(text: &str, expand: bool) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + if expand { + label.set_hexpand(true); + } else { + label.set_size_request(if text == "Size" { 72 } else { 126 }, -1); + } + label.add_css_class("column-heading"); + label +} + +fn metadata_value(text: &str, width: i32, height: i32) -> gtk::Label { + let value = gtk::Label::new(Some(text)); + value.set_size_request(width, height); + value.set_xalign(0.0); + value.set_valign(gtk::Align::Center); + value.set_ellipsize(gtk::pango::EllipsizeMode::End); + value.add_css_class("metadata"); + value +} + +fn tool_button(label: &str, tip: &str, size: i32) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.set_tooltip_text(Some(tip)); + button.set_size_request(size, size); + button.add_css_class("tool-button"); + button +} + +fn apply_filter(browser: &Browser) { + let needle = browser.filter.text().to_lowercase(); + let mut child = browser.list.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(row) = widget.clone().downcast::() { + let name = row + .child() + .and_then(|line| line.first_child()) + .and_then(|label| label.downcast::().ok()) + .map(|label| label.text().to_string().to_lowercase()) + .unwrap_or_default(); + row.set_visible(name.contains(&needle)); + } + } +} + +fn format_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_time(time: std::time::SystemTime) -> String { + // Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable. + match time.elapsed() { + Ok(age) if age.as_secs() < 60 => "Just now".into(), + Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60), + Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600), + Ok(age) => format!("{} days ago", age.as_secs() / 86400), + Err(_) => "Just now".into(), + } +} + +fn install_style(config: &AppConfig) { + let provider = gtk::CssProvider::new(); + let css = r#" + * { font-family: @font_family@; font-size: @font_size@; } + window, .root { background: @background@; color: @text@; } + .toolbar { background: @panel@; padding: @toolbar_pad_y@ @toolbar_pad_x@; } + entry, searchentry { color: @text@; background: @surface@; border: @border_width@ solid @border@; border-radius: @corner_radius@; padding: @control_pad_y@ @control_pad_x@; min-height: @control_min_height@; } + entry:focus, searchentry:focus { border-color: @accent@; } + .tool-button { color: @text@; background: @surface@; border: @border_width@ solid @border@; border-radius: @corner_radius@; padding: 0; min-width: @button_size@; min-height: @button_size@; } + .back-button { min-width: @button_size@; min-height: @button_size@; } + .tool-button:hover { color: @emphasis@; background: @selected@; border-color: @accent@; } + .tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: @border_width@ solid @focus_border@; outline-offset: -2px; } + .sidebar { background: @panel@; border-top: @top_border_width@ solid @border@; border-right: @border_width@ solid @border@; padding: @sidebar_pad_y@ @sidebar_pad_x@; min-width: @sidebar_width@px; max-width: @sidebar_width@px; } + .place { color: @text@; background: transparent; border: @border_width@ solid transparent; border-radius: @corner_radius@; padding: @place_pad_y@ @place_pad_x@; min-height: @place_min_height@; } + .place:hover { color: @emphasis@; background: @hover@; border-color: @border@; } + .place:active { background: @selected@; } + .headings { background: @panel@; border-top: @top_border_width@ solid @border@; border-bottom: @border_width@ solid @border@; padding: @heading_pad_y@ @heading_pad_x@; } + .preview-pane { background: @panel@; border-left: @border_width@ solid @border@; padding: @preview_pad@; min-width: @preview_width@px; max-width: @preview_width@px; } + .preview-heading { color: @emphasis@; font-size: @preview_heading_size@; } + .preview-title { color: @text@; font-size: @preview_title_size@; padding: @preview_title_pad@ 0; } + .preview-details { color: @muted@; font-size: @preview_details_size@; padding: @preview_title_pad@ 0; } + .preview-message { color: @muted@; padding: @preview_message_pad@; } + .preview-text { background: @background@; color: @text@; padding: @preview_text_pad@; } + .preview-picture { background: @background@; } + .workspace-split > separator { background: @border@; min-width: @border_width@; } + .column-heading { color: @muted@; font-size: @preview_details_size@; } + .file-list { background: @background@; } + .file-list row { border-bottom: @border_width@ solid @border@; min-height: @row_height@; } + .file-list row:hover { background: @hover@; } + .file-list row:selected { background: @selected@; outline: @border_width@ solid @accent@; outline-offset: -1px; } + .directory { color: @directory@; } + .filename { color: @text@; } + .metadata, .status { color: @muted@; font-size: @metadata_font_size@; } + .metadata { padding: @metadata_pad_top@ 0 @metadata_pad_bottom@; } + scrolledwindow { border: 0; } + scrolledwindow overshoot { opacity: 0; background: transparent; border: none; box-shadow: none; } + scrollbar { background: transparent; border: none; box-shadow: none; } + scrollbar.vertical { min-width: @scrollbar_width@; } + scrollbar.horizontal { min-height: @scrollbar_width@; } + scrollbar > range > trough { background: transparent; border: none; box-shadow: none; } + scrollbar > range > trough > slider { background: @accent@; min-width: @scrollbar_thumb_width@; min-height: @scrollbar_thumb_width@; border: none; border-radius: @scrollbar_thumb_radius@; outline-style: none; outline-width: 0; box-shadow: none; } + scrollbar > range > trough > slider:hover, + scrollbar > range > trough > slider:active, + scrollbar > range > trough > slider:focus { background: @accent@; border: none; outline-style: none; outline-width: 0; box-shadow: none; } + scrollbar button { min-width: 0; min-height: 0; padding: 0; border: 0; } + "#; + let mut css = css.to_string(); + for (key, value) in &config.colors.0 { + css = css.replace(&format!("@{key}@"), value); + } + for (key, value) in &config.style_values { + css = css.replace(&format!("@{key}@"), value); + } + css = css + .replace("@sidebar_width@", &config.sidebar_width.to_string()) + .replace("@preview_width@", &config.preview_width.to_string()); + provider.load_from_data(&css); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/src/main.rs.backup-ini-prep-20261018T140000Z b/src/main.rs.backup-ini-prep-20261018T140000Z new file mode 100644 index 0000000..8a61175 --- /dev/null +++ b/src/main.rs.backup-ini-prep-20261018T140000Z @@ -0,0 +1,1194 @@ +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use std::cell::Cell; +use std::cell::RefCell; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Entry { + path: PathBuf, + name: String, + directory: bool, + size: u64, + modified: String, +} + +#[derive(Clone)] +struct AppConfig { + sidebar_width: i32, + places: Vec<(String, PathBuf)>, + colors: Palette, +} + +#[derive(Clone)] +struct Palette { + background: String, + panel: String, + text: String, + muted: String, + accent: String, + border: String, +} + +impl Default for Palette { + fn default() -> Self { + Self { + background: "#151515".into(), + panel: "#181818".into(), + text: "#D0D0D0".into(), + muted: "#858585".into(), + accent: "#6C99BB".into(), + border: "#2F2F2F".into(), + } + } +} + +fn load_config(home: &Path) -> AppConfig { + #[derive(Clone, Copy, PartialEq)] + enum Section { + Root, + Places, + Colors, + } + + let mut config = AppConfig { + sidebar_width: 136, + places: ["Desktop", "Documents", "Downloads", "Pictures"] + .into_iter() + .map(|name| (name.to_string(), home.join(name))) + .collect(), + colors: Palette::default(), + }; + let file = home.join(".config/wayfinder/config.yml"); + let Ok(contents) = fs::read_to_string(&file) else { + return config; + }; + + let mut section = Section::Root; + let mut places = Vec::<(String, PathBuf)>::new(); + let mut pending_place: Option<(String, Option)> = None; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line == "---" { + continue; + } + if !raw_line.chars().next().is_some_and(char::is_whitespace) { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let key = key.trim(); + let value = unquote(value.trim()); + match key { + "places" => section = Section::Places, + "colors" => section = Section::Colors, + "sidebar_width" => { + if let Ok(width) = value.parse::() { + config.sidebar_width = width.clamp(96, 240); + } + section = Section::Root; + } + _ => section = Section::Root, + } + continue; + } + + match section { + Section::Places => { + let item = line.strip_prefix('-').map(str::trim); + if let Some(item) = item { + if let Some((name, Some(path))) = pending_place.take() { + places.push((name, path)); + } + pending_place = Some((String::new(), None)); + if !item.is_empty() { + set_place_field(&mut pending_place, item, home); + } + } else if pending_place.is_some() { + set_place_field(&mut pending_place, line, home); + } + } + Section::Colors => { + if let Some((key, value)) = line.split_once(':') { + let value = unquote(value.trim()); + if !valid_color(&value) { + continue; + } + match key.trim() { + "background" => config.colors.background = value, + "panel" => config.colors.panel = value, + "text" => config.colors.text = value, + "muted" => config.colors.muted = value, + "accent" => config.colors.accent = value, + "border" => config.colors.border = value, + _ => {} + } + } + } + Section::Root => {} + } + } + if let Some((name, Some(path))) = pending_place { + places.push((name, path)); + } + if !places.is_empty() { + config.places = places; + } + config +} + +fn set_place_field(place: &mut Option<(String, Option)>, line: &str, home: &Path) { + let Some((key, value)) = line.split_once(':') else { + return; + }; + let value = unquote(value.trim()); + let Some((name, path)) = place.as_mut() else { + return; + }; + match key.trim() { + "name" => *name = value, + "path" => { + let expanded = if value == "~" { + home.to_path_buf() + } else if let Some(rest) = value.strip_prefix("~/") { + home.join(rest) + } else { + PathBuf::from(value) + }; + *path = Some(expanded); + } + _ => {} + } +} + +fn unquote(value: &str) -> String { + value.trim_matches(|ch| ch == '\'' || ch == '"').to_string() +} + +fn valid_color(value: &str) -> bool { + matches!(value.len(), 4 | 7 | 9) + && value.starts_with('#') + && value[1..].chars().all(|ch| ch.is_ascii_hexdigit()) +} + +#[derive(Clone)] +struct Browser { + location: Rc>, + show_hidden: Rc>, + list: gtk::ListBox, + location_entry: gtk::Entry, + status: gtk::Label, + filter: gtk::SearchEntry, + preview_title: gtk::Label, + preview_details: gtk::Label, + preview_panel: gtk::Box, + preview_stack: gtk::Stack, + preview_text: gtk::TextView, + preview_picture: gtk::Picture, + preview_message: gtk::Label, + preview_request: Rc>, + preview_job: Rc>>, + preview_file: Rc>>, + size_heading: gtk::Label, + modified_heading: gtk::Label, + available_width: Rc>, + sidebar_width: i32, +} + +struct PreviewJob { + request: u64, + child: Child, + output: PathBuf, + started: Instant, +} + +fn main() { + let app = gtk::Application::builder() + .application_id("dev.wfalkowski.Wayfinder") + .flags(gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.connect_activate(build_ui); + app.run(); +} + +fn build_ui(app: >k::Application) { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let config = load_config(&home); + install_style(&config); + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Wayfinder") + .default_width(1080) + .default_height(700) + .build(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + root.add_css_class("root"); + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); + toolbar.add_css_class("toolbar"); + + let back = tool_button("", "Go to parent folder"); + let back_icon = gtk::Image::from_icon_name("go-previous"); + back_icon.set_pixel_size(16); + back_icon.set_halign(gtk::Align::Center); + back_icon.set_valign(gtk::Align::Center); + back.set_child(Some(&back_icon)); + back.add_css_class("back-button"); + let refresh = tool_button("↻", "Reload this folder"); + let location_entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text("Enter a folder path") + .build(); + location_entry.add_css_class("location"); + let filter = gtk::SearchEntry::builder() + .placeholder_text("Filter") + .width_chars(1) + .build(); + toolbar.append(&back); + toolbar.append(&refresh); + toolbar.append(&location_entry); + toolbar.append(&filter); + root.append(&toolbar); + + let body = gtk::Box::new(gtk::Orientation::Horizontal, 0); + body.set_vexpand(true); + let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 2); + sidebar.set_width_request(config.sidebar_width); + sidebar.set_hexpand(false); + sidebar.add_css_class("sidebar"); + for (name, path) in &config.places { + let button = gtk::Button::with_label(name); + button.set_halign(gtk::Align::Fill); + if let Some(label) = button + .child() + .and_then(|child| child.downcast::().ok()) + { + label.set_xalign(0.0); + label.set_hexpand(true); + } + button.add_css_class("place"); + // Place actions are wired after the browser exists below. + button.set_tooltip_text(Some(&path.display().to_string())); + button.set_widget_name(&format!("place:{}", path.display())); + sidebar.append(&button); + } + body.append(&sidebar); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_hexpand(true); + content.set_vexpand(true); + let headings = gtk::Box::new(gtk::Orientation::Horizontal, 8); + headings.add_css_class("headings"); + headings.append(&heading("Name", true)); + let size_heading = heading("Size", false); + let modified_heading = heading("Modified", false); + headings.append(&size_heading); + headings.append(&modified_heading); + content.append(&headings); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::Single); + list.set_activate_on_single_click(false); + list.add_css_class("file-list"); + let scroll = gtk::ScrolledWindow::builder() + .child(&list) + .hexpand(true) + .vexpand(true) + .build(); + scroll.set_kinetic_scrolling(false); + content.append(&scroll); + + let preview_heading = gtk::Label::new(Some("Preview")); + preview_heading.set_xalign(0.0); + preview_heading.add_css_class("preview-heading"); + let preview_title = gtk::Label::new(Some("Select a file")); + preview_title.set_xalign(0.0); + preview_title.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + preview_title.add_css_class("preview-title"); + let preview_details = gtk::Label::new(Some("Select a file to preview")); + preview_details.set_xalign(0.0); + preview_details.set_wrap(true); + preview_details.add_css_class("preview-details"); + + let preview_stack = gtk::Stack::new(); + preview_stack.set_hexpand(true); + preview_stack.set_vexpand(true); + preview_stack.set_visible_child_name("empty"); + let preview_empty = gtk::Label::new(Some("Select a file to preview")); + preview_empty.set_wrap(true); + preview_empty.add_css_class("preview-message"); + preview_stack.add_named(&preview_empty, Some("empty")); + + let preview_text = gtk::TextView::new(); + preview_text.set_editable(false); + preview_text.set_cursor_visible(false); + preview_text.set_monospace(true); + preview_text.set_wrap_mode(gtk::WrapMode::None); + preview_text.add_css_class("preview-text"); + let text_scroll = gtk::ScrolledWindow::builder() + .child(&preview_text) + .hexpand(true) + .vexpand(true) + .build(); + text_scroll.set_kinetic_scrolling(false); + preview_stack.add_named(&text_scroll, Some("text")); + + let preview_picture = gtk::Picture::new(); + preview_picture.set_can_shrink(true); + preview_picture.set_keep_aspect_ratio(true); + preview_picture.set_halign(gtk::Align::Center); + preview_picture.set_valign(gtk::Align::Center); + preview_picture.set_hexpand(true); + preview_picture.set_vexpand(true); + preview_picture.add_css_class("preview-picture"); + preview_stack.add_named(&preview_picture, Some("image")); + + let preview_message = gtk::Label::new(None); + preview_message.set_wrap(true); + preview_message.set_justify(gtk::Justification::Center); + preview_message.add_css_class("preview-message"); + preview_stack.add_named(&preview_message, Some("message")); + + let preview = gtk::Box::new(gtk::Orientation::Vertical, 10); + preview.set_width_request(320); + preview.set_hexpand(false); + preview.set_vexpand(true); + preview.set_visible(false); + preview.add_css_class("preview-pane"); + preview.append(&preview_heading); + preview.append(&preview_title); + preview.append(&preview_details); + preview.append(&preview_stack); + + let workspace = gtk::Paned::new(gtk::Orientation::Horizontal); + workspace.set_start_child(Some(&content)); + workspace.set_end_child(Some(&preview)); + workspace.set_position(620); + workspace.set_hexpand(true); + workspace.set_vexpand(true); + workspace.set_resize_start_child(true); + workspace.set_resize_end_child(false); + workspace.set_shrink_start_child(false); + workspace.set_shrink_end_child(false); + workspace.add_css_class("workspace-split"); + body.append(&workspace); + root.append(&body); + + let status = gtk::Label::new(None); + status.set_xalign(0.0); + status.add_css_class("status"); + status.set_margin_start(12); + status.set_margin_end(12); + status.set_margin_top(6); + status.set_margin_bottom(6); + root.append(&status); + let size_observer = gtk::DrawingArea::new(); + size_observer.set_hexpand(true); + size_observer.set_vexpand(true); + size_observer.set_halign(gtk::Align::Fill); + size_observer.set_valign(gtk::Align::Fill); + size_observer.set_can_target(false); + size_observer.set_opacity(0.0); + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&root)); + overlay.add_overlay(&size_observer); + window.set_child(Some(&overlay)); + + let browser = Browser { + location: Rc::new(RefCell::new(home)), + show_hidden: Rc::new(RefCell::new(false)), + list: list.clone(), + location_entry: location_entry.clone(), + status: status.clone(), + filter: filter.clone(), + preview_title, + preview_details, + preview_panel: preview, + preview_stack, + preview_text, + preview_picture, + preview_message, + preview_request: Rc::new(Cell::new(0)), + preview_job: Rc::new(RefCell::new(None)), + preview_file: Rc::new(RefCell::new(None)), + size_heading, + modified_heading, + available_width: Rc::new(Cell::new(window.default_width())), + sidebar_width: config.sidebar_width, + }; + { + let b = browser.clone(); + let back = back.clone(); + let refresh = refresh.clone(); + let location_entry = location_entry.clone(); + let filter = filter.clone(); + let toolbar = toolbar.clone(); + size_observer.connect_resize(move |_, width, _| { + b.available_width.set(width); + update_toolbar_widths(&toolbar, &back, &refresh, &location_entry, &filter); + update_metadata_columns(&b); + }); + } + reload(&browser); + + { + let b = browser.clone(); + location_entry.connect_activate(move |entry| { + let path = PathBuf::from(entry.text().as_str()); + navigate(&b, path); + }); + } + { + let b = browser.clone(); + back.connect_clicked(move |_| { + let parent = b.location.borrow().parent().map(Path::to_path_buf); + if let Some(path) = parent { + navigate(&b, path); + } + }); + } + { + let b = browser.clone(); + refresh.connect_clicked(move |_| reload(&b)); + } + { + let b = browser.clone(); + filter.connect_search_changed(move |_| apply_filter(&b)); + } + { + let b = browser.clone(); + list.connect_row_selected(move |_, row| { + if let Some(path) = row + .and_then(|row| row.tooltip_text()) + .map(|path| PathBuf::from(path.as_str())) + { + show_preview(&b, path); + } else { + clear_preview(&b); + } + }); + } + { + let b = browser.clone(); + list.connect_row_activated(move |_, row| { + if let Some(path) = row.tooltip_text() { + navigate(&b, PathBuf::from(path.as_str())); + } + }); + } + // Sidebar uses the path stored on each button; this avoids hard-coded home assumptions. + let mut child = sidebar.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(button) = widget.clone().downcast::() { + let b = browser.clone(); + let path = button + .widget_name() + .strip_prefix("place:") + .map(PathBuf::from); + if let Some(path) = path { + button.connect_clicked(move |_| navigate(&b, path.clone())); + } + } + } + { + let b = browser.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, state| { + if key == gtk::gdk::Key::Escape && b.preview_panel.is_visible() { + clear_preview(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l { + b.location_entry.grab_focus(); + b.location_entry.select_region(0, -1); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::F5 { + reload(&b); + return glib::Propagation::Stop; + } + if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h { + let visible = !*b.show_hidden.borrow(); + *b.show_hidden.borrow_mut() = visible; + reload(&b); + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::BackSpace { + if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) { + navigate(&b, parent); + } + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + window.add_controller(keys); + } + { + let b = browser.clone(); + window.connect_close_request(move |_| { + clear_preview(&b); + glib::Propagation::Proceed + }); + } + window.present(); +} + +fn navigate(browser: &Browser, path: PathBuf) { + if path.is_dir() { + *browser.location.borrow_mut() = path; + reload(browser); + } else if path.is_file() { + match open_file(&path) { + Ok(()) => browser.status.set_text(&format!( + "Opened {}", + path.file_name().unwrap_or_default().to_string_lossy() + )), + Err(error) => browser + .status + .set_text(&format!("Cannot open {}: {error}", path.display())), + } + } else { + browser + .status + .set_text("That path does not exist or cannot be opened."); + } +} + +fn clear_preview(browser: &Browser) { + cancel_preview_work(browser); + browser.preview_panel.set_visible(false); + update_metadata_columns(browser); + browser.preview_title.set_text("Select a file"); + browser.preview_details.set_text("Select a file to preview"); + browser.preview_stack.set_visible_child_name("empty"); +} + +fn cancel_preview_work(browser: &Browser) -> u64 { + let request = browser.preview_request.get().wrapping_add(1); + browser.preview_request.set(request); + if let Some(mut job) = browser.preview_job.borrow_mut().take() { + let _ = job.child.kill(); + let _ = job.child.wait(); + let _ = fs::remove_file(job.output); + } + browser.preview_picture.set_filename(None::<&Path>); + if let Some(path) = browser.preview_file.borrow_mut().take() { + let _ = fs::remove_file(path); + } + request +} + +fn show_preview(browser: &Browser, path: PathBuf) { + if !supports_preview(&path) { + clear_preview(browser); + return; + } + let request = cancel_preview_work(browser); + browser.preview_panel.set_visible(true); + update_metadata_columns(browser); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + browser.preview_title.set_text(&name); + + let details = fs::metadata(&path) + .map(|metadata| { + let modified = metadata + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "Unknown date".into()); + format!("{} · {modified}", format_size(metadata.len())) + }) + .unwrap_or_else(|_| path.display().to_string()); + browser.preview_details.set_text(&details); + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if is_preview_text(&extension) { + show_text_preview(browser, &path); + } else if is_preview_image(&extension) { + start_image_preview(browser, path, request, details); + } +} + +fn update_toolbar_widths( + toolbar: >k::Box, + back: >k::Button, + refresh: >k::Button, + location: >k::Entry, + filter: >k::SearchEntry, +) { + let controls = back.width().max(34) + refresh.width().max(34) + 3 * 6 + 20; + let available = (toolbar.width() - controls).max(168); + let filter_outer = (available * 30 / 100).clamp(84, 180); + let location_outer = (available - filter_outer).max(84); + // Requests are content widths; the toolbar inputs add 20 px of CSS padding and border. + filter.set_width_request((filter_outer - 20).max(64)); + location.set_width_request((location_outer - 20).max(64)); +} + +fn update_metadata_columns(browser: &Browser) { + let preview_width = if browser.preview_panel.is_visible() { + 344 + } else { + 0 + }; + let file_area_width = browser.available_width.get() - browser.sidebar_width - preview_width; + let visible = !browser.preview_panel.is_visible() || file_area_width >= 520; + browser.size_heading.set_visible(visible); + browser.modified_heading.set_visible(visible); + + let mut next = browser.list.first_child(); + while let Some(row) = next { + next = row.next_sibling(); + let Some(line) = row.first_child() else { + continue; + }; + if let Some(size) = line.first_child().and_then(|name| name.next_sibling()) { + size.set_visible(visible); + if let Some(modified) = size.next_sibling() { + modified.set_visible(visible); + } + } + } +} + +fn supports_preview(path: &Path) -> bool { + if path.is_dir() { + return false; + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + is_preview_text(&extension) || is_preview_image(&extension) +} + +fn set_preview_message(browser: &Browser, message: &str) { + browser.preview_message.set_text(message); + browser.preview_stack.set_visible_child_name("message"); +} + +fn is_preview_text(extension: &str) -> bool { + matches!( + extension, + "txt" + | "md" + | "markdown" + | "rs" + | "toml" + | "json" + | "yaml" + | "yml" + | "ini" + | "conf" + | "log" + | "sh" + | "bash" + | "zsh" + | "fish" + | "c" + | "h" + | "cc" + | "cpp" + | "hpp" + | "py" + | "html" + | "css" + | "js" + | "ts" + | "xml" + | "csv" + | "lock" + | "desktop" + | "service" + | "lua" + | "nix" + ) +} + +fn is_preview_image(extension: &str) -> bool { + matches!( + extension, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "avif" | "ico" + ) +} + +fn show_text_preview(browser: &Browser, path: &Path) { + const PREVIEW_LIMIT: usize = 64 * 1024; + let mut bytes = Vec::with_capacity(PREVIEW_LIMIT + 1); + let result = fs::File::open(path).and_then(|file| { + file.take((PREVIEW_LIMIT + 1) as u64) + .read_to_end(&mut bytes) + }); + if let Err(error) = result { + set_preview_message(browser, &format!("Cannot read this file: {error}")); + return; + } + if bytes.contains(&0) { + set_preview_message( + browser, + "This file contains binary data and cannot be previewed as text.", + ); + return; + } + let truncated = bytes.len() > PREVIEW_LIMIT; + bytes.truncate(PREVIEW_LIMIT); + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + text.push_str("\n\n[Preview limited to the first 64 KiB]"); + } + browser.preview_text.buffer().set_text(&text); + browser.preview_stack.set_visible_child_name("text"); +} + +fn start_image_preview(browser: &Browser, path: PathBuf, request: u64, details: String) { + let source = match fs::canonicalize(&path) { + Ok(source) => source, + Err(error) => { + set_preview_message(browser, &format!("Cannot open this image: {error}")); + return; + } + }; + let output_dir = std::env::temp_dir().join(format!("wayfinder-preview-{}", std::process::id())); + if let Err(error) = fs::create_dir_all(&output_dir) { + set_preview_message(browser, &format!("Cannot create preview cache: {error}")); + return; + } + if let Err(error) = fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o700)) { + set_preview_message(browser, &format!("Cannot secure preview cache: {error}")); + return; + } + let output = output_dir.join(format!("preview-{request}.png")); + let _ = fs::remove_file(&output); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let is_jpeg = matches!(extension.to_ascii_lowercase().as_str(), "jpg" | "jpeg"); + let sandbox_input = format!("/wayfinder-input/preview.{extension}"); + let mut command = Command::new("bwrap"); + command + .args([ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/etc", + "/etc", + "--ro-bind-try", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--dir", + "/wayfinder-input", + "--ro-bind", + ]) + .arg(&source) + .arg(&sandbox_input) + .arg("--bind") + .arg(&output_dir) + .args([ + "/wayfinder-output", + "--chdir", + "/", + "--setenv", + "HOME", + "/tmp", + "--", + ]); + let sandbox_output = format!("/wayfinder-output/preview-{request}.png"); + if is_jpeg { + command + .args([ + "/usr/bin/python3", + "-c", + "from PIL import Image; import sys; Image.MAX_IMAGE_PIXELS = 100000000; im = Image.open(sys.argv[1]); im.thumbnail((512, 512), Image.Resampling.LANCZOS); im.convert('RGBA' if 'A' in im.getbands() else 'RGB').save(sys.argv[2], format='PNG')", + ]) + .arg(&sandbox_input) + .arg(&sandbox_output); + } else { + command + .args(["/usr/bin/gdk-pixbuf-thumbnailer", "--size", "512"]) + .arg(&sandbox_input) + .arg(&sandbox_output); + } + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::null()); + + match command.spawn() { + Ok(child) => { + browser + .preview_details + .set_text("Generating image preview…"); + *browser.preview_job.borrow_mut() = Some(PreviewJob { + request, + child, + output: output.clone(), + started: Instant::now(), + }); + let browser = browser.clone(); + glib::timeout_add_local(Duration::from_millis(80), move || { + if browser.preview_request.get() != request { + return glib::ControlFlow::Break; + } + let completion = { + let mut pending = browser.preview_job.borrow_mut(); + let Some(job) = pending.as_mut() else { + return glib::ControlFlow::Break; + }; + if job.request != request { + return glib::ControlFlow::Break; + } + match job.child.try_wait() { + Ok(None) if job.started.elapsed() < Duration::from_secs(8) => None, + Ok(None) => { + let _ = job.child.kill(); + let _ = job.child.wait(); + Some(false) + } + Ok(Some(status)) => Some(status.success()), + Err(_) => Some(false), + } + .map(|success| { + let job = pending.take().expect("preview job exists"); + (success, job.output) + }) + }; + let Some((success, output)) = completion else { + return glib::ControlFlow::Continue; + }; + if success && output.is_file() { + browser.preview_picture.set_filename(Some(&output)); + browser.preview_details.set_text(&details); + *browser.preview_file.borrow_mut() = Some(output); + browser.preview_stack.set_visible_child_name("image"); + } else { + let _ = fs::remove_file(output); + set_preview_message( + &browser, + "Image preview failed. The format may be unsupported, or the sandboxed thumbnailer may be unavailable.", + ); + } + glib::ControlFlow::Break + }); + } + Err(error) => set_preview_message( + browser, + &format!("Could not start the sandboxed image preview: {error}"), + ), + } +} + +fn open_file(path: &Path) -> Result<(), String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let text_extensions = [ + "txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh", + "bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts", + "xml", "csv", "lock", "desktop", "service", "lua", "nix", + ]; + let image_extensions = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif", + ]; + + let mut command = if text_extensions.contains(&extension.as_str()) { + let mut command = Command::new("foot"); + command.args(["-e", "nvim", "--"]).arg(path); + command + } else if image_extensions.contains(&extension.as_str()) { + let mut command = Command::new("imv"); + command.arg(path); + command + } else if fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + { + let mut command = Command::new("foot"); + command.arg("-e").arg(path); + command + } else { + let uri = gio::File::for_path(path).uri(); + return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>) + .map_err(|error| error.to_string()); + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not start the requested application: {error}")) +} + +fn reload(browser: &Browser) { + clear_preview(browser); + while let Some(row) = browser.list.first_child() { + browser.list.remove(&row); + } + let path = browser.location.borrow().clone(); + browser.location_entry.set_text(&path.display().to_string()); + let entries = match fs::read_dir(&path) { + Ok(items) => items, + Err(error) => { + browser + .status + .set_text(&format!("Cannot read {}: {error}", path.display())); + return; + } + }; + let mut rows = Vec::new(); + for item in entries.flatten() { + let path = item.path(); + let name = item.file_name().to_string_lossy().into_owned(); + if !*browser.show_hidden.borrow() && name.starts_with('.') { + continue; + } + let Ok(meta) = item.metadata() else { continue }; + let directory = meta.is_dir(); + let modified = meta + .modified() + .ok() + .map(format_time) + .unwrap_or_else(|| "—".into()); + rows.push(Entry { + path, + name, + directory, + size: meta.len(), + modified, + }); + } + rows.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in rows { + let row = make_row(&entry); + row.set_selectable(true); + row.set_activatable(true); + row.set_tooltip_text(Some(&entry.path.to_string_lossy())); + browser.list.append(&row); + } + let count = browser.list.observe_children().n_items(); + browser + .status + .set_text(&format!("{count} items · {}", path.display())); + apply_filter(browser); +} + +fn make_row(entry: &Entry) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let line = gtk::Box::new(gtk::Orientation::Horizontal, 8); + line.set_margin_start(8); + line.set_margin_end(8); + line.set_margin_top(5); + line.set_margin_bottom(5); + let name = gtk::Label::new(None); + let display_name: String = entry + .name + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect(); + name.set_text(&if entry.directory { + format!("{display_name}/") + } else { + display_name + }); + name.set_xalign(0.0); + name.set_wrap(true); + name.set_wrap_mode(gtk::pango::WrapMode::WordChar); + name.set_valign(gtk::Align::Center); + name.set_hexpand(true); + name.add_css_class(if entry.directory { + "directory" + } else { + "filename" + }); + let size_text = if entry.directory { + "Folder".to_string() + } else { + format_size(entry.size) + }; + let size = metadata_value(&size_text, 72); + let modified = metadata_value(&entry.modified, 126); + line.append(&name); + line.append(&size); + line.append(&modified); + row.set_child(Some(&line)); + row +} + +fn heading(text: &str, expand: bool) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + if expand { + label.set_hexpand(true); + } else { + label.set_size_request(if text == "Size" { 72 } else { 126 }, -1); + } + label.add_css_class("column-heading"); + label +} + +fn metadata_value(text: &str, width: i32) -> gtk::Label { + let value = gtk::Label::new(Some(text)); + value.set_size_request(width, 24); + value.set_xalign(0.0); + value.set_valign(gtk::Align::Center); + value.set_ellipsize(gtk::pango::EllipsizeMode::End); + value.add_css_class("metadata"); + value +} + +fn tool_button(label: &str, tip: &str) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.set_tooltip_text(Some(tip)); + button.set_size_request(34, 34); + button.add_css_class("tool-button"); + button +} + +fn apply_filter(browser: &Browser) { + let needle = browser.filter.text().to_lowercase(); + let mut child = browser.list.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if let Ok(row) = widget.clone().downcast::() { + let name = row + .child() + .and_then(|line| line.first_child()) + .and_then(|label| label.downcast::().ok()) + .map(|label| label.text().to_string().to_lowercase()) + .unwrap_or_default(); + row.set_visible(name.contains(&needle)); + } + } +} + +fn format_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_time(time: std::time::SystemTime) -> String { + // Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable. + match time.elapsed() { + Ok(age) if age.as_secs() < 60 => "Just now".into(), + Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60), + Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600), + Ok(age) => format!("{} days ago", age.as_secs() / 86400), + Err(_) => "Just now".into(), + } +} + +fn install_style(config: &AppConfig) { + let provider = gtk::CssProvider::new(); + let css = r#" + * { font-family: "Maple Mono", "JetBrainsMono Nerd Font", monospace; font-size: 10pt; } + window, .root { background: @background@; color: @text@; } + .toolbar { background: @panel@; padding: 8px 10px; } + entry, searchentry { color: @text@; background: #202020; border: 1px solid @border@; border-radius: 2px; padding: 7px 9px; min-height: 18px; } + entry:focus, searchentry:focus { border-color: @accent@; } + .tool-button { color: @text@; background: #202020; border: 1px solid @border@; border-radius: 2px; padding: 0; min-width: 34px; min-height: 34px; } + .back-button { min-width: 34px; min-height: 34px; } + .tool-button:hover { color: @text@; background: #303030; border-color: @accent@; } + .tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: 1px solid #6C99BB; outline-offset: -2px; } + .sidebar { background: @panel@; border-top: 2px solid @border@; border-right: 1px solid @border@; padding: 8px 6px; min-width: @sidebar_width@px; max-width: @sidebar_width@px; } + .place { color: @text@; background: transparent; border: 1px solid transparent; border-radius: 2px; padding: 7px 9px; min-height: 20px; } + .place:hover { color: @text@; background: #202020; border-color: @border@; } + .place:active { background: #303030; } + .headings { background: @panel@; border-top: 2px solid @border@; border-bottom: 1px solid @border@; padding: 9px 8px; } + .preview-pane { background: @panel@; border-left: 1px solid @border@; padding: 12px; min-width: 320px; max-width: 320px; } + .preview-heading { color: @text@; font-size: 11pt; } + .preview-title { color: @text@; font-size: 10pt; padding: 2px 0; } + .preview-details { color: @muted@; font-size: 9pt; padding: 2px 0; } + .preview-message { color: @muted@; padding: 12px; } + .preview-text { background: @background@; color: @text@; padding: 10px; } + .preview-picture { background: @background@; } + .workspace-split > separator { background: @border@; min-width: 1px; } + .column-heading { color: @muted@; font-size: 9pt; } + .file-list { background: @background@; } + .file-list row { border-bottom: 1px solid @border@; min-height: 36px; } + .file-list row:hover { background: #202020; } + .file-list row:selected { background: #303030; outline: 1px solid @accent@; outline-offset: -1px; } + .directory { color: @accent@; } + .filename { color: @text@; } + .metadata, .status { color: @muted@; font-size: 10pt; } + .metadata { padding: 3px 0 5px; } + scrolledwindow { border: 0; } + scrolledwindow overshoot { opacity: 0; background: transparent; border: none; box-shadow: none; } + scrollbar { background: transparent; border: none; box-shadow: none; } + scrollbar.vertical { min-width: 6px; } + scrollbar.horizontal { min-height: 6px; } + scrollbar > range > trough { background: transparent; border: none; box-shadow: none; } + scrollbar > range > trough > slider { background: @accent@; min-width: 4px; min-height: 4px; border: none; border-radius: 0; outline-style: none; outline-width: 0; box-shadow: none; } + scrollbar > range > trough > slider:hover, + scrollbar > range > trough > slider:active, + scrollbar > range > trough > slider:focus { background: @accent@; border: none; outline-style: none; outline-width: 0; box-shadow: none; } + scrollbar button { min-width: 0; min-height: 0; padding: 0; border: 0; } + "#; + let css = css + .replace("@background@", &config.colors.background) + .replace("@panel@", &config.colors.panel) + .replace("@text@", &config.colors.text) + .replace("@muted@", &config.colors.muted) + .replace("@accent@", &config.colors.accent) + .replace("@border@", &config.colors.border) + .replace("@sidebar_width@", &config.sidebar_width.to_string()); + provider.load_from_data(&css); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +}