Initial Wayfinder release

This commit is contained in:
wfalkowski committed 2026-10-18 14:25:13 +00:00
commit e8513b943a
16 files changed
+8234

No files matched your search

Generated
+671
View File
@@ -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",
]
+10
View File
@@ -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" }
+50
View File
@@ -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.
+31
View File
@@ -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
+80
View File
@@ -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
+10
View File
@@ -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
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
+1764
View File
File diff suppressed because it is too large. Load diff
+498
View File
@@ -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<RefCell<PathBuf>>,
show_hidden: Rc<RefCell<bool>>,
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: &gtk::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::<gtk::Button>() {
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::<gtk::ListBoxRow>() {
let name = row
.child()
.and_then(|line| line.first_child())
.and_then(|label| label.downcast::<gtk::Label>().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,
);
}
}
+929
View File
@@ -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<RefCell<PathBuf>>,
show_hidden: Rc<RefCell<bool>>,
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<Cell<u64>>,
preview_job: Rc<RefCell<Option<PreviewJob>>>,
preview_file: Rc<RefCell<Option<PathBuf>>>,
}
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: &gtk::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::<gtk::Label>().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::<gtk::Button>() {
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::<gtk::ListBoxRow>() {
let name = row
.child()
.and_then(|line| line.first_child())
.and_then(|label| label.downcast::<gtk::Label>().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,
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff