From 844bb22314da7fb94745c5b4469837bd751e0184 Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Tue, 28 Jul 2026 00:03:12 -0400 Subject: [PATCH] runar is a keyboard-first, statically-linkable Linux file manager written from scratch in pure Rust. --- BLOG.md | 85 + Cargo.lock | 4909 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 52 + LICENSE | 338 +++ QUICKSTART.md | 189 ++ README.md | 299 +++ runar-ss.png | Bin 0 -> 102901 bytes src/config/actions.rs | 313 +++ src/config/bookmarks.rs | 223 ++ src/config/defaults.rs | 163 ++ src/config/mod.rs | 162 ++ src/date.rs | 181 ++ src/icons.rs | 213 ++ src/launch.rs | 344 +++ src/main.rs | 99 + src/mime.rs | 401 ++++ src/mounts.rs | 305 +++ src/ui/about.rs | 116 + src/ui/context_menu.rs | 298 +++ src/ui/grid.rs | 202 ++ src/ui/menubar.rs | 441 ++++ src/ui/mod.rs | 1149 +++++++++ src/ui/pathbar.rs | 98 + src/ui/sidebar.rs | 279 +++ src/vfs/appdir.rs | 122 + src/vfs/mod.rs | 244 ++ src/vfs/watcher.rs | 94 + 27 files changed, 11319 insertions(+) create mode 100644 BLOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 runar-ss.png create mode 100644 src/config/actions.rs create mode 100644 src/config/bookmarks.rs create mode 100644 src/config/defaults.rs create mode 100644 src/config/mod.rs create mode 100644 src/date.rs create mode 100644 src/icons.rs create mode 100644 src/launch.rs create mode 100644 src/main.rs create mode 100644 src/mime.rs create mode 100644 src/mounts.rs create mode 100644 src/ui/about.rs create mode 100644 src/ui/context_menu.rs create mode 100644 src/ui/grid.rs create mode 100644 src/ui/menubar.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/pathbar.rs create mode 100644 src/ui/sidebar.rs create mode 100644 src/vfs/appdir.rs create mode 100644 src/vfs/mod.rs create mode 100644 src/vfs/watcher.rs diff --git a/BLOG.md b/BLOG.md new file mode 100644 index 0000000..6951fad --- /dev/null +++ b/BLOG.md @@ -0,0 +1,85 @@ +# Building runar: a hybrid file manager for the post-DE world + +I'm Jeremy Anderson, and I run a small corner of the web at dcos.net where I write about Linux desktop plumbing, Rust GUI experiments, and the kind of low-level tinker projects that don't fit anywhere else. This is the story of runar, a file manager I've been building since early 2026. The state of Linux file managers in 2026 is, depending on your perspective, either healthy or strange. Nautilus has spent the last decade shedding features until it's barely a file manager anymore — it's a GNOME Shell extension with a file viewer attached, and if your workflow doesn't match the GNOME workflow, Nautilus will quietly refuse to accommodate you. Thunar is still solid, still my daily driver on Xfce, but it's Xfce-bound in ways that go deeper than the toolkit: the assumptions about volume management, the D-Bus dependencies, the libxfce4ui coupling. PCManFM is showing its age — the GTK2/3 split, the lingering 32-bit assumptions, the maintainer burnout that's visible in the commit log. Nemo is fine, but it's Cinnamon-bound, and dragging Cinnamon's dependencies onto a non-Cinnamon system feels wrong. + +What I noticed, somewhere around the third time I installed a fresh Arch or Alpine system and sighed at the file-manager situation, is that the lightweight-power-user niche is wide open. There's Nautilus for GNOME people, Dolphin for KDE people, Thunar for Xfce people, Nemo for Cinnamon people, and then... a long tail of abandoned GTK2 projects and GTK3 ports that never quite finished. Nobody is building a file manager for the person who knows what `/proc/mounts` is, who wants a static binary they can drop on a USB stick, who doesn't want to inherit a desktop environment's worth of D-Bus daemons just to browse their files. So I started building one. + +## The hybrid philosophy + +runar is not a from-scratch reinvention. It's a deliberate hybrid, and the genealogy matters because each of its ancestors solved a real problem that the modern DE file managers have papered over. From Thunar I took the dual-pane/tree-and-grid layout, the breadcrumb pathbar that gets out of your way, and the keyboard-first navigation model where you never have to touch the mouse to move through a directory. Thunar's layout is, in my opinion, the high-water mark of file-manager UX — clean, discoverable, and unobtrusive. I wanted runar to feel like that within five seconds of launching it. From PCManFM I took the lightweight GTK ethos, which is to say the *spirit* of minimal-footprint, fast-boot, no-surprises file management. We kept the spirit and swapped the toolkit, but the design priorities are straight out of the PCManFM playbook: don't block the UI thread, don't allocate gratuitously, don't reach for a D-Bus daemon when a file will do. + +From ROX-Filer I took the two ideas that genuinely matter. The first is the AppDir paradigm: a directory containing an executable `AppRun` script (or a file matching the directory's name) is treated as an application bundle. Double-click launches it. Shift+Double-Click enters it as a directory. No `.desktop` file, no `xdg-mime` query, no installation step, no package manager. AppDirs pre-date Flatpak by a decade and remain the simplest self-contained app packaging format on Linux. The second is the instant MIME action hook: instead of `.desktop` files and `xdg-mime` queries, ROX let you wire file types to shell commands in a small config file. runar does this with a TOML file. From Puppy Linux and DSL I took the broader lesson that ROX-Filer was, for those distributions, the desktop backbone — the file manager *was* the desktop, and AppDirs were the application format. That's a design posture I find genuinely inspiring. And from SliTaz I took the minimal-footprint, fast-boot, read-only-media philosophy: a file manager should run happily from a squashfs image on a CD-R, or from a USB stick with a read-only overlay, without complaint. + +The key insight across all of these is that they solved real problems — packaging, MIME dispatch, removable media, fast boot — that GNOME Files and Dolphin have since buried under abstraction layers. runar keeps the solutions and drops the abstraction. The AppDir is still a better packaging format than a Flatpak for a single-file application. The TOML action hook is still a faster MIME dispatcher than the `xdg-mime` / `mimeapps.list` / `.desktop` stack. The `/proc/mounts` poller is still a more transparent volume monitor than `gio::VolumeMonitor`. None of these ideas are mine. I'm just the one putting them back together in Rust. + +## The clean sidebar canvas + +The hardest design call in runar was deciding what *not* to put in the sidebar. Modern file managers shove XDG user directories at you the moment you open the window: Downloads, Documents, Pictures, Music, Videos. Then they add "Recent", "Starred", "Other Locations", "Trash", "Network". Then they add bookmarks you didn't ask for. By the time you finish scrolling the sidebar on a 13-inch laptop, you've forgotten what you came for, and the actual file view is off-screen below the fold. This is the UX failure mode of every DE-integrated file manager: the sidebar is treated as a discoverability surface for features the desktop team wants to surface, not as a navigation aid for the user's actual workflow. + +runar's sidebar has three sections and nothing else. **DEVICES** is what's actually mounted, polled from `/proc/mounts` with pseudo-filesystems filtered out — you see your real filesystems, your USB sticks, your NFS mounts, and nothing else. **LOCATIONS** is a small fixed set of power-user paths: `/mnt`, `/var/run/media`, `/opt`, `/usr/src`, `$HOME`, and `$HOME/Downloads`. **BOOKMARKS** is purely user-added — empty on first run, no defaults injected, no "we thought you'd want these" presets. That's the whole sidebar. No "Recent files" because your shell history is faster. No "Starred" because that's what bookmarks are for. No "Other Locations" because that's what the pathbar is for. The sidebar fits on screen without scrolling on a 13-inch laptop, and on a 24-inch monitor it occupies maybe a quarter of the left edge. + +The hardcoded LOCATIONS list is opinionated, and I'll defend every entry. `/mnt` and `/var/run/media` because that's where USB sticks and removable media show up on systemd systems — `/media` is legacy, `/run/media/$USER` is the udisks2 convention, and `/var/run/media` is the symlink target that shows up in `/proc/mounts`. `/opt` because that's where vendor packages live, and if you're the kind of person who installs vendor packages you want one click to get there. `/usr/src` because that's where kernel hackers live, and kernel hackers are part of runar's audience. `$HOME` and `$HOME/Downloads` because every workflow needs a scratch pad, and the browser still defaults to Downloads. If you don't like the list, the source is right there — fork it, change six lines, rebuild. The point of a hardcoded list isn't to impose my taste; it's to keep the sidebar short enough to be useful, which no configurable sidebar ever is. + +## The GTK4-to-iced pivot + +The original project manifest specified `gtk4-rs`, and for the first week of prototyping that's what I used. After a week I switched to iced, and I want to explain why because I think it's the most contested decision in the whole project and I want to be fair to both sides. The promise of `gtk4-rs` is real: native X11 and Wayland rendering, native icon theme lookup, standard XDS drag-and-drop, hardware-accelerated grid views, accessibility via AT-SPI, and the full weight of GTK's 20-year polish in rendering and input handling. All of that is true, and none of it is trivial. If you're building a file manager that has to feel native on a Fedora GNOME desktop, `gtk4-rs` is the right answer. + +The cost is what killed it for me. At build time you need `libgtk-4-dev`, `libglib2.0-dev`, `libgraphene-1.0-dev`, and a pile of headers. At runtime you depend on a system icon theme being installed — hicolor, Adwaita, Papirus, something — because GTK's icon lookup goes through the theme, and if the user is on a minimal Alpine install with no theme, your file manager looks broken out of the box. The shared library footprint is around 30 MB that has to be present at runtime. And then there's the Rust ergonomics problem: GLib's object system transposed into Rust via `glib::Object`, `glib::MainContext`, signal connections, raw pointer lifetimes. It's workable. The `gtk4-rs` maintainers have done heroic work. But it's not idiomatic Rust, and it's not the Elm-style functional update model that makes GUI code legible. Static linking is technically possible with `gtk4` static libs but practically a nightmare — you end up rebuilding GTK itself, and the resulting binary still wants a theme at runtime. + +The iced trade-off is the mirror image. iced is pure Rust — `cargo build` just works on any platform with a Rust toolchain, no system packages required. The release profile (`lto=fat`, `codegen-units=1`, `strip=symbols`, `panic=abort`) produces a 5 to 8 MB binary with zero shared library dependencies, and I mean zero: `ldd runar` returns "not a dynamic executable." Icons are inline SVG, baked into the binary, so there's no system theme dependency — runar looks the same on Ubuntu and on a from-scratch Alpine install, which is exactly the property I wanted. The architecture is async-native Elm: `State`, `update(Message) -> Task`, `view(&State) -> Element`, `subscription(&State) -> Subscription`. Clean separation, no signal spaghetti, no `glib::clone!` macro. The costs are real too: no native cross-process DND (XDS) — in-app drag-and-drop works, but you can't drag a file from runar to GIMP. No `gio::VolumeMonitor`, which I replaced with a 60-line `/proc/mounts` poller. And iced is immature at file-manager scale — untested at Nautilus traffic levels, and I won't pretend otherwise. + +For runar's use case — a power-user file manager for people who already know what `/proc/mounts` is — the static binary wins. The cross-process DND gap is real but recoverable; a winit hook or a future iced release could close it. The icon theme gap is, for my audience, a feature rather than a bug: runar looks the same everywhere, and "looks the same everywhere" is a property I will trade a lot of polish for. And the Rust ergonomics argument alone is almost enough. Compare the same key-press handler in both toolkits. In `gtk4-rs` you create an `EventControllerKey`, `connect_key_pressed` with a closure that clones the app handle, returns a `glib::Propagation`, and you have to remember to add the controller to the widget. In iced it's a pure function from `(Key, Modifiers)` to `Option`, and the runtime handles dispatch. Same functionality, half the code, no clone macro, no propagation enum to remember. After a week of writing the gtk4 version, I rewrote it in iced in two days and the code was shorter and clearer. That settled it. + +## Architecture decisions + +### Non-blocking VFS (Phase 1) + +The single most important architectural decision in runar is that `scan_directory` runs entirely on the tokio runtime and never touches the UI thread. When you open a directory, runar spawns a scan task that walks the directory with `tokio::fs::read_dir`, stats each entry, and streams `ScanEvent::Entry` events back to the UI over an `mpsc::unbounded_channel`. The UI thread processes these events incrementally and adds each entry to the visible list as it arrives. On a fast local filesystem this looks instantaneous. On a slow NFS mount with 10,000+ files, the first entries appear within milliseconds and the rest trickle in as they're stat'd, and at no point does the UI freeze. + +This is the design lesson from every file manager that has ever frozen on a stuck network share: don't do I/O on the UI thread, ever, even for "quick" metadata calls. The temptation is strong because a single `stat` call feels cheap, and on a local filesystem it is. On NFS it's a round trip. On a dead NFS mount it's a 60-second timeout, and if you're on the UI thread when it fires, your window manager will offer to force-quit you. runar's rule is that the UI thread does no I/O at all — not directory reads, not stats, not MIME lookups, not icon loads. Everything goes through a channel. This is more code than the synchronous version, but it's the difference between a file manager that feels fast and one that feels broken. + +### /proc/mounts instead of gio::VolumeMonitor + +`gio::VolumeMonitor` is the standard answer for "what's mounted on this system" in the GLib world. It's a D-Bus-speaking wrapper around udev and udisks2, it handles hotplug events, it knows about optical drives and MTP devices and GVFS mounts. If you're already in GLib-land, it's the right answer. We're not in GLib-land. runar polls `/proc/mounts` directly every 5 seconds, parses it with a 60-line parser, filters out pseudo-filesystems (proc, sysfs, tmpfs, cgroup, devtmpfs, and friends), and tags network mounts (nfs, cifs, sshfs) for separate icon treatment in the sidebar. + +The trade-off is that we don't get hotplug events — there's up to a 5-second delay between a USB stick being mounted and it appearing in the sidebar. For a power-user file manager, that's fine. The upside is zero D-Bus dependency, zero udev dependency, zero udisks2 dependency, and a volume monitor that works identically on systemd systems, on OpenRC systems, on a BusyBox initramfs, and on a container where D-Bus isn't even running. `/proc/mounts` is the kernel's own view of what's mounted where, and the kernel is the one source of truth that's always present. When you strip away the abstractions, this is what's underneath them, and it turns out the underneath is plenty for a file manager. + +### Inline SVG icons + +The standard way to resolve icons on Linux is the `freedesktop-icons` crate, which walks the system icon theme (hicolor, Papirus, Breeze, etc.) at runtime and returns a path to an SVG or PNG. This works great on a desktop Linux system with a full icon theme installed. It breaks on a from-scratch Alpine install with no theme. It breaks in a container. It breaks on a minimal embedded system. And it imposes a runtime dependency on a package you can't ship — your binary is correct, but it looks wrong because the theme isn't there. + +runar ships a tiny inline SVG glyph set baked into the binary: folder, file, appdir, symlink, device, network, bookmark, up, edit, reload. Ten icons, about 3 KB of SVG markup, zero file I/O at runtime. The icons are rendered by iced's vector backend and scaled to whatever size the view requests. They're not pretty — they're functional, monochrome, and deliberately spare. If you want Papirus, fork the project and replace `src/icons.rs`. The point isn't to win a beauty contest; the point is that runar looks the same on every system it runs on, and "looks the same on every system" is a property that matters more than "looks native on this specific system" for the audience I'm building for. + +### AppDir detection + +ROX-Filer's killer feature, the one thing every ROX user remembers, is the AppDir. A directory containing an executable `AppRun` script — or a file with the same name as the directory — is treated as an application bundle. You double-click it and it launches. You Shift+Double-Click it and you enter the directory like any other. No `.desktop` file, no `xdg-mime` query, no installation step, no package manager, no root. The application is the directory; the directory is the application. This pre-dates Flatpak by a decade and macOS app bundles by several years, and it remains the simplest self-contained app packaging format on Linux. + +runar implements AppDir detection in `src/vfs/appdir.rs`, which is 60 lines of Rust. During the directory scan, each subdirectory is probed for an `AppRun` executable or a same-named launcher file. On a match, the entry is tagged `EntryKind::AppDir { launcher }`, the icon flips to the appdir glyph, and double-click dispatches to the launch handler in `src/launch.rs`, which spawns the launcher directly. Shift+Double-Click falls through to the normal "enter directory" behavior. The whole feature is small enough to hold in your head, which is the point — packaging formats that grow beyond what you can hold in your head become Flatpak, and Flatpak is fine but it's not what runar is for. + +### Instant MIME action hooks + +runar's MIME dispatch is a TOML file at `~/.config/runar/actions.toml`. It maps file extensions and MIME globs to shell commands. An entry looks like this: + +```toml +[[actions]] +pattern = "image/*" +command = "feh {}" +``` + +The `{}` is substituted with the shell-quoted file path. Commands run via `sh -c`, so pipes work, environment variables work, command substitution works, and you can compose with any tool on your `$PATH`. The launch dispatcher consults this file first, on every activation, and falls back to `xdg-open` (via the `open` crate) only if no pattern matches. + +What's missing here is the entire `.desktop` / `xdg-mime` / `mimeapps.list` stack. No `.desktop` files. No `xdg-mime` queries. No `mimeapps.list` precedence rules. No desktop-environment daemon. Just a TOML file and a `sh -c` call. This is the ROX-Filer philosophy distilled to its essence: the user knows what they want to happen when they double-click a file, and the file manager's job is to do that thing, not to consult a committee of specifications first. The cost is that you have to write the TOML file yourself the first time — there's no "detect installed applications and build the list for you" magic. For runar's audience, that's a feature: you write the three lines you actually want, and the file manager does exactly that, forever, without surprises. + +## What's next + +Phase 3 of the original manifest is only partially done. The launch dispatcher is in and works — AppDirs launch, MIME actions fire, `xdg-open` is the fallback. What's not in yet is background file operations: copy, move, delete with progress toasts, cancellation, and error surfacing. That's the next big chunk of work, and it's mostly a UI problem rather than a systems problem. The async plumbing is already there — tokio is running, the channels work, the scan loop proves the pattern. What's missing is a progress-bar widget, a cancellation token threaded through the operation, and an error toast that surfaces failures without blocking the rest of the UI. None of that is research; it's just work. + +After file operations, the next item is a true multi-column icon grid. The current view is a single-column virtualized list, which scales to 10,000+ entries without breaking a sweat but doesn't feel like a file manager to anyone who grew up with icon views. A virtualized grid is harder than a virtualized list — the layout math is nastier, the selection model has to handle two-dimensional ranges, the keyboard navigation has to decide what "down" means — but iced's `grid` widget is mature enough now to build on. After that: cross-process DND via a winit hook, since the iced maintainers have signaled they're open to XDS support but it's not on their near-term roadmap. And thumbnail rendering, which is almost free given that `rayon` and the `image` crate are already in the dependency tree — wire them up behind a cache, render on a worker pool, ship the bytes to the UI as they're ready. + +The long-term vision is the part I'm most serious about and least able to prove. I want runar to be the default file manager for a from-scratch Linux desktop. Not a GNOME replacement, not a KDE replacement, not "yet another lightweight DE" — a foundation for people who want to build their own desktop without inheriting 20 years of XDG specification baggage. The desktop-as-file-manager posture that Puppy and DSL pioneered with ROX-Filer is still viable, and Rust's static-binary story makes it more viable than it was in 2005. If you want a desktop where the file manager is the shell and AppDirs are the application format and `/proc/mounts` is the volume manager, runar is the file manager half of that. The other half — window management, panel, launcher — is someone else's project, but I'd love to talk to whoever's building it. + +## Closing + +If any of this resonates, the code is at https://dcos.net/runar under GPL-2.0-only, the same license as Thunar, because the license is part of the genealogy and I want the genealogy to be honest. Patches are welcome, and the architecture is small enough to hold in your head — about 2,000 lines of Rust across 13 files. The largest file is `src/vfs.rs` at maybe 350 lines, and most of the rest are under 200. If you've ever wanted to hack on a file manager but bounced off Nautilus's 80,000-line codebase, or off Dolphin's plugin architecture, or off Thunar's Xfce coupling, this is your entry point. The code is readable, the build is one `cargo build --release` away, and the static binary that comes out runs on any Linux system with a kernel and a display. That's the whole pitch. Come hack on it. + +— Jeremy Anderson, 2026 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3a03461 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4909 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.19", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.19", + "x11rb", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cosmic-text" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" +dependencies = [ + "bitflags 2.13.1", + "fontdb 0.16.2", + "log", + "rangemap", + "rayon", + "rustc-hash 1.1.0", + "rustybuzz", + "self_cell", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" +dependencies = [ + "bitflags 2.13.1", + "libloading 0.8.9", + "winapi", +] + +[[package]] +name = "dark-light" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" +dependencies = [ + "dconf_rs", + "detect-desktop-environment", + "dirs", + "objc", + "rust-ini", + "web-sys", + "winreg", + "zbus", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dconf_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + +[[package]] +name = "detect-desktop-environment" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "font-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e32eac81c1135c1df01d4e6d4233c47ba11f6a6d07f33e0bba09d18797077770" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.21.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.14.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.13.1", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "iced" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88acfabc84ec077eaf9ede3457ffa3a104626d79022a9bf7f296093b1d60c73f" +dependencies = [ + "iced_core", + "iced_futures", + "iced_renderer", + "iced_widget", + "iced_winit", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_core" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0013a238275494641bf8f1732a23a808196540dc67b22ff97099c044ae4c8a1c" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "dark-light", + "glam", + "log", + "num-traits", + "once_cell", + "palette", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "iced_futures" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c04a6745ba2e80f32cf01e034fd00d853aa4f4cd8b91888099cb7aaee0d5d7c" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasm-timer", +] + +[[package]] +name = "iced_glyphon" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c3bb56f1820ca252bc1d0994ece33d233a55657c0c263ea7cb16895adbde82" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu", +] + +[[package]] +name = "iced_graphics" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba25a18cfa6d5cc160aca7e1b34f73ccdff21680fa8702168c09739767b6c66f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "once_cell", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_renderer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73558208059f9e622df2bf434e044ee2f838ce75201a023cf0ca3e1244f46c2a" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_runtime" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348b5b2c61c934d88ca3b0ed1ed913291e923d086a66fa288ce9669da9ef62b5" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c625d368284fcc43b0b36b176f76eff1abebe7959dd58bd8ce6897d641962a50" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_graphics", + "kurbo 0.10.4", + "log", + "resvg", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15708887133671d2bcc6c1d01d1f176f43a64d6cdc3b2bf893396c3ee498295f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "futures", + "glam", + "guillotiere", + "iced_glyphon", + "iced_graphics", + "log", + "once_cell", + "resvg", + "rustc-hash 2.1.3", + "thiserror 1.0.69", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81429e1b950b0e4bca65be4c4278fea6678ea782030a411778f26fa9f8983e1d" +dependencies = [ + "iced_renderer", + "iced_runtime", + "num-traits", + "once_cell", + "rustc-hash 2.1.3", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44cd4e1c594b6334f409282937bf972ba14d31fedf03c23aa595d982a2fda28" +dependencies = [ + "iced_futures", + "iced_graphics", + "iced_runtime", + "log", + "rustc-hash 2.1.3", + "thiserror 1.0.69", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "winapi", + "window_clipboard", + "winit", +] + +[[package]] +name = "imagesize" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "naga" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" +dependencies = [ + "bit-set", + "bitflags 2.13.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", + "memoffset", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "palette_derive", + "phf", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944d052815156ac8fa77eaac055220e95ba0b01fa8887108ca710c03805d9051" +dependencies = [ + "gif", + "jpeg-decoder", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "runar" +version = "0.1.0" +dependencies = [ + "env_logger", + "iced", + "log", + "mime_guess", + "notify", + "open", + "rayon", + "serde", + "tokio", + "toml", + "walkdir", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[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.3", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.19", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "drm", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading 0.8.9", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio 1.2.2", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "usvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb 0.18.0", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases 0.1.1", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases 0.1.1", + "codespan-reporting", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" +dependencies = [ + "bitflags 2.13.1", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window_clipboard" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d692d46038c433f9daee7ad8757e002a4248c20b0a3fbc991d99521d3bcb6d" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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 = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.2", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yazi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zeno" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..881742a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "runar" +version = "0.1.0" +edition = "2021" +description = "Hybrid Thunar/PCManFM + ROX-Filer file manager built with iced. Pure-Rust, static binary." +authors = ["Jeremy Anderson "] +license = "GPL-2.0-only" +repository = "https://dcos.net/runar" +homepage = "https://dcos.net/runar" +readme = "README.md" +keywords = ["file-manager", "iced", "gtk-alternative", "linux", "appdir"] +categories = ["filesystem", "gui"] + +[dependencies] +# UI Shell — pure Rust, no system lib deps, true static binary +iced = { version = "0.13", features = ["tokio", "svg", "advanced"] } + +# Async runtime +tokio = { version = "1.38", features = ["fs", "rt-multi-thread", "sync", "process", "macros", "io-util"] } +notify = "6.1" + +# MIME +mime_guess = "2.0" + +# Config (TOML) +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" + +# Fast utility +rayon = "1.10" +walkdir = "2.5" + +# Launch fallback (xdg-open equivalent, pure Rust) +open = "5.3" + +# Logging (minimal) +log = "0.4" +env_logger = "0.11" + +[[bin]] +name = "runar" +path = "src/main.rs" + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +strip = "symbols" +panic = "abort" + +[profile.dev] +opt-level = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..99706a8 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,189 @@ +# runar Quickstart + +A focused guide to building, launching, and customizing **runar** — a pure-Rust file manager built with iced 0.13. In under ten minutes you will build the binary, tour the interface, navigate by keyboard, wire up a file association, and produce a release build you can install system-wide. + +## 1. Prerequisites + +You need Rust 1.70 or later on the stable channel. If you do not already have it, install it from [https://rustup.rs](https://rustup.rs). runar targets Linux on any modern distribution — Ubuntu, Fedora, Arch, Alpine, and others all work — and requires a working X11 or Wayland session to display its window. + +runar is pure Rust with no system library dependencies. On Debian and Ubuntu you do **not** need `libgtk-4-dev` or any other `-dev` packages; nothing pulls in a C toolchain beyond what cargo already uses. On Alpine, musl builds work out of the box with no extra setup, which makes runar a natural fit for that distribution. + +## 2. Build & Run + +Clone the repository and launch a release build in three commands: + +```sh +git clone https://dcos.net/runar.git +cd runar +cargo run --release +``` + +The first build takes roughly three to five minutes because the release profile uses fat LTO and a single codegen unit to squeeze out runtime performance. Subsequent builds finish in under thirty seconds thanks to incremental compilation, so the long initial wait is a one-time cost. + +If you are iterating on code or just want to start quickly, `cargo run` (a debug build) compiles much faster but runs slower and produces a larger, unstripped binary. For day-to-day use, prefer `--release`. + +You can open runar at a specific directory by passing it as an argument: + +```sh +cargo run --release -- /etc +``` + +This is handy for poking around system configuration trees without navigating from your home directory. + +## 3. First Launch Tour + +When the window opens you are presented with four regions. The **top bar** shows a breadcrumb of your current location, for example `/ > etc`. Click any segment to jump up to that ancestor directory. Click the pencil icon — or press `/` — to switch the pathbar into edit mode and type a path manually. Press Enter to navigate, or Escape to return to breadcrumb mode without changing location. + +The **left sidebar** is divided into three sections. **DEVICES** lists mounts polled from `/proc/mounts`, filtered to show only real storage: pseudo-filesystems such as `proc`, `sysfs`, and `tmpfs` are excluded. Network mounts (`nfs`, `cifs`, `sshfs`) are shown with a purple globe icon so you can spot them at a glance. **LOCATIONS** holds hardcoded defaults that are always present and not removable: `/mnt`, `/var/run/media`, `/opt`, `/usr/src`, your `$HOME`, and `$HOME/Downloads`. **BOOKMARKS** starts empty; you populate it by editing `~/.config/runar/bookmarks.toml`, which is covered later in this guide. + +The **main grid** is the file list with columns for icon, name, size, modified date, and type. Hidden files (dotfiles) appear in muted gray so they stay visible without dominating the view. AppDirs — directories that contain an `AppRun` file or an executable with the same name as the directory — get a teal icon with a play arrow, marking them as launchable applications. + +The **status bar** at the bottom shows item count and the current path. It also surfaces launch results and watch errors, so if an action fails or an inotify watch drops, you will see it there rather than having to check a separate log. + +## 4. Keyboard Navigation + +runar is keyboard-first. The full binding table: + +| Key | Action | +|-----|--------| +| Arrow Up / `K` | move selection up | +| Arrow Down / `J` | move selection down | +| `H` / Backspace | go up one directory | +| `L` / Enter | activate selection (open / launch / navigate) | +| Shift+Enter | on an AppDir, enter it as a directory | +| `/` or Ctrl+L | toggle pathbar edit mode | +| Escape | exit pathbar edit mode | + +Try it now. Open `/usr/bin`, hold `J` to scroll down the long listing, press `L` to launch a binary such as `htop` or `less`, then press `H` to climb back up to `/usr`. The vim-style `H`/`J`/`K`/`L` bindings make one-handed navigation natural once you are used to them, and the arrow keys remain available for anyone who prefers them. + +## 5. Opening Files (Built-in MIME Defaults) + +Double-click any file (or select it and press Enter) and runar launches it through a three-layer fallback: + +1. **Your `actions.toml` overrides** (covered in the next section) — highest priority. +2. **Built-in default MIME table** — sensible power-user defaults for common types, no config required. +3. **`xdg-open`** — final fallback that defers to your system's defaults. + +The built-in table honors standard environment variables so it works the way you expect on any system: + +| MIME category | Default action | +|---|---| +| `text/*` (plain, markdown, csv, source code) | `$EDITOR` if set, else walks: `scitano` → `scite` → `geany` → `nano` | +| `text/html` | `${BROWSER:-xdg-open} {}` | +| `image/*` | `${IMAGE_VIEWER:-xdg-open} {}` | +| `video/*` | `${VIDEO_PLAYER:-xdg-open} {}` | +| `audio/*` | `${AUDIO_PLAYER:-xdg-open} {}` | +| `application/pdf` | `${PDF_VIEWER:-xdg-open} {}` | +| `application/json`, `/yaml`, `/toml`, `/xml` | Same editor chain as `text/*` | +| `application/x-shellscript` | Same editor chain as `text/*` | +| `application/zip`, `/x-tar`, `/gzip`, etc. | `ls -l {}` (list contents, don't extract) | + +**Editor fallback chain:** for any text-type file, runar tries (in order) `$EDITOR` (if set in your shell), then `scitano`, `scite`, `geany`, and finally `nano`. The first one found on `$PATH` wins. This means out-of-the-box on a typical Linux install with `nano` present, double-clicking a `.txt` opens it in `nano`; install `scite` and it becomes the default; set `EDITOR=code` in your shell and VS Code wins. No config file required. + +To override per-extension with your own chain: see the next section. + +## 6. Add a Custom File Association + +The killer feature inherited from ROX-Filer is instant MIME action hooks with no desktop environment daemon required. Edit `~/.config/runar/actions.toml`, creating the file if it does not exist. Two forms are supported: + +**Single-command form** — one command, always tries to run it: + +```toml +[[actions]] +pattern = "txt" +command = "foot -e vim {}" + +[[actions]] +pattern = "image/*" +command = "feh {}" + +[[actions]] +pattern = ".pdf" +command = "zathura {}" + +[[actions]] +pattern = "Makefile" +command = "make -C $(dirname {})" +``` + +The `pattern` field accepts four forms. A bare extension like `txt` or a dotted extension like `.txt` matches by file extension and is case-insensitive. A MIME glob such as `image/*` matches any MIME type under that category, so `image/png` and `image/jpeg` both hit the rule. An exact MIME string like `image/png` matches only that type. A plain filename like `Makefile` matches by exact name, which is how you catch extensionless files. + +The `{}` token is substituted with the shell-quoted file path. Commands run via `sh -c`, so pipes, environment variables, and command substitution all work as they would in a terminal — note the `$(dirname {})` call in the Makefile example. + +You do not need to restart runar for changes to take effect; `actions.toml` is re-read on each launch action, so simply navigate away and back. Double-click a `.txt` file and it opens in `vim` inside `foot`. No `xdg-mime` queries, no `.desktop` files, no daemon. + +**Fallback-chain form** — try each command in order, succeed on the first whose binary is on `$PATH`. Useful when you want a preferred tool but a graceful fallback for systems where it isn't installed: + +```toml +[[actions]] +pattern = "txt" +commands = ["scitano {}", "scite {}", "geany {}", "nano {}"] +``` + +If every command in the chain fails the `$PATH` check, runar falls through to the built-in MIME table, then to `xdg-open`. The chain is checked statically (runar parses the leading binary name and verifies it exists on `$PATH` before spawning) so a missing binary doesn't pay the cost of a failed `sh -c` invocation. + +## 7. Top Menubar and Right-Click Context Menu + +runar ships with a traditional top menubar — **File / Edit / View / Go / Bookmarks / Help** — inspired by Thunar and PCManFM. Click any menu button to open its dropdown; click again or press Escape to close. Menu entries that aren't wired yet (Rename, Delete, Cut, Copy, Paste, Properties) are listed but emit a "not implemented yet" status-bar message so you can see the click registered. The wired entries include: + +- **File → Open** (Enter) — activates the selected entry +- **File → Reload** (Ctrl+R) — rescan the current directory +- **Go → Up** (Alt+Up) — navigate to the parent directory +- **Go → Edit Path…** (Ctrl+L) — toggle the pathbar into edit mode +- **Go → LOCATIONS** — jump straight to any hardcoded default location +- **Bookmarks → Add Current Directory** (Ctrl+D) — pin the current dir +- **Bookmarks → (entry)** — jump to any user bookmark +- **Help → About runar** — show the About dialog (click anywhere or press Escape to close) + +**Right-click any row** in the file grid to open a context menu with type-aware entries: + +- **Directories**: Open, Open in Terminal, Cut, Copy, Paste, Rename, Delete, Properties +- **AppDirs**: Launch, Enter Directory (the Shift+Enter equivalent), Cut, Copy, Rename, Delete, Properties +- **Files**: Open (with detected MIME type shown), Open With…, Copy Path, Open in Terminal, Cut, Copy, Rename, Delete, Properties +- **Symlinks**: Follow Link, Edit Target…, Copy Path, Cut, Copy, Rename, Delete, Properties + +The context menu selects the row underneath the cursor when you right-click, so subsequent keyboard actions (Enter, Delete, etc.) apply to it. Press Escape to close the menu without acting. + +## 8. Drag Folders to the Bookmarks Sidebar + +You can pin a folder to the sidebar by dragging it from the file grid into the BOOKMARKS section. The folder is inserted at the position where you drop it — between the two bookmarks your cursor lands on, or at the end if you drop below the last entry. + +**How it works:** + +1. **Press and hold** the left mouse button on a folder (or AppDir) row in the file grid. +2. **Move the cursor** slightly — runar detects the movement and promotes the click to a drag. The status bar shows `dragging /path/to/folder… drop on a bookmark slot`. +3. **Drag over the sidebar's BOOKMARKS section.** As the cursor enters each existing bookmark row, that row highlights in blue — this is where the folder will be inserted (before the highlighted entry). If you drag below the last bookmark, the empty append zone highlights instead. +4. **Release the mouse button.** The folder is inserted at the highlighted position, `bookmarks.toml` is saved atomically, and the status bar confirms `bookmarked /path/to/folder at position N`. + +**Rules:** + +- Only **directories and AppDirs** can be dragged to bookmarks — files are silently ignored (you can't navigate into a file, so bookmarking one is meaningless). +- If you drop a folder that's **already bookmarked**, it's **moved** to the new position rather than duplicated. +- If you drop **outside the BOOKMARKS section** (on DEVICES, LOCATIONS, the grid, or empty space), the drag is cancelled with a status-bar message. +- A plain **click without movement** still works as before (single-click selects, double-click opens). The drag only activates when the cursor moves while the button is held. + +**Reordering:** you can also drag an existing bookmark row to a new position within the BOOKMARKS section — the same insert-at-index logic applies, so dropping bookmark #3 between bookmarks #1 and #2 moves it there. + +## 9. Build a Release Binary + +For a daily-driver binary you can drop into `/usr/local/bin`: + +```sh +cargo build --release +sudo cp target/release/runar /usr/local/bin/ +``` + +The release profile uses `opt-level=3`, `lto=fat`, `codegen-units=1`, `strip=symbols`, and `panic=abort`, producing a roughly 5-8 MB stripped binary. It has zero shared library dependencies beyond glibc (or musl) and libgcc, so it will run on any distribution with a compatible ABI. + +For a fully static musl binary — ideal for Alpine, distro-hopping, or chroot environments — add the target and rebuild: + +```sh +rustup target add x86_64-unknown-linux-musl +cargo build --release --target x86_64-unknown-linux-musl +``` + +The resulting binary runs on any Linux kernel 3.2 or newer with no userspace dependencies whatsoever. + +## 10. Where to Go Next + +Read the full `README.md` for architecture details and the complete configuration schema. Edit `~/.config/runar/bookmarks.toml` to pin frequently used directories — no XDG defaults are injected, so it is a clean slate you control entirely. File bugs and feature requests at [https://dcos.net/runar](https://dcos.net/runar). The `BLOG.md` file explores the design philosophy behind runar and the GTK4-to-iced pivot that shaped its current form. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24ee692 --- /dev/null +++ b/README.md @@ -0,0 +1,299 @@ +# runar + +**A hybrid Thunar/PCManFM + ROX-Filer file manager, built in Rust with iced.** + +[![license: GPL-2.0-only](https://img.shields.io/badge/license-GPL--2.0--only-blue.svg)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) +[![language: Rust](https://img.shields.io/badge/language-Rust-orange.svg)](https://www.rust-lang.org/) +[![toolkit: iced 0.13](https://img.shields.io/badge/toolkit-iced%200.13-blue.svg)](https://github.com/iced-rs/iced) +[![tests: 21 passing](https://img.shields.io/badge/tests-83%20passing-brightgreen.svg)](#testing) + + +runar is a keyboard-first, statically-linkable Linux file manager written from scratch in pure Rust. It borrows its visual language from the dual-pane, breadcrumb-driven design of Thunar and PCManFM, while adopting ROX-Filer's AppDir paradigm and instantaneous, daemon-free MIME action hooks. The result is a single self-contained binary with no system library dependencies and no requirement for a desktop environment to be running. It targets power users who want a fast, predictable, scriptable file manager that respects the filesystem as the source of truth. The sidebar intentionally drops the conventional pile of hardcoded XDG user directories (Music, Pictures, Videos, Public, Templates) in favor of filesystem mounts, a small fixed set of power-user locations, and user-defined bookmarks — giving you a clean slate rather than a vendor-curated set of folders. + +--- + + + +Screenshots are coming soon. The interface is composed of three primary regions: a breadcrumb pathbar at the top that can be toggled into a text input field, a three-section sidebar on the left (DEVICES, LOCATIONS, BOOKMARKS), and a scrolling file list on the right with icon, name, size, modified, and type columns. The look is intentionally minimal — inline SVG glyphs replace any system icon theme, so the same UI renders identically on a stock Arch install, a minimal Sway session, or a headless recovery image booted into a TTY with `xinit`. Until real screenshots land, the best way to see runar is to `cargo run` it. + +--- + +## Why runar? + +runar combines the clean dual-pane/tree-and-grid layout of Thunar/PCManFM with the raw speed, AppDir capability, and lightweight MIME action hooks of ROX-Filer (the backbone of Puppy Linux and DSL). The sidebar strips out the conventional hardcoded XDG user directory sprawl — no Music, Pictures, Videos, Public, or Templates cluttering the view — in favor of filesystem mounts, a small fixed set of power-user locations (including `$HOME` and `$HOME/Downloads` as deliberate exceptions), and a fully user-editable Bookmarks section. + +Conventional GTK-based file managers tend to bundle a pile of assumptions about the surrounding desktop: they expect a settings daemon, a volume monitor service, an icon theme, and a curated set of XDG user directories like Music, Pictures, and Public. runar throws those assumptions out. The only thing it assumes is a mounted `/proc` filesystem and a working Linux kernel, which means it runs equally well on a workstation, a server, a Raspberry Pi, or a recovery live USB. This design choice is deliberate: the file manager should reflect what is actually on disk, not what a desktop integration layer believes ought to be there. + +The ROX-Filer lineage shows up most clearly in AppDir support and in the `actions.toml` MIME-hook system. AppDirs — directories that bundle an application together with its icons and resources, launched by executing an `AppRun` script or a same-named binary — let you install software by dropping a folder anywhere on the filesystem, no package manager required. The `actions.toml` file lets you bind any file extension, MIME glob, or exact filename to an arbitrary shell command, bypassing `xdg-open` entirely when you want finer control. Together these features restore the Unix philosophy of small, composable tools that the modern desktop has largely abandoned. + +--- + +## Features + +runar is built around a small, deliberate feature set that prioritizes speed, predictability, and scriptability over visual flash. + +- **Pure-Rust, statically linkable binary** — a single `target/release/runar` executable with no shared library dependencies and no system icon theme requirement. +- **Keyboard-first navigation** — Vim-style `H`/`J`/`K`/`L` plus arrow keys, with `/` or Ctrl+L to toggle the pathbar into edit mode and Enter to activate the selection. +- **Dual-pane visual language** — breadcrumb pathbar on top, three-section sidebar (DEVICES, LOCATIONS, BOOKMARKS) on the left, and a scrolling file list on the right. +- **ROX-Filer AppDir support** — directories containing an `AppRun` script or a same-named executable are detected and can be launched as applications; Shift+Enter overrides this to enter them as directories. +- **`actions.toml` MIME hooks** — bind any file extension, MIME glob (e.g. `image/*`), or exact filename to an arbitrary shell command, taking precedence over `xdg-open`. +- **Pure-Rust mount polling** — replaces `gio::VolumeMonitor` with a direct `/proc/mounts` reader that filters out pseudo-filesystems and unescapes octal sequences in mount points. +- **Daemon-free sidebar** — no settings daemon, no volume monitor service, no icon theme; the sidebar reflects actual filesystem mounts and user-defined locations only. +- **User-editable bookmarks** — `bookmarks.toml` is purely user-added, with no injected XDG defaults; the Bookmarks section starts empty. +- **Hardcoded power-user LOCATIONS** — `/mnt`, `/var/run/media`, `/opt`, `/usr/src`, `$HOME`, and `$HOME/Downloads` are always present and complement the user bookmarks. +- **Inline SVG glyph set** — every icon lives in `icons.rs` as inline SVG, so the UI renders identically on any Linux install without depending on a system icon theme. +- **Atomic config saves** — `bookmarks.toml` and `actions.toml` are written via a temp-file-plus-rename pattern so a crash mid-write can never corrupt your configuration. +- **Async non-blocking VFS** — directory scanning runs on a tokio runtime and streams results back to the UI, so even a directory with tens of thousands of entries never freezes the interface. +- **`notify`-based filesystem watcher** — inotify events are bridged into a tokio channel and reflected in the grid automatically as files are created, modified, or deleted. + +--- + +## Why iced, not GTK4? + +The project manifest originally proposed `gtk4-rs`. We switched to **iced** for the static-binary ethos: pure-Rust toolchain, no system lib deps (`libgtk-4-dev`, `libglib2.0-dev`, hicolor icon theme, etc.). A release build produces a single self-contained binary that runs on a fresh minimal Linux install. + +Trade-offs: +- ✅ True static binary — no shared lib deps, no system icon theme required +- ✅ Pure Rust — `cargo build` just works, no `pkg-config` gymnastics +- ✅ Async-native Elm architecture — clean state/update/view separation +- ❌ No native cross-process DND (XDS) — in-app DND works, cross-process does not +- ❌ No `gio::VolumeMonitor` — replaced with pure-Rust `/proc/mounts` poller +- ❌ Immature at file-manager scale — untested at Nautilus/Thunar traffic levels + +For runar's use case (power-user file manager, Linux-native, security-conscious), the static-binary win outweighs the ecosystem gaps. Shipping one binary that runs anywhere — from a fully loaded KDE Plasma desktop to a 50 MB SliTaz live image to a headless server reached over SSH with X forwarding — is worth more than native drag-and-drop into GIMP. The Elm-style state/update/view architecture that iced provides also maps cleanly onto a file manager's event loop: filesystem events, keyboard input, and UI clicks all funnel through a single `update()` function that mutates a single `AppState` struct, which makes the code easy to reason about and easy to test. The gaps that remain — cross-process DND, mature high-throughput list rendering, thumbnail pipelines — are explicitly tracked in the roadmap below and are the active focus of ongoing work. + +--- + +## Installation + +runar builds with a stock Rust toolchain and no system dependencies beyond a working Linux installation with `/proc` mounted. You need Rust 1.70 or later on the stable channel; everything else is pulled in by Cargo as crate dependencies. + +```sh +cargo run # debug build, opens at CWD +cargo run -- /some/path # debug build, opens at /some/path +cargo build --release # optimized static binary at target/release/runar +``` + +The release profile is already configured in `Cargo.toml` for a tight, stripped binary: `opt-level=3`, `lto=fat`, `codegen-units=1`, `strip=symbols`, and `panic=abort`. A release build typically takes one to three minutes on a modern laptop, since fat LTO and a single codegen unit trade compile time for a smaller, faster binary. The resulting `target/release/runar` is a single executable you can drop into `/usr/local/bin`, copy onto a USB stick, or ship inside a container image without any runtime library dependencies. For a fully static musl binary that has no dynamic linker requirement at all, add the musl target and rebuild: + +```sh +rustup target add x86_64-unknown-linux-musl +cargo build --release --target x86_64-unknown-linux-musl +``` + +The musl variant is what you want for recovery images, initramfs payloads, and any environment where glibc may not be present or where you want the binary to run unchanged across distributions. Both the glibc and musl release binaries behave identically at runtime; only the libc ABI linkage differs. + +--- + +## Quick start + +```sh +cargo run --release # opens at your home directory +cargo run --release -- /etc # opens at /etc +cargo run --release -- /mnt/data # opens at a specific mount point +``` + +Once the window is up, press `/` or Ctrl+L to edit the path inline, type a new path, hit Enter to navigate, and use `H`/`J`/`K`/`L` or the arrow keys to move around the file grid. Drop a `[[bookmarks]]` stanza into `~/.config/runar/bookmarks.toml` to pin frequently visited directories in the sidebar, and an `[[actions]]` stanza into `~/.config/runar/actions.toml` to bind file types to your preferred editors and viewers. For a more detailed walkthrough — including how to set up AppDir shortcuts, how to write MIME-glob action rules, and how to integrate runar with a tiling window manager — see [QUICKSTART.md](QUICKSTART.md). + +--- + +## Key bindings + +runar is driven from the keyboard. Every navigation action has both an arrow-key binding and a Vim-style equivalent, so you can keep your hands in one place regardless of input preference. + +| Key | Action | +|-----|--------| +| Arrow Up / `K` | move selection up | +| Arrow Down / `J` | move selection down | +| `H` / Backspace | go up one directory | +| `L` / Enter | activate selection (open / launch / navigate) | +| Shift+Enter | on an AppDir, enter it as a directory (overrides launch) | +| `/` or Ctrl+L | toggle pathbar edit mode | +| Escape | exit pathbar edit mode | + +The global keyboard handler lives in `src/main.rs` and dispatches into `App::update()` for state mutation. When the pathbar is in edit mode, the file grid ignores navigation keys so that typing a path does not move the selection. Shift+Enter on a directory that runar has detected as an AppDir will descend into it as a normal directory rather than executing its `AppRun` script, which is the escape hatch you need when you want to inspect an AppDir's contents rather than launch the application it bundles. + +--- + +## Configuration + +Both of runar's configuration files live under `~/.config/runar/` and are created empty on first save. Neither file ever injects XDG defaults — the Bookmarks section starts empty, and the actions table starts empty, so `xdg-open` is the fallback for every file until you explicitly override it. The path is resolved through the standard `XDG_CONFIG_HOME` environment variable, falling back to `~/.config` when unset, exactly as the XDG Base Directory Specification requires. + +### `bookmarks.toml` + +Bookmarks are purely user-added entries that appear in the BOOKMARKS section of the sidebar, below the hardcoded LOCATIONS. The `label` field is optional; when omitted, runar falls back to the basename of the path. + +```toml +[[bookmarks]] +path = "/mnt/data" +label = "Data" # optional +``` + +### `actions.toml` + +Actions bind a pattern to a shell command (or a chain of fallback commands). The `pattern` may be a bare file extension (with or without a leading dot), a MIME glob such as `image/*`, or an exact filename like `Makefile`. The `{}` placeholder in `command` is substituted with the shell-quoted absolute path of the matched file, and the resulting command line is executed via `sh -c`. The first matching action wins, so order your rules from most specific to least specific. + +**Single-command form** (simplest): + +```toml +[[actions]] +pattern = "txt" +command = "foot -e vim {}" + +[[actions]] +pattern = "image/*" +command = "feh {}" + +[[actions]] +pattern = "Makefile" +command = "make -C $(dirname {})" +``` + +**Fallback-chain form** — try each command in order, succeed on the first whose binary is on `$PATH`. Useful when you want a preferred tool but a graceful fallback for systems where it isn't installed: + +```toml +[[actions]] +pattern = "txt" +commands = ["scitano {}", "scite {}", "geany {}", "nano {}"] + +[[actions]] +pattern = "image/*" +commands = ["feh {}", "sxiv {}", "xdg-open {}"] +``` + +If every command in the chain fails the `$PATH` check, runar falls through to the built-in MIME table, then to `xdg-open`. The chain is checked statically (we parse the leading binary name from each command string and verify it exists on `$PATH` before spawning) so a missing binary doesn't pay the cost of a failed `sh -c` invocation. + +Both files are written atomically on save: runar writes to a sibling temp file and then renames it over the original, so a crash or power loss mid-write cannot leave a half-written config behind. The config engine has unit tests covering roundtrip serialization, the fail-soft behavior when a config file does not exist yet, and the precedence rules for `actions.toml` patterns. + +--- + +## Default locations + +The LOCATIONS section of the sidebar contains a small, hardcoded set of power-user directories chosen by the author. These are always present and cannot be removed by editing `bookmarks.toml`; they complement the user-editable Bookmarks section rather than competing with it. + +- `/mnt` — traditional mount point +- `/var/run/media` — udisks2 auto-mount point (systemd systems) +- `/opt` — optional software packages +- `/usr/src` — kernel sources / build trees +- `$HOME` — user's home directory +- `$HOME/Downloads` — downloads folder + +This list reflects a deliberate bias toward systems administration and power-user workflows: `/mnt` and `/var/run/media` are where removable media and network mounts actually appear on a modern Linux box, `/opt` and `/usr/src` are where hand-installed software and kernel build trees live, and `$HOME` plus `$HOME/Downloads` cover the day-to-day user directories without forcing Music, Pictures, Videos, Public, and Templates into your sidebar. If you want additional shortcuts — a project directory, a media library, a network share — add them to `bookmarks.toml` and they will appear in the BOOKMARKS section right below these defaults. The split between hardcoded LOCATIONS and user-editable BOOKMARKS keeps the sidebar visually stable across config changes while still letting you customize it freely. + +--- + +## Architecture + +runar is organized into a thin UI layer over an async VFS core, with configuration and launch dispatch factored out into standalone modules. The split makes it straightforward to unit-test the filesystem, config, and launch logic without ever instantiating the iced runtime. + +``` +src/ + main.rs iced entry point + key-press bridge (raw Key/Modifiers → Message) + date.rs Howard Hinnant civil_from_days algorithm (leap-year-correct date math, no chrono) + vfs/ + mod.rs async scan_directory (Phase 1 — non-blocking tokio directory reader) + appdir.rs ROX AppDir detection (AppRun or same-name executable) + watcher.rs notify-based inotify watcher, bridged to tokio channel + config/ + mod.rs Config struct, atomic save, XDG path resolution (~/.config/runar/) + bookmarks.rs bookmarks.toml (purely user-added, no XDG defaults) + actions.rs actions.toml (extension/MIME → shell command overrides) + defaults.rs Hardcoded default locations shown in sidebar LOCATIONS section + mounts.rs /proc/mounts poller + pure parse_mounts() (replaces gio::VolumeMonitor) + icons.rs Inline SVG glyph set with cached LazyLock per variant + mime.rs Built-in default MIME action table (text→$EDITOR, image→$IMAGE_VIEWER, etc.) + launch.rs Launch dispatcher: AppDir exec → actions.toml → built-in MIME → xdg-open + ui/ + mod.rs App state, update(), view(), subscription(), handle_key() + pathbar.rs Breadcrumb view, toggleable to text input via / or Ctrl+L + sidebar.rs Three sections: DEVICES, LOCATIONS, BOOKMARKS + grid.rs Scrolling file list with icon/name/size/modified/type columns (mouse_area for right-click) + menubar.rs Top menubar (File/Edit/View/Go/Bookmarks/Help) + dropdown panels + context_menu.rs Right-click context menu with type-aware entries per file kind + about.rs About dialog (Help → About runar) +``` + +The `vfs` module is the heart of the project: `scan_directory` runs inside a tokio runtime and streams `Entry` records back to the iced subscription loop, so the UI thread never blocks even when scanning a directory containing tens of thousands of files. The `appdir` submodule classifies each directory entry as either a plain directory, an AppDir (containing an `AppRun` script or a same-named executable), or a regular file, which the launch dispatcher uses to decide what to do when you press Enter. `watcher.rs` wraps the `notify` crate's inotify backend and bridges events into the same tokio channel, so changes from other processes — `git pull`, a download finishing, a USB stick being mounted — show up in the grid automatically. Configuration is split across `mod.rs`, `bookmarks.rs`, `actions.toml`, and `defaults.rs`, each responsible for one file or concept, and `mounts.rs` handles `/proc/mounts` parsing independently of any desktop volume monitor. The `ui` module is intentionally thin: `mod.rs` holds the `AppState` struct and the `update`/`view`/`subscription` trinity, while `pathbar.rs`, `sidebar.rs`, and `grid.rs` are pure view functions that turn slices of state into iced widgets. + +--- + +## Testing + +runar ships with **83 unit tests, all passing**. Run them with: + +```sh +cargo test --bin runar +``` + +The test suite covers every non-UI module and is structured to mirror the source tree, so each module's tests live alongside its implementation in a `#[cfg(test)] mod tests` block. Tests are deterministic, do not mutate process-global state (no `std::env::set_var` calls — overrides are threaded through function parameters instead, which avoids both parallel-test flakiness and the `unsafe` requirement that Rust 1.82+ imposes on env mutation), and do not touch the real filesystem for writes: where a temp directory is needed, the tests use `std::env::temp_dir()` and clean up after themselves. The full breakdown: + +- **date::tests::\*** — Howard Hinnant `civil_from_days` algorithm: epoch anchors, leap-year handling (1972 leap, 1973 non-leap, 2000 leap despite div-by-100, 1900 and 2100 non-leap), pre-epoch dates, Y2K rollover, `format_date` shape (14 tests) +- **mime::tests::\*** — built-in default MIME action table: text→editor chain (scitano→scite→geany→nano, $EDITOR first), image→$IMAGE_VIEWER, video/audio/PDF→xdg-open-with-env-override, HTML→$BROWSER (checked before text/ rule), archives→`ls -l`, JSON/YAML/TOML/XML→editor, shellscript→editor, editor chain ordering, $EDITOR precedence, unknown MIME returns None, charset-suffix stripping, path-based resolution (17 tests) +- **config::tests::\*** — config load/save roundtrip, missing-config fail-soft, `config_dir_with` path composition (3 tests) +- **config::bookmarks::tests::\*** — `bookmarks.toml` roundtrip (1 test) +- **config::actions::tests::\*** — pattern matching (extension, dot-prefix, MIME glob, exact filename), single-command form, fallback-chain form, `commands_to_try` ordering (chain wins over single), empty action, TOML roundtrip for both forms, TOML parsing of both forms (12 tests) +- **config::defaults::tests::\*** — hardcoded locations include `/mnt` `/opt` `/usr/src` `/var/run/media`, `$HOME` and `$HOME/Downloads` resolution when home provided, omission when home is `None`, total count (4 tests) +- **launch::tests::\*** — `shell_quote` (simple paths, paths with spaces, paths with single-quotes), `extract_leading_binary` (simple, env assignment, env-var expansion returns None, empty, `sh -c`), `binary_on_path` (finds sh, rejects nonexistent, handles absolute path) (11 tests) +- **vfs::tests::\*** — directory scan reports done (1 test) +- **vfs::appdir::tests::\*** — `AppRun` detection, same-name executable detection, plain dir rejection (3 tests) +- **mounts::tests::\*** — `/proc/mounts` parsing (sample input, pseudo-FS filtering, network FS tagging, dedup, empty input, malformed lines), octal escape unescaping (spaces, tabs, backslashes), UTF-8 preservation in mount labels (9 tests) +- **icons::tests::\*** — all variants have distinct SVG markup, widget construction doesn't panic (2 tests) + +The `shell_quote` tests in `launch.rs` are particularly important because they are the only line of defense between user-controlled filenames and `sh -c`. They assert that paths containing spaces are wrapped in single quotes, that paths containing single quotes themselves are escaped correctly using the `'\''` idiom, and that simple paths without special characters are passed through unchanged. The `actions.toml` tests verify the four pattern-matching strategies — bare extension, dotted extension, MIME glob, and exact filename — so that the precedence rules documented in the Configuration section above are enforced by code rather than by convention. The mounts tests cover both the happy path of parsing a typical `/proc/mounts` line and the edge case of mount points containing octal-escaped characters like `\040` for a space, which the kernel uses to disambiguate the whitespace-separated mount table format. + +--- + +## Roadmap + +runar is feature-complete for daily power-user use, but several capabilities that desktop users expect from a Thunar-class file manager are still on the way. The phases below track what is done and what is queued for the next several release cycles. + +- [x] Phase 1: Core engine & async VFS (scanner, watcher, AppDir detector) +- [x] Phase 2: Shell layout (pathbar, sidebar, file grid, keyboard nav) +- [x] Phase 3: Launch dispatcher (AppDir exec, actions.toml, built-in MIME table, xdg-open fallback) +- [x] Phase 4: Config engine (bookmarks.toml, actions.toml, atomic save) +- [ ] Background copy/move/delete with progress toasts +- [ ] True multi-column icon grid with spatial H/J/K/L navigation +- [ ] Virtualized file list (iced 0.13's stock `scrollable` lays out all children; page or custom-widget the entry list for 10k+ entries) +- [ ] Cross-process drag-and-drop (XDS — currently limited to in-app) +- [ ] Thumbnail rendering (will need to add the `image` crate as a dependency) +- [ ] File permissions dialog +- [ ] Bulk rename tool +- [ ] Bookmark editor UI (currently edit bookmarks.toml by hand) + +The completed phases cover everything you need to browse, navigate, and launch files. The remaining items fall into two buckets: filling in file-management operations that every file manager needs (background copy/move/delete, permissions dialog, bulk rename) and pushing the UI closer to parity with Thunar and ROX-Filer (spatial icon grid, thumbnails, cross-process DND). The thumbnail pipeline is the lowest-hanging fruit in concept but will require adding the `image` crate as a dependency (it is not currently in `Cargo.toml`) and wiring a thumbnail cache into the grid renderer alongside the existing `rayon` pool. The cross-process DND item is the hardest, because XDS support in pure Rust effectively does not exist today and will likely require either an FFI shim to libX11 or a from-scratch implementation of the XDS protocol on top of a pure-Rust X11 client. Pull requests targeting any of these items are very welcome. + +--- + +## Credits and inspiration + +### Design inspiration (no code copied) + +- **Thunar** (Xfce) — dual-pane layout, breadcrumb pathbar, keyboard-first navigation +- **PCManFM** (LXDE) — lightweight GTK file manager philosophy +- **ROX-Filer** — AppDir paradigm, instant MIME action hooks (no DE daemon dependency) +- **Puppy Linux / DSL** — ROX-Filer as desktop backbone, AppDir application bundles +- **SliTaz** — minimal-footprint philosophy, fast boot from read-only media + +### Code reuse + +No code was copied from any of the above projects. runar is a clean-room implementation. The Rust crates used (iced, tokio, notify, mime_guess, serde, toml, rayon, walkdir, open) are each credited in `Cargo.toml` and remain under their respective licenses. + +### Author + +**Jeremy Anderson** — https://dcos.net/runar + +--- + +## Contributing + +Patches welcome at https://dcos.net/runar (see repository for issue tracker and merge request workflow). + +Building from source requires Rust 1.70+ (stable). No system dependencies beyond a working Linux installation with /proc mounted. + +The contribution workflow is intentionally lightweight: fork the repository, branch off `main`, write code that passes `cargo test --bin runar` and `cargo clippy -- -D warnings`, and open a merge request with a clear description of the change and the motivation behind it. New features should ship with unit tests in the same `#[cfg(test)] mod tests` style as the existing code, and any new user-visible string should be plain ASCII (Unicode is fine where it makes sense, but avoid emoji in the UI itself). If you are adding a new keyboard binding, document it in the Key bindings table in this README and update the global keyboard handler in `src/main.rs` in the same commit. If you are touching the launch dispatcher or the `actions.toml` parser, add a test case to `launch::tests` or `config::actions::tests` that exercises the new behavior, since those code paths are the boundary between user-controlled input and `sh -c` and need the most rigorous coverage. + +--- + +## License + +GPL-2.0-only — see the [LICENSE](LICENSE) file for details. diff --git a/runar-ss.png b/runar-ss.png new file mode 100644 index 0000000000000000000000000000000000000000..be50cc08ae191faa35602f97a831a700f261c58c GIT binary patch literal 102901 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

mM~M3$@YobAa#FxI{cK41C*0l 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 bE_8lF>YYn6Itb;ps4wG*^Ts}L4N3k7Dwvu0 literal 0 HcmV?d00001 diff --git a/src/config/actions.rs b/src/config/actions.rs new file mode 100644 index 0000000..47ee916 --- /dev/null +++ b/src/config/actions.rs @@ -0,0 +1,313 @@ +//! `actions.toml` — file extension / MIME-type → shell command overrides. +//! +//! ## Schema +//! +//! Single-command form (backward compatible with v0.1): +//! ```toml +//! [[actions]] +//! pattern = "txt" +//! command = "foot -e vim {}" +//! ``` +//! +//! Fallback-chain form (v0.2+) — try each command in order, succeed on +//! the first one whose binary is installed: +//! ```toml +//! [[actions]] +//! pattern = "txt" +//! commands = ["scitano {}", "scite {}", "geany {}", "nano {}"] +//! ``` +//! +//! When the launcher resolves an action, it walks `commands` (or the +//! single-element list `[command]`) and runs the first command whose +//! leading binary is found on `$PATH`. If none are installed, the +//! launcher falls through to the built-in MIME table or `xdg-open`. +//! +//! ## Pattern forms +//! +//! * `image/*` → MIME prefix glob (matched against `mime.essence_str()`) +//! * `image/png` → exact MIME +//! * `.txt` / `txt` → file extension (case-insensitive) +//! * `Makefile` → exact filename match + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use super::bookmarks::atomic_write; + +/// A single user-defined file-association override. +/// +/// Backward-compat note: serializes with `command` if there's only one +/// entry, otherwise with `commands`. Both forms are accepted on load. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Action { + pub pattern: String, + /// Single-command form. Mutually exclusive with `commands` — if both + /// are set, `commands` wins. If neither is set, the action is a no-op. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Fallback-chain form. Each command is tried in order; the first one + /// whose leading binary is on `$PATH` runs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub commands: Vec, +} + +impl Action { + /// Construct a single-command action. Convenience for tests. + #[cfg(test)] + pub fn single(pattern: impl Into, command: impl Into) -> Self { + Self { + pattern: pattern.into(), + command: Some(command.into()), + commands: Vec::new(), + } + } + + /// Construct a fallback-chain action. + #[cfg(test)] + pub fn chain(pattern: impl Into, commands: Vec) -> Self { + Self { + pattern: pattern.into(), + command: None, + commands, + } + } + + /// All commands to try, in order. If `commands` is non-empty it wins; + /// otherwise we fall back to the single `command` (if any). + pub fn commands_to_try(&self) -> Vec<&str> { + if !self.commands.is_empty() { + self.commands.iter().map(|s| s.as_str()).collect() + } else if let Some(c) = &self.command { + vec![c.as_str()] + } else { + Vec::new() + } + } + + pub fn load_all(dir: &Path) -> Vec { + let path = dir.join("actions.toml"); + match std::fs::read_to_string(&path) { + Ok(s) => match toml::from_str::(&s) { + Ok(shape) => shape.actions, + Err(e) => { + log::warn!("malformed {}: {e}", path.display()); + Vec::new() + } + }, + Err(_) => Vec::new(), + } + } + + pub fn save_all(dir: &Path, actions: &[Self]) -> std::io::Result<()> { + let shape = FileShape { + actions: actions.to_vec(), + }; + let s = toml::to_string_pretty(&shape).unwrap_or_default(); + atomic_write(dir.join("actions.toml"), s.as_bytes()) + } + + /// True if this action's pattern matches `path` (with optional MIME). + pub fn matches(&self, path: &Path, mime: Option<&mime_guess::Mime>) -> bool { + let p = &self.pattern; + + // MIME patterns contain a slash. + if p.contains('/') { + if let Some(m) = mime { + let essence = m.essence_str(); + if p == essence { + return true; + } + if let Some(prefix) = p.strip_suffix("/*") { + return essence == prefix + || essence.starts_with(&format!("{prefix}/")); + } + } + return false; + } + + // Extension patterns. + let ext_form = p.strip_prefix('.').unwrap_or(p); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if ext.eq_ignore_ascii_case(ext_form) { + return true; + } + } + + // Filename match (catches extensionless files like "Makefile"). + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name == p { + return true; + } + } + + false + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct FileShape { + #[serde(default)] + actions: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_match() { + let a = Action::single("txt", "vim {}"); + assert!(a.matches(Path::new("/tmp/notes.txt"), None)); + assert!(!a.matches(Path::new("/tmp/notes.md"), None)); + } + + #[test] + fn dot_prefix_extension() { + let a = Action::single(".pdf", "zathura {}"); + assert!(a.matches(Path::new("/tmp/book.pdf"), None)); + } + + #[test] + fn mime_glob() { + let a = Action::single("image/*", "feh {}"); + let png_mime: mime_guess::Mime = "image/png".parse().unwrap(); + let jpg_mime: mime_guess::Mime = "image/jpeg".parse().unwrap(); + let txt_mime: mime_guess::Mime = "text/plain".parse().unwrap(); + assert!(a.matches(Path::new("x.png"), Some(&png_mime))); + assert!(a.matches(Path::new("x.jpg"), Some(&jpg_mime))); + assert!(!a.matches(Path::new("x.txt"), Some(&txt_mime))); + } + + #[test] + fn exact_filename() { + let a = Action::single("Makefile", "make -C {}"); + assert!(a.matches(Path::new("/proj/Makefile"), None)); + assert!(!a.matches(Path::new("/proj/main.c"), None)); + } + + #[test] + fn commands_to_try_with_single_command() { + let a = Action::single("txt", "vim {}"); + assert_eq!(a.commands_to_try(), vec!["vim {}"]); + } + + #[test] + fn commands_to_try_with_chain() { + let a = Action::chain( + "txt", + vec![ + "scitano {}".into(), + "scite {}".into(), + "geany {}".into(), + "nano {}".into(), + ], + ); + assert_eq!( + a.commands_to_try(), + vec!["scitano {}", "scite {}", "geany {}", "nano {}"] + ); + } + + #[test] + fn commands_to_try_chain_wins_over_command() { + // If both `command` and `commands` are set, `commands` wins. + // (This shouldn't happen with normal TOML parsing — both fields + // are optional — but we defend against it anyway.) + let a = Action { + pattern: "txt".into(), + command: Some("vim {}".into()), + commands: vec!["scite {}".into(), "geany {}".into()], + }; + assert_eq!(a.commands_to_try(), vec!["scite {}", "geany {}"]); + } + + #[test] + fn commands_to_try_empty() { + let a = Action { + pattern: "txt".into(), + command: None, + commands: Vec::new(), + }; + assert!(a.commands_to_try().is_empty()); + } + + #[test] + fn roundtrip_single_command() { + let tmp = std::env::temp_dir().join(format!( + "runar-fm-act-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&tmp).unwrap(); + + let actions = vec![ + Action::single("txt", "vim {}"), + Action::single("image/*", "feh {}"), + ]; + Action::save_all(&tmp, &actions).unwrap(); + let loaded = Action::load_all(&tmp); + assert_eq!(loaded, actions); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn roundtrip_chain() { + let tmp = std::env::temp_dir().join(format!( + "runar-fm-act-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&tmp).unwrap(); + + let actions = vec![Action::chain( + "txt", + vec![ + "scitano {}".into(), + "scite {}".into(), + "geany {}".into(), + "nano {}".into(), + ], + )]; + Action::save_all(&tmp, &actions).unwrap(); + let loaded = Action::load_all(&tmp); + assert_eq!(loaded, actions); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn toml_single_command_form_loads() { + // Old-format TOML (just `command = ...`) should still load. + let toml = r#" +[[actions]] +pattern = "txt" +command = "vim {}" +"#; + let shape: FileShape = toml::from_str(toml).unwrap(); + assert_eq!(shape.actions.len(), 1); + assert_eq!(shape.actions[0].command.as_deref(), Some("vim {}")); + assert!(shape.actions[0].commands.is_empty()); + assert_eq!(shape.actions[0].commands_to_try(), vec!["vim {}"]); + } + + #[test] + fn toml_chain_form_loads() { + let toml = r#" +[[actions]] +pattern = "txt" +commands = ["scitano {}", "scite {}", "geany {}", "nano {}"] +"#; + let shape: FileShape = toml::from_str(toml).unwrap(); + assert_eq!(shape.actions.len(), 1); + assert!(shape.actions[0].command.is_none()); + assert_eq!( + shape.actions[0].commands_to_try(), + vec!["scitano {}", "scite {}", "geany {}", "nano {}"] + ); + } +} diff --git a/src/config/bookmarks.rs b/src/config/bookmarks.rs new file mode 100644 index 0000000..e743891 --- /dev/null +++ b/src/config/bookmarks.rs @@ -0,0 +1,223 @@ +//! `bookmarks.toml` — purely user-added paths. +//! +//! Schema: +//! ```toml +//! [[bookmarks]] +//! path = "/mnt/data" +//! label = "Data" +//! ``` +//! +//! `label` is optional; if absent, the path's basename (or "/" for root) +//! is used for display. No XDG defaults are ever injected. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default)] +pub struct Bookmarks { + entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BookmarkEntry { + pub path: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct FileShape { + #[serde(default)] + bookmarks: Vec, +} + +impl Bookmarks { + #[allow(dead_code)] + pub fn paths(&self) -> Vec { + self.entries.iter().map(|e| e.path.clone()).collect() + } + + pub fn entries(&self) -> &[BookmarkEntry] { + &self.entries + } + + /// Add a path if it isn't already bookmarked. Returns true on insertion. + pub fn add(&mut self, path: PathBuf) -> bool { + if self.entries.iter().any(|e| e.path == path) { + return false; + } + self.entries.push(BookmarkEntry { path, label: None }); + true + } + + /// Insert a path at a specific index. Used by drag-and-drop when the + /// user drops a folder between two existing bookmarks. If the path is + /// already bookmarked, it's moved to the new index (remove + reinsert). + /// The index is clamped to `[0, len]`. + /// + /// Returns true if the bookmark was inserted (or moved). + pub fn insert_at(&mut self, path: PathBuf, index: usize) -> bool { + // Remove existing entry if present (this is a move, not a dup). + self.entries.retain(|e| e.path != path); + let clamped = index.min(self.entries.len()); + self.entries.insert(clamped, BookmarkEntry { path, label: None }); + true + } + + pub fn remove(&mut self, path: &Path) -> bool { + let before = self.entries.len(); + self.entries.retain(|e| e.path != path); + self.entries.len() != before + } + + pub fn load(dir: &Path) -> Self { + let path = dir.join("bookmarks.toml"); + match std::fs::read_to_string(&path) { + Ok(s) => match toml::from_str::(&s) { + Ok(shape) => Self { + entries: shape.bookmarks, + }, + Err(e) => { + log::warn!("malformed {}: {e}", path.display()); + Self::default() + } + }, + Err(_) => Self::default(), + } + } + + pub fn save(&self, dir: &Path) -> std::io::Result<()> { + let shape = FileShape { + bookmarks: self.entries.clone(), + }; + let s = toml::to_string_pretty(&shape).unwrap_or_default(); + atomic_write(dir.join("bookmarks.toml"), s.as_bytes()) + } +} + +/// Write to a temp file in the same dir, fsync it, then rename — avoids +/// leaving a half-written config file if we crash mid-write, and (with +/// the fsync) gives a much stronger guarantee that the write survives a +/// power loss immediately after the function returns. +/// +/// The temp file is fsync'd before the rename so that the rename itself +/// only exposes a fully-durably-stored file. The parent directory is +/// fsync'd after the rename so the rename itself is durable — without +/// this step, a power loss after `rename()` returns could leave the old +/// file (or no file) visible on reboot, even though the data was written. +/// +/// This is the standard "atomic durable write" pattern on POSIX filesystems. +pub(crate) fn atomic_write(target: PathBuf, data: &[u8]) -> std::io::Result<()> { + use std::fs::OpenOptions; + use std::io::Write; + + let dir = target.parent().unwrap_or(Path::new(".")); + let tmp = dir.join(format!( + ".{}.tmp", + target.file_name().and_then(|n| n.to_str()).unwrap_or("cfg") + )); + + // Write data to temp file. + { + let mut f = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp)?; + f.write_all(data)?; + f.sync_all()?; // fsync the temp file before rename + } // file handle dropped here + + // Atomic rename. + std::fs::rename(&tmp, &target)?; + + // fsync the parent directory so the rename is durable. On Linux + // this requires opening the dir as a file (O_RDONLY), which std + // supports via File::open. Errors here are non-fatal — the file + // content is already durable, we just can't guarantee the rename + // survives a power loss. Log and continue. + if let Ok(dir_file) = std::fs::File::open(dir) { + let _ = dir_file.sync_all(); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_bookmarks() { + let tmp = std::env::temp_dir().join(format!( + "runar-fm-bm-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&tmp).unwrap(); + + let mut bm = Bookmarks::default(); + bm.add(PathBuf::from("/mnt/data")); + bm.add(PathBuf::from("/home/user/projects")); + bm.save(&tmp).unwrap(); + + let loaded = Bookmarks::load(&tmp); + assert_eq!(loaded.entries.len(), 2); + assert_eq!(loaded.entries[0].path, PathBuf::from("/mnt/data")); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn insert_at_beginning() { + let mut bm = Bookmarks::default(); + bm.add(PathBuf::from("/a")); + bm.add(PathBuf::from("/b")); + bm.add(PathBuf::from("/c")); + bm.insert_at(PathBuf::from("/new"), 0); + assert_eq!(bm.entries.len(), 4); + assert_eq!(bm.entries[0].path, PathBuf::from("/new")); + assert_eq!(bm.entries[1].path, PathBuf::from("/a")); + } + + #[test] + fn insert_at_middle() { + let mut bm = Bookmarks::default(); + bm.add(PathBuf::from("/a")); + bm.add(PathBuf::from("/b")); + bm.add(PathBuf::from("/c")); + bm.insert_at(PathBuf::from("/new"), 2); + assert_eq!(bm.entries.len(), 4); + assert_eq!(bm.entries[2].path, PathBuf::from("/new")); + assert_eq!(bm.entries[3].path, PathBuf::from("/c")); + } + + #[test] + fn insert_at_end() { + let mut bm = Bookmarks::default(); + bm.add(PathBuf::from("/a")); + bm.add(PathBuf::from("/b")); + bm.insert_at(PathBuf::from("/new"), 5); // clamp to len=2 + assert_eq!(bm.entries.len(), 3); + assert_eq!(bm.entries[2].path, PathBuf::from("/new")); + } + + #[test] + fn insert_at_moves_existing() { + // If the path is already bookmarked, insert_at moves it to the + // new index rather than creating a duplicate. + let mut bm = Bookmarks::default(); + bm.add(PathBuf::from("/a")); + bm.add(PathBuf::from("/b")); + bm.add(PathBuf::from("/c")); + // Move /a to index 2 (between /b and /c... actually after remove, + // /b and /c shift left, so index 2 = after /c) + bm.insert_at(PathBuf::from("/a"), 2); + assert_eq!(bm.entries.len(), 3); // no duplicate + assert_eq!(bm.entries[0].path, PathBuf::from("/b")); + assert_eq!(bm.entries[1].path, PathBuf::from("/c")); + assert_eq!(bm.entries[2].path, PathBuf::from("/a")); + } +} diff --git a/src/config/defaults.rs b/src/config/defaults.rs new file mode 100644 index 0000000..e09dee4 --- /dev/null +++ b/src/config/defaults.rs @@ -0,0 +1,163 @@ +//! Default locations — hardcoded shortcuts shown in the sidebar's +//! "LOCATIONS" section. These are always present (cannot be removed by +//! the user) and complement the user-editable Bookmarks section. +//! +//! Per the project's customization request, the following locations are +//! pinned by default: +//! * /mnt — traditional mount point +//! * /var/run/media — udisks2 auto-mount point (systemd systems) +//! * /opt — optional software packages +//! * /usr/src — kernel sources / build trees +//! * $HOME — user's home directory +//! * $HOME/Downloads — downloads folder (deliberate exception to the +//! "no hardcoded XDG dirs" rule — see README) +//! +//! $HOME is resolved at runtime via the HOME env var, falling back to +//! /home/userdir if HOME isn't set (matching the user-spec wording). +//! +//! ## Testing note +//! +//! `home_dir()` reads the `HOME` env var. Tests don't mutate `HOME` +//! (that's a flakiness risk under parallel `cargo test` and an `unsafe` +//! operation on Rust 1.82+); instead they call `defaults_with(home)` +//! directly with an explicit home path. + +use std::path::PathBuf; + +/// A single default location entry. +#[derive(Debug, Clone)] +pub struct DefaultLocation { + /// Display label (sidebar text). + pub label: &'static str, + /// Resolved filesystem path. May be `None` if $HOME couldn't be + /// resolved for the home-relative entries. + pub path: Option, + /// Icon to display next to the label. + pub icon: crate::icons::Icon, +} + +/// Resolve $HOME at runtime. Returns None if HOME isn't set and the +/// fallback path doesn't exist either. +/// +/// Production callers use this. Tests should call `defaults_with(home)` +/// directly with an explicit home path. +pub fn home_dir() -> Option { + if let Some(h) = std::env::var_os("HOME") { + return Some(PathBuf::from(h)); + } + // Fallback per user spec wording. + let fallback = PathBuf::from("/home/userdir"); + if fallback.is_dir() { + Some(fallback) + } else { + None + } +} + +/// Return the hardcoded list of default locations, using $HOME resolved +/// from the environment. Called fresh on each sidebar render so $HOME +/// resolution stays current (rare to change at runtime, but cheap to +/// recompute). +/// +/// Entries whose path doesn't resolve (e.g. /var/run/media on a system +/// that doesn't use udisks2) are still shown — clicking them navigates +/// there and surfaces a "cannot read" error in the status bar, which is +/// more discoverable than silently hiding them. +pub fn defaults() -> Vec { + defaults_with(home_dir()) +} + +/// Same as `defaults()` but takes an explicit home directory. Used by +/// tests so they don't have to mutate the `HOME` env var. +pub fn defaults_with(home: Option) -> Vec { + let mut out = Vec::with_capacity(6); + + out.push(DefaultLocation { + label: "mnt", + path: Some(PathBuf::from("/mnt")), + icon: crate::icons::Icon::Device, + }); + + out.push(DefaultLocation { + label: "removable", + path: Some(PathBuf::from("/var/run/media")), + icon: crate::icons::Icon::Device, + }); + + out.push(DefaultLocation { + label: "opt", + path: Some(PathBuf::from("/opt")), + icon: crate::icons::Icon::Folder, + }); + + out.push(DefaultLocation { + label: "src", + path: Some(PathBuf::from("/usr/src")), + icon: crate::icons::Icon::Folder, + }); + + if let Some(h) = &home { + out.push(DefaultLocation { + label: "Home", + path: Some(h.clone()), + icon: crate::icons::Icon::Folder, + }); + out.push(DefaultLocation { + label: "Downloads", + path: Some(h.join("Downloads")), + icon: crate::icons::Icon::Folder, + }); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_include_mnt_opt_src() { + let d = defaults_with(Some(PathBuf::from("/home/testuser"))); + let paths: Vec = d + .iter() + .filter_map(|x| x.path.as_ref().map(|p| p.to_string_lossy().into_owned())) + .collect(); + assert!(paths.iter().any(|p| p == "/mnt")); + assert!(paths.iter().any(|p| p == "/opt")); + assert!(paths.iter().any(|p| p == "/usr/src")); + assert!(paths.iter().any(|p| p == "/var/run/media")); + } + + #[test] + fn defaults_include_home_and_downloads_when_home_provided() { + let d = defaults_with(Some(PathBuf::from("/home/testuser"))); + let has_home = d.iter().any(|x| x.label == "Home" && x.path.as_deref() == Some(std::path::Path::new("/home/testuser"))); + let has_downloads = d.iter().any(|x| x.label == "Downloads" && x.path.as_deref() == Some(std::path::Path::new("/home/testuser/Downloads"))); + assert!(has_home, "Home entry missing or wrong path: {:?}", d.iter().filter(|x| x.label == "Home").collect::>()); + assert!(has_downloads, "Downloads entry missing or wrong path"); + } + + #[test] + fn defaults_omit_home_entries_when_home_is_none() { + // Simulates $HOME unset and /home/userdir not existing. + let d = defaults_with(None); + assert!(!d.iter().any(|x| x.label == "Home")); + assert!(!d.iter().any(|x| x.label == "Downloads")); + // But the non-home entries are still present. + assert!(d.iter().any(|x| x.label == "mnt")); + assert!(d.iter().any(|x| x.label == "opt")); + assert!(d.iter().any(|x| x.label == "src")); + assert!(d.iter().any(|x| x.label == "removable")); + } + + #[test] + fn defaults_total_count() { + // 4 fixed + 2 home-relative = 6 when home is provided + let d = defaults_with(Some(PathBuf::from("/home/testuser"))); + assert_eq!(d.len(), 6); + // 4 fixed only when home is None + let d = defaults_with(None); + assert_eq!(d.len(), 4); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..7f4069c --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,162 @@ +//! Config engine: TOML-backed user configuration. +//! +//! Two files live under `~/.config/runar/`: +//! +//! * `bookmarks.toml` — purely user-added paths (no XDG defaults injected, +//! per project spec: "Excluded: Default hardcoded XDG +//! directories (~/Downloads, ~/Documents, etc.)"). +//! +//! * `actions.toml` — file-extension / MIME-type → shell command overrides. +//! Checked before `xdg-open`/`open::that` fallback. +//! +//! Both files are created empty on first run. The engine is fail-soft: if +//! a file is missing or malformed, the corresponding config is treated as +//! empty and the app still launches. +//! +//! ## Testing note +//! +//! The directory-resolution functions (`config_dir`, `config_dir_with`) +//! take an explicit override parameter so tests don't have to mutate +//! `XDG_CONFIG_HOME` / `HOME` env vars (which is a flakiness risk under +//! parallel `cargo test` and an `unsafe` operation on Rust 1.82+). + +pub mod bookmarks; +pub mod actions; +pub mod defaults; + +pub use actions::Action; +pub use bookmarks::Bookmarks; +pub use defaults::{defaults as default_locations, DefaultLocation}; + +use std::path::{Path, PathBuf}; + +/// Combined in-memory config, loaded once at startup and re-saved on edit. +#[derive(Debug, Clone, Default)] +pub struct Config { + pub bookmarks: Bookmarks, + pub actions: Vec, +} + +/// Where config files live. Resolved via `XDG_CONFIG_HOME` → `~/.config`, +/// then suffixed with `runar/`. Created on first save if missing. +/// +/// Production callers use this. Tests should call `config_dir_with` to +/// inject an explicit base path (avoids env-var mutation races under +/// parallel test execution). +pub fn config_dir() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|h| { + let mut p = PathBuf::from(h); + p.push(".config"); + p + }) + }) + .unwrap_or_else(|| PathBuf::from(".config")); + config_dir_with(&base) +} + +/// Same as `config_dir` but takes an explicit base directory (the parent +/// of the `runar/` suffix). Used by tests so they don't have to touch +/// `XDG_CONFIG_HOME`. +pub fn config_dir_with(base: &Path) -> PathBuf { + let mut p = base.to_path_buf(); + p.push("runar"); + p +} + +impl Config { + /// Load both files from the default config dir. Missing files → + /// empty section. Malformed → empty section plus a logged warning + /// (we never panic on bad config). + pub fn load() -> Self { + Self::load_from(&config_dir()) + } + + /// Load both files from an explicit directory. Used by tests. + pub fn load_from(dir: &Path) -> Self { + let bookmarks = Bookmarks::load(dir); + let actions = Action::load_all(dir); + Self { bookmarks, actions } + } + + /// Persist both files atomically (write-to-temp + fsync + rename). + /// Creates the config dir on demand. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&config_dir()) + } + + /// Persist both files to an explicit directory. Used by tests. + pub fn save_to(&self, dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + self.bookmarks.save(dir)?; + Action::save_all(dir, &self.actions)?; + Ok(()) + } + + /// Find the first action whose pattern matches the given path (by + /// extension or MIME type). The launch dispatcher consults this before + /// falling back to `xdg-open`. + pub fn find_action_for(&self, path: &Path) -> Option<&Action> { + let mime = mime_guess::from_path(path).first(); + self.actions.iter().find(|a| a.matches(path, mime.as_ref())) + } + + /// Add a bookmark (deduped). Caller is responsible for `save()`. + pub fn add_bookmark(&mut self, path: PathBuf) -> bool { + self.bookmarks.add(path) + } + + /// Insert a bookmark at a specific index (for drag-and-drop reordering). + /// If the path is already bookmarked, it's moved to the new index. + /// Caller is responsible for `save()`. + pub fn insert_bookmark_at(&mut self, path: PathBuf, index: usize) -> bool { + self.bookmarks.insert_at(path, index) + } + + /// Remove a bookmark by exact path. Caller is responsible for `save()`. + pub fn remove_bookmark(&mut self, path: &Path) -> bool { + self.bookmarks.remove(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_config_loads_empty() { + // Use an explicit non-existent dir; no env-var mutation required. + let tmp = std::env::temp_dir().join(format!( + "runar-fm-cfg-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + // Note: dir doesn't exist on disk — Config::load_from should + // treat that as "no config" rather than panicking. + let cfg = Config::load_from(&tmp); + assert!(cfg.bookmarks.paths().is_empty()); + assert!(cfg.actions.is_empty()); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn add_then_remove_bookmark_roundtrip() { + let mut cfg = Config::default(); + let p = PathBuf::from("/tmp/example"); + assert!(cfg.add_bookmark(p.clone())); + assert!(!cfg.add_bookmark(p.clone())); // dedup + assert!(cfg.bookmarks.paths().contains(&p)); + assert!(cfg.remove_bookmark(&p)); + assert!(!cfg.bookmarks.paths().contains(&p)); + } + + #[test] + fn config_dir_with_appends_runar() { + let p = config_dir_with(Path::new("/tmp/test-config-base")); + assert_eq!(p, PathBuf::from("/tmp/test-config-base/runar")); + } +} diff --git a/src/date.rs b/src/date.rs new file mode 100644 index 0000000..6d12e87 --- /dev/null +++ b/src/date.rs @@ -0,0 +1,181 @@ +//! Date formatting — converts `SystemTime` to a `YYYY-MM-DD` string. +//! +//! Uses Howard Hinnant's `civil_from_days` algorithm: exact (leap-year- +//! correct) pure integer math, no `chrono` dependency. +//! +//! References: +//! * https://howardhinnant.github.io/date_algorithms.html +//! +//! The algorithm converts a count of days since the Unix epoch (1970-01-01) +//! into a (year, month, day) triple. Days are taken as i64 to handle +//! pre-epoch dates gracefully (we just bail out on those via the existing +//! `duration_since(UNIX_EPOCH)` error path). + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Format a `SystemTime` as `YYYY-MM-DD`. Returns `"?"` for `None`, +/// pre-epoch times, or any other unresolvable input. +pub fn format_date(t: Option) -> String { + let t = match t { + Some(t) => t, + None => return "?".into(), + }; + let dur = match t.duration_since(UNIX_EPOCH) { + Ok(d) => d, + Err(_) => return "?".into(), + }; + let days = (dur.as_secs() / 86400) as i64; + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Howard Hinnant's `civil_from_days` — converts days-since-1970-01-01 +/// to a proleptic Gregorian (year, month, day) triple. +/// +/// Algorithm validity: any `i64` days count. Months are 1-12, days are +/// 1-31. Leap years are handled correctly. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; // shift epoch from 1970-01-01 to 0000-03-01 + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146_097] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let y = if m <= 2 { y + 1 } else { y }; + (y, m as u32, d as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epoch_is_1970_01_01() { + // 0 days since epoch = 1970-01-01 + assert_eq!(civil_from_days(0), (1970, 1, 1)); + } + + #[test] + fn epoch_plus_one_day() { + // 1 day after epoch = 1970-01-02 + assert_eq!(civil_from_days(1), (1970, 1, 2)); + } + + #[test] + fn epoch_minus_one_day() { + // 1 day before epoch = 1969-12-31 + assert_eq!(civil_from_days(-1), (1969, 12, 31)); + } + + #[test] + fn one_year_after_epoch() { + // 1970 was not a leap year, so 365 days after 1970-01-01 = 1971-01-01 + assert_eq!(civil_from_days(365), (1971, 1, 1)); + } + + #[test] + fn four_years_after_epoch() { + // 1972 was a leap year. Days from 1970-01-01 to 1974-01-01: + // 1970: 365, 1971: 365, 1972: 366, 1973: 365 = 1461 + assert_eq!(civil_from_days(1461), (1974, 1, 1)); + } + + #[test] + fn leap_year_1972() { + // 1972 is a leap year (divisible by 4, not by 100). + // 1972-01-01 = 730 days after 1970-01-01 (365 + 365). + assert_eq!(civil_from_days(730), (1972, 1, 1)); + // 1972-02-29 = 730 + 31 (Jan) + 28 (Feb 1-28) = 789 + assert_eq!(civil_from_days(789), (1972, 2, 29)); + // 1972-03-01 = 790 + assert_eq!(civil_from_days(790), (1972, 3, 1)); + } + + #[test] + fn non_leap_year_1973() { + // 1973 is not a leap year. 1973-02-28 = 1972-12-31 + 31 + 27 = 1125 + 58 = 1125 + // Let's just verify 1973-02-28 exists and 1973-03-01 follows. + // 1973-01-01 = 1096 (1970 + 1971 + 1972 = 365+365+366 = 1096) + assert_eq!(civil_from_days(1096), (1973, 1, 1)); + // 1973-02-28 = 1096 + 31 (Jan) + 27 (Feb 1-28 is 28 days, so Feb 28 is +27 from Feb 1) + // Actually: Jan has 31 days, so Feb 1 = 1096 + 31 = 1127. Feb 28 = 1127 + 27 = 1154. + assert_eq!(civil_from_days(1154), (1973, 2, 28)); + // 1973-03-01 = 1155 (no Feb 29 in 1973) + assert_eq!(civil_from_days(1155), (1973, 3, 1)); + } + + #[test] + fn year_2000_is_leap() { + // 2000 IS a leap year (divisible by 400, despite being divisible by 100). + // 2000-01-01: 30 years from 1970. Leap years in 1970-1999: 1972, 76, 80, 84, 88, 92, 96 = 7. + // Days = 30*365 + 7 = 10_957. + assert_eq!(civil_from_days(10_957), (2000, 1, 1)); + // 2000-02-29 = 10_957 + 31 (Jan) + 28 (Feb 1-28) = 11_016 + assert_eq!(civil_from_days(11_016), (2000, 2, 29)); + // 2000-03-01 = 11_017 + assert_eq!(civil_from_days(11_017), (2000, 3, 1)); + } + + #[test] + fn year_2100_is_not_leap() { + // 2100 is NOT a leap year (divisible by 100, not by 400). + // 2100-01-01: 130 years from 1970. Leap years 1972..2096: every 4 years = 32, + // minus 2100 itself (not in range) = 32. But 2000 was a leap year (div by 400). + // So count: 1972, 76, ..., 2096 = (2096-1972)/4 + 1 = 32. None excluded in range. + // Days = 130*365 + 32 = 47_482. + assert_eq!(civil_from_days(47_482), (2100, 1, 1)); + // 2100-02-28 = 47_482 + 31 + 27 = 47_540 + assert_eq!(civil_from_days(47_540), (2100, 2, 28)); + // 2100-03-01 = 47_541 (NO Feb 29 in 2100) + assert_eq!(civil_from_days(47_541), (2100, 3, 1)); + } + + #[test] + fn year_1900_is_not_leap() { + // 1900 is NOT a leap year (divisible by 100, not by 400). + // 1900-01-01 = -25_569 days from 1970-01-01. + // (1900 to 1970 = 70 years. Leap years 1904..1968 = (1968-1904)/4 + 1 = 17. + // 1900 is not leap, so 17 leap years in range. Days = 70*365 + 17 = 25_567. + // So 1900-01-01 = -25_567 from 1970-01-01.) + assert_eq!(civil_from_days(-25_567), (1900, 1, 1)); + // 1900-02-28 = -25_567 + 31 + 27 = -25_509 + assert_eq!(civil_from_days(-25_509), (1900, 2, 28)); + // 1900-03-01 = -25_508 (no Feb 29 in 1900) + assert_eq!(civil_from_days(-25_508), (1900, 3, 1)); + } + + #[test] + fn y2k_rollover() { + // 1999-12-31 → 2000-01-01 transition. + // 1999-12-31 = 10_956 (one day before 2000-01-01 = 10_957). + assert_eq!(civil_from_days(10_956), (1999, 12, 31)); + assert_eq!(civil_from_days(10_957), (2000, 1, 1)); + } + + #[test] + fn format_date_none() { + assert_eq!(format_date(None), "?"); + } + + #[test] + fn format_date_pre_epoch_returns_question_mark() { + // Pre-epoch SystemTime can't be expressed as duration_since(UNIX_EPOCH), + // so format_date returns "?" for them. + let t = Some(SystemTime::UNIX_EPOCH - std::time::Duration::from_secs(86400)); + assert_eq!(format_date(t), "?"); + } + + #[test] + fn format_date_produces_yyyy_mm_dd_shape() { + let t = Some(SystemTime::now()); + let s = format_date(t); + assert_eq!(s.len(), 10, "expected YYYY-MM-DD (10 chars), got: {s}"); + assert_eq!(s.chars().nth(4), Some('-')); + assert_eq!(s.chars().nth(7), Some('-')); + // Year should start with 2 (we're in the 2000s for the foreseeable future) + assert_eq!(s.chars().next(), Some('2')); + } +} diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..0f9b50f --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,213 @@ +//! Inline SVG icon set. +//! +//! Baked into the binary as string constants — no system icon theme +//! lookup, no file I/O at runtime, no `hicolor/Papirus/...` dependency. +//! This keeps the "static binary, works on a fresh minimal install" +//! promise from the project spec. +//! +//! Icons are 16×16 (matching the file-manager scale). iced renders them +//! via the `svg` widget (`iced::widget::svg::Svg::from_data`). +//! +//! ## Performance note +//! +//! Each icon's `svg::Handle` is built ONCE and cached in a `LazyLock`. +//! This matters because the file grid can re-render many times per +//! second during scrolling or directory scans, and each row rebuilds +//! its icon widget. Without caching, every visible row would re-parse +//! the SVG markup from scratch on every frame — visible CPU burn with +//! 1000+ entries. With caching, the parse happens at most once per icon +//! variant for the lifetime of the process. + +use iced::widget::svg::{Handle, Svg}; +use std::sync::LazyLock; + +/// Glyphs available throughout the UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Icon { + Folder, + File, + AppDir, + Symlink, + Device, + Network, + Bookmark, + Up, + Edit, + Reload, +} + +impl Icon { + /// Raw SVG markup for this icon. Stroke/fill use accent colors that + /// render fine on both light and dark iced themes. + /// + /// Marked `allow(dead_code)` because production code uses the cached + /// `LazyLock` statics directly (see `handle()`), but the test + /// module calls this to verify each variant has distinct markup. + #[allow(dead_code)] + fn svg(self) -> &'static str { + match self { + Icon::Folder => FOLDER_SVG, + Icon::File => FILE_SVG, + Icon::AppDir => APPDIR_SVG, + Icon::Symlink => SYMLINK_SVG, + Icon::Device => DEVICE_SVG, + Icon::Network => NETWORK_SVG, + Icon::Bookmark => BOOKMARK_SVG, + Icon::Up => UP_SVG, + Icon::Edit => EDIT_SVG, + Icon::Reload => RELOAD_SVG, + } + } + + /// Cached `svg::Handle` for this icon. Built once on first access, + /// reused for every subsequent `widget()` call. The handle owns the + /// SVG bytes; iced's renderer caches the parsed raster internally + /// keyed by handle identity. + fn handle(self) -> &'static Handle { + // One LazyLock per icon variant. Indexed by the Icon enum so + // callers don't pay for variants they never use. + match self { + Icon::Folder => &FOLDER_HANDLE, + Icon::File => &FILE_HANDLE, + Icon::AppDir => &APPDIR_HANDLE, + Icon::Symlink => &SYMLINK_HANDLE, + Icon::Device => &DEVICE_HANDLE, + Icon::Network => &NETWORK_HANDLE, + Icon::Bookmark => &BOOKMARK_HANDLE, + Icon::Up => &UP_HANDLE, + Icon::Edit => &EDIT_HANDLE, + Icon::Reload => &RELOAD_HANDLE, + } + } + + /// Build an `iced::widget::svg::Svg` for this icon at a given pixel size. + /// The underlying `Handle` is cached (see `handle()`), so repeated + /// calls don't re-parse the SVG markup. The Handle is `Arc`-backed + /// internally, so cloning it here is a cheap refcount bump. + pub fn widget(self, size: f32) -> Svg<'static> { + Svg::new(self.handle().clone()) + .width(size) + .height(size) + } +} + +// --- Cached handles ---------------------------------------------------------- +// Each LazyLock wraps a Handle built from the corresponding SVG markup. +// First access pays the parse cost; subsequent accesses are free. + +static FOLDER_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(FOLDER_SVG.as_bytes().to_vec())); +static FILE_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(FILE_SVG.as_bytes().to_vec())); +static APPDIR_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(APPDIR_SVG.as_bytes().to_vec())); +static SYMLINK_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(SYMLINK_SVG.as_bytes().to_vec())); +static DEVICE_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(DEVICE_SVG.as_bytes().to_vec())); +static NETWORK_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(NETWORK_SVG.as_bytes().to_vec())); +static BOOKMARK_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(BOOKMARK_SVG.as_bytes().to_vec())); +static UP_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(UP_SVG.as_bytes().to_vec())); +static EDIT_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(EDIT_SVG.as_bytes().to_vec())); +static RELOAD_HANDLE: LazyLock = + LazyLock::new(|| Handle::from_memory(RELOAD_SVG.as_bytes().to_vec())); + +// --- SVG definitions --------------------------------------------------------- +// Minimal flat style, 16×16 viewBox, no external refs. Colors chosen to +// read on both light/dark themes: +// * folders/devices → #5294e2 (steel blue) +// * appdirs → #4db6ac (teal — distinguishes "launchable" dirs) +// * files → #888 (neutral gray) +// * symlinks → #ffa726 (amber, like a redirect indicator) +// * network → #ab47bc (purple) +// * bookmarks → #fdd835 (yellow star) + +const FOLDER_SVG: &str = r##" + + +"##; + +const FILE_SVG: &str = r##" + + +"##; + +const APPDIR_SVG: &str = r##" + + +"##; + +const SYMLINK_SVG: &str = r##" + + +"##; + +const DEVICE_SVG: &str = r##" + + + +"##; + +const NETWORK_SVG: &str = r##" + + +"##; + +const BOOKMARK_SVG: &str = r##" + +"##; + +const UP_SVG: &str = r##" + +"##; + +const EDIT_SVG: &str = r##" + +"##; + +const RELOAD_SVG: &str = r##" + +"##; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn widget_returns_same_handle_instance() { + // Each call to widget() should reuse the cached handle, not + // build a new one. We can't compare Handles directly (no PartialEq + // impl in iced 0.13), but we can verify the function doesn't + // panic and produces a non-zero-sized Svg. + let _w1 = Icon::Folder.widget(14.0); + let _w2 = Icon::Folder.widget(16.0); + let _w3 = Icon::File.widget(14.0); + // If we got here without panicking, the LazyLocks initialized OK. + } + + #[test] + fn all_variants_have_distinct_svg() { + // Smoke test: ensure no two icons accidentally share the same SVG. + let svgs = [ + Icon::Folder.svg(), + Icon::File.svg(), + Icon::AppDir.svg(), + Icon::Symlink.svg(), + Icon::Device.svg(), + Icon::Network.svg(), + Icon::Bookmark.svg(), + Icon::Up.svg(), + Icon::Edit.svg(), + Icon::Reload.svg(), + ]; + for i in 0..svgs.len() { + for j in (i + 1)..svgs.len() { + assert_ne!(svgs[i], svgs[j], "icons at indices {i} and {j} share SVG"); + } + } + } +} diff --git a/src/launch.rs b/src/launch.rs new file mode 100644 index 0000000..b39646f --- /dev/null +++ b/src/launch.rs @@ -0,0 +1,344 @@ +//! Launch dispatcher — Phase 3 of the project manifest. +//! +//! Decision tree (applied on double-click or Enter on an entry): +//! +//! 1. AppDir → spawn the inner launcher directly (AppRun or same-name +//! executable). No shell, no `$PATH` lookup — the AppDir's +//! own executable is trusted. +//! +//! 2. File → consult, in order: +//! a. `actions.toml` (user overrides — highest priority). +//! Supports a fallback chain: `commands = [...]`. The +//! first command whose binary is on `$PATH` wins. +//! b. Built-in default MIME table (`src/mime.rs`). The +//! default command strings use shell-level fallback +//! chains (e.g. `${EDITOR:-vi}`, the scitano→scite→ +//! geany→nano loop) so they work on any system. +//! c. `open::that` (xdg-open on Linux) — final fallback. +//! +//! 3. Directory / Symlink → handled by the UI layer (navigation). This +//! module is invoked only for "open" actions. +//! +//! All launches are detached: the child process is spawned and not +//! waited on. Errors are returned synchronously only if `spawn()` itself +//! fails (e.g. missing binary); child-side failures surface to the user +//! in the child's terminal. + +use crate::config::Config; +use crate::vfs::EntryKind; +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; + +/// Outcome of a launch attempt, for UI feedback. +#[derive(Debug)] +pub enum LaunchResult { + /// Successfully spawned. Carries a human-readable description for the + /// status bar (e.g. "launched vim /tmp/notes.txt"). + Spawned(String), + /// Nothing matched and the dispatcher declined to spawn (e.g. no + /// action and `open::that` not applicable). UI shows "no handler". + NoHandler, + /// Spawn failed. The user should see this. + Failed(String), +} + +/// Launch `path` according to its kind, consulting the user's action +/// overrides before falling back to system defaults. +pub async fn launch(path: &Path, kind: &EntryKind, config: &Config) -> LaunchResult { + match kind { + EntryKind::AppDir { launcher } => spawn_direct(launcher, &[]).await, + EntryKind::File => launch_file(path, config).await, + // Symlinks are resolved by the UI before calling launch(). + // Directory and Symlink are no-ops here. + EntryKind::Symlink { .. } | EntryKind::Directory => LaunchResult::NoHandler, + } +} + +/// File-launch step-down: try user overrides, then built-in MIME table, +/// then `xdg-open`. Each step either returns a `LaunchResult` or falls +/// through to the next. +async fn launch_file(path: &Path, config: &Config) -> LaunchResult { + // 1. User overrides from actions.toml (highest priority). + if let Some(result) = try_user_action(path, config).await { + return result; + } + // 2. Built-in default MIME table. + if let Some(result) = try_builtin_default(path).await { + return result; + } + // 3. xdg-open fallback. + try_xdg_open(path) +} + +/// Step 1: user-defined `actions.toml` overrides. Returns `None` if no +/// action matches OR if the action's command chain has no binary on +/// `$PATH` (in which case we fall through to the built-in table). +async fn try_user_action(path: &Path, config: &Config) -> Option { + let action = config.find_action_for(path)?; + let commands: Vec = action + .commands_to_try() + .into_iter() + .map(|c| c.replace("{}", &shell_quote(path))) + .collect(); + try_command_chain(&commands).await +} + +/// Step 2: built-in default MIME table. +async fn try_builtin_default(path: &Path) -> Option { + let default = crate::mime::default_action_for_path(path)?; + let cmd = default.command.replace("{}", &shell_quote(path)); + Some(run_shell(&cmd).await) +} + +/// Step 3: `xdg-open` final fallback. +fn try_xdg_open(path: &Path) -> LaunchResult { + match open::that(path) { + Ok(()) => LaunchResult::Spawned(format!("opened {}", path.display())), + Err(e) => LaunchResult::Failed(format!("open: {e}")), + } +} + +/// Walk the command chain. Returns `Some(LaunchResult)` for the first +/// command whose leading binary is on `$PATH` (or whose leading binary +/// cannot be statically resolved — those are tried blindly). Returns +/// `None` if every command's leading binary is missing from `$PATH`. +/// +/// "Leading binary" extraction: parse the command's first word (after +/// any env-var assignments) and check `which`-style whether it is on +/// `$PATH`. This is a heuristic — full shell semantics would require +/// invoking `sh -c` — but the cost of a failed spawn justifies the +/// pre-check. +async fn try_command_chain(commands: &[String]) -> Option { + // Iterate manually because `run_shell` is async and closures passed + // to `find_map` cannot be async. Each iteration either returns a + // LaunchResult (binary found or unresolvable — try it) or continues + // to the next command (binary missing from `$PATH`). + for cmd in commands { + match extract_leading_binary(cmd) { + Some(bin) if !binary_on_path(&bin).await => { + log::debug!("launch chain: {bin} not on PATH, trying next"); + continue; + } + _ => return Some(run_shell(cmd).await), + } + } + None +} + +/// Extract the leading binary name from a shell command string. +/// +/// Handles common cases: +/// * `vim /tmp/foo.txt` → "vim" +/// * `foot -e vim /tmp/foo.txt` → "foot" +/// * `${EDITOR:-vi} /tmp/foo.txt` → None (env-var expansion — defer to sh) +/// * `sh -c '...complex...'` → "sh" +/// +/// Returns `None` for commands starting with something that cannot be +/// statically resolved (env vars, command substitution). The caller +/// tries those blindly without a pre-check. +fn extract_leading_binary(cmd: &str) -> Option { + let mut rest = cmd.trim_start(); + if rest.is_empty() { + return None; + } + loop { + let first_word = rest.split_whitespace().next()?; + match parse_leading_token(first_word) { + LeadingToken::EnvAssignment => { + rest = &rest[first_word.len()..]; + } + LeadingToken::EnvVarExpansion => return None, + LeadingToken::Binary(name) => return Some(name), + } + } +} + +/// Classify the first whitespace-delimited token of a command. +enum LeadingToken { + /// `VAR=value` — skip and continue scanning. + EnvAssignment, + /// `$VAR` or `${VAR:-default}` — defer to the shell. + EnvVarExpansion, + /// A concrete binary name. + Binary(String), +} + +/// Parse a single leading token. Step-down: each guard returns early. +fn parse_leading_token(word: &str) -> LeadingToken { + if word.starts_with('$') { + return LeadingToken::EnvVarExpansion; + } + if let Some(eq_pos) = word.find('=') { + let name = &word[..eq_pos]; + if is_valid_env_var_name(name) { + return LeadingToken::EnvAssignment; + } + } + LeadingToken::Binary(word.to_string()) +} + +/// True if `name` is a valid POSIX environment variable name: non-empty, +/// alphanumeric + underscore, not starting with a digit. +fn is_valid_env_var_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with(|c: char| c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// True if `binary` is found on `$PATH`. Implements `which` logic +/// manually to avoid pulling in the `which` crate. If `$PATH` is unset, +/// falls back to `/usr/bin:/bin` per POSIX. +async fn binary_on_path(binary: &str) -> bool { + // Absolute or relative path: check the file directly. + if binary.contains('/') { + return std::path::Path::new(binary).is_file(); + } + let path = std::env::var_os("PATH") + .unwrap_or_else(|| std::ffi::OsString::from("/usr/bin:/bin")); + std::env::split_paths(&path).any(|dir| is_executable(&dir.join(binary))) +} + +/// True if `path` is a regular file with at least one executable bit set. +fn is_executable(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + meta.is_file() && (meta.permissions().mode() & 0o111 != 0) +} + +/// Spawn an executable directly (no shell). Used for AppDir launchers. +async fn spawn_direct(exe: &Path, args: &[&str]) -> LaunchResult { + match Command::new(exe) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(_) => LaunchResult::Spawned(format!("launched {}", exe.display())), + Err(e) => LaunchResult::Failed(format!("spawn {}: {e}", exe.display())), + } +} + +/// Run a shell command string via `sh -c`. Used for `actions.toml` +/// overrides and built-in default actions. The user has full shell +/// syntax available, including `${EDITOR:-vi}` env-var expansion. +async fn run_shell(cmd: &str) -> LaunchResult { + match Command::new("sh") + .arg("-c") + .arg(cmd) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(_) => LaunchResult::Spawned(format!("ran: {cmd}")), + Err(e) => LaunchResult::Failed(format!("sh -c: {e}")), + } +} + +/// Quote a path for safe inclusion in a shell command. Single-quote, +/// escaping any embedded single-quotes with the `'\''` idiom. +fn shell_quote(path: &Path) -> String { + let s = path.to_string_lossy(); + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + s.chars().for_each(|c| match c { + '\'' => out.push_str("'\\''"), + _ => out.push(c), + }); + out.push('\''); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quote_simple_path() { + assert_eq!(shell_quote(Path::new("/tmp/notes.txt")), "'/tmp/notes.txt'"); + } + + #[test] + fn quote_path_with_space() { + assert_eq!( + shell_quote(Path::new("/tmp/my file.txt")), + "'/tmp/my file.txt'" + ); + } + + #[test] + fn quote_path_with_single_quote() { + assert_eq!(shell_quote(Path::new("/tmp/it's")), "'/tmp/it'\\''s'"); + } + + #[test] + fn extract_leading_binary_simple() { + assert_eq!(extract_leading_binary("vim /tmp/foo"), Some("vim".into())); + assert_eq!( + extract_leading_binary("foot -e vim /tmp/foo"), + Some("foot".into()) + ); + } + + #[test] + fn extract_leading_binary_with_env_assignment() { + assert_eq!( + extract_leading_binary("EDITOR=vim /tmp/foo"), + Some("/tmp/foo".into()) + ); + } + + #[test] + fn extract_leading_binary_env_var_expansion_returns_none() { + assert_eq!(extract_leading_binary("${EDITOR:-vi} /tmp/foo"), None); + assert_eq!(extract_leading_binary("$EDITOR /tmp/foo"), None); + } + + #[test] + fn extract_leading_binary_empty() { + assert_eq!(extract_leading_binary(""), None); + assert_eq!(extract_leading_binary(" "), None); + } + + #[test] + fn extract_leading_binary_sh_dash_c() { + assert_eq!(extract_leading_binary("sh -c 'echo hi'"), Some("sh".into())); + } + + #[test] + fn is_valid_env_var_name_accepts_standard_names() { + assert!(is_valid_env_var_name("EDITOR")); + assert!(is_valid_env_var_name("FOO_BAR")); + assert!(is_valid_env_var_name("A1B2")); + } + + #[test] + fn is_valid_env_var_name_rejects_invalid() { + assert!(!is_valid_env_var_name("")); + assert!(!is_valid_env_var_name("1FOO")); + assert!(!is_valid_env_var_name("FOO-BAR")); + assert!(!is_valid_env_var_name("FOO.BAR")); + } + + #[tokio::test] + async fn binary_on_path_finds_sh() { + assert!(binary_on_path("sh").await); + } + + #[tokio::test] + async fn binary_on_path_rejects_nonexistent() { + assert!(!binary_on_path("definitely-not-a-real-binary-xyz123").await); + } + + #[tokio::test] + async fn binary_on_path_handles_absolute_path() { + assert!(binary_on_path("/bin/sh").await); + assert!(!binary_on_path("/nonexistent/path/to/binary").await); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6bb4206 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,99 @@ +//! runar — a hybrid Thunar/PCManFM + ROX-Filer file manager. +//! +//! Copyright (C) 2026 Jeremy Anderson +//! Licensed under GPL-2.0-only. See LICENSE file for details. +//! +//! Design inspiration (no code copied): +//! * Thunar (Xfce) — dual-pane layout, breadcrumb pathbar +//! * PCManFM — lightweight GTK file manager +//! * ROX-Filer — AppDir paradigm, instant MIME action hooks +//! * Puppy Linux / DSL — ROX-Filer as desktop backbone +//! * SliTaz — minimal-footprint philosophy +//! +//! iced-based UI shell wrapping the Phase 1 VFS engine. The window shows +//! a pathbar, a stripped sidebar (Devices + default Locations + user +//! Bookmarks), and a file grid. All directory I/O happens on background +//! tokio tasks via iced subscriptions; the UI thread never blocks. +//! +//! Key bindings (Phase 2 keyboard-first navigation): +//! * Arrow Up / Down — move selection +//! * J / K — same (vim-style) +//! * H / L — go up / enter selection +//! * Enter — activate selection (open / launch / navigate) +//! * Backspace — go up one directory +//! * / or Ctrl+L — toggle pathbar edit mode +//! * Escape — exit pathbar edit mode (only when editing) +//! * Shift+Enter — on an AppDir, enter it as a directory +//! +//! Usage: runar [directory] (defaults to current directory) + +// Clippy's `doc_overindented_list_items` lint flags continuation lines +// in our doc comments (lines indented to align with a preceding bullet +// item's text) as misindented list items. They're not list items — +// they're prose continuations — but clippy can't tell the difference. +// Suppress crate-wide rather than littering `#[allow]` on every doc +// block. +#![allow(clippy::doc_overindented_list_items)] + +mod config; +mod date; +mod icons; +mod launch; +mod mime; +mod mounts; +mod ui; +mod vfs; + +use iced::keyboard::on_key_press; +use iced::{application, Subscription}; +use std::path::PathBuf; +use ui::{App, Message}; + +fn main() -> iced::Result { + env_logger::init(); + + let initial_dir = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))); + + // Resolve to a canonical absolute path so breadcrumbs render cleanly. + let initial_dir = std::fs::canonicalize(&initial_dir).unwrap_or(initial_dir); + + // The first arg to `application()` is the title — either a `&'static str` + // or a `Fn(&State) -> String`. We use the closure form so the title + // reflects the current directory. + application( + |app: &App| format!("runar — {}", app.current_dir().display()), + ui::update, + ui::view, + ) + .theme(|_| iced::Theme::Dark) + .subscription(subscription) + .run_with(move || (App::new(initial_dir), iced::Task::none())) +} + +/// Per-app subscription aggregator: combine the VFS/watch/mount +/// subscriptions from `ui::subscription` with a global key-press +/// subscription for keyboard navigation. +fn subscription(app: &App) -> Subscription { + // Keyboard handling: iced's `on_key_press` requires a bare `fn` (not a + // closure), so app state cannot be captured here. Instead, emit a + // `KeyPressed(key, modifiers)` Message for every key press and let + // `ui::update` perform the stateful interpretation. This keeps the + // keyboard handler in `update()` where `&mut App` is available, so + // Escape closes only the topmost open layer (About → context menu → + // menubar → pathbar edit) rather than toggling state blindly. + let key_sub = on_key_press(key_press_bridge); + Subscription::batch([ui::subscription(app), key_sub]) +} + +/// Stateless bridge: wrap the raw (Key, Modifiers) pair into a Message +/// and let `update()` interpret it. Returns `None` for key events we +/// never care about (so they propagate to widgets like text_input). +fn key_press_bridge( + key: iced::keyboard::Key, + modifiers: iced::keyboard::Modifiers, +) -> Option { + Some(Message::KeyPressed(key, modifiers)) +} diff --git a/src/mime.rs b/src/mime.rs new file mode 100644 index 0000000..1253f82 --- /dev/null +++ b/src/mime.rs @@ -0,0 +1,401 @@ +//! MIME type detection and built-in default action table. +//! +//! `mime_guess` resolves file extensions to MIME types. We layer a +//! table-driven action mapping on top so common file types open in +//! sensible applications out-of-the-box, without requiring the user to +//! write an `actions.toml`. +//! +//! ## Resolution order +//! +//! When the user activates a file, the launch dispatcher consults these +//! sources in order: +//! 1. `~/.config/runar/actions.toml` — user overrides (highest priority) +//! 2. Built-in default table in this module +//! 3. `open::that` (xdg-open on Linux) — final fallback +//! +//! ## Editor fallback chain +//! +//! Text-file defaults use a shell-level fallback chain rather than a +//! single binary, so they work on any system with at least one +//! reasonable editor installed. The chain, in order: +//! +//! 1. `$EDITOR` (if set — always wins, matches user's shell convention) +//! 2. `scitano` +//! 3. `scite` +//! 4. `geany` +//! 5. `nano` (near-universal on Linux, terminal-based) +//! +//! `$EDITOR` is checked first via `if [ -n "$EDITOR" ]`; the remaining +//! binaries are tried via `command -v` and the first match is exec'd. +//! `nano` is the final fallback because it is near-universal and +//! terminal-based. + +use std::path::Path; + +/// A built-in default action for a category of files. +#[derive(Debug, Clone)] +pub struct DefaultAction { + /// Human-readable label for the action (e.g. "open in editor"). + /// Reserved for future use in a right-click context menu. + #[allow(dead_code)] + pub label: &'static str, + /// Shell command template. `{}` is substituted with the shell-quoted + /// file path. + pub command: &'static str, +} + +/// Shell snippet that walks the editor fallback chain. Used by the +/// text/*, application/json, application/x-shellscript, etc. defaults. +/// +/// Order: $EDITOR → scitano → scite → geany → nano. `$EDITOR` wins +/// unconditionally when set (matches shell convention). Otherwise each +/// binary is tried via `command -v` and the first match is exec'd. +const EDITOR_CHAIN: &str = "\ +if [ -n \"$EDITOR\" ]; then exec $EDITOR {}; fi; \ +for e in scitano scite geany nano; do \ + if command -v \"$e\" >/dev/null 2>&1; then exec \"$e\" {}; fi; \ +done; \ +echo 'no editor found (tried: $EDITOR, scitano, scite, geany, nano)' >&2; \ +false"; + +/// How a MIME essence string matches a rule. +#[derive(Debug, Clone, Copy)] +enum Matcher { + /// Exact equality, e.g. `text/html`. + Exact(&'static str), + /// Prefix match, e.g. `text/` matches `text/plain`, `text/html`, etc. + Prefix(&'static str), +} + +/// A single row in the built-in MIME action table. +struct MimeRule { + matcher: Matcher, + action: DefaultAction, +} + +/// The built-in MIME → action table. Evaluated in declaration order; the +/// first matching rule wins. Exact matches must precede prefix matches +/// that would otherwise shadow them (e.g. `text/html` before `text/`). +/// +/// Table-driven dispatch replaces a cascade of if/else branches. Adding +/// a new MIME category is a one-line change: append a row here. +const MIME_RULES: &[MimeRule] = &[ + // HTML → browser. Must precede the text/ prefix rule. + MimeRule { + matcher: Matcher::Exact("text/html"), + action: DefaultAction { + label: "open in browser", + command: "${BROWSER:-xdg-open} {}", + }, + }, + // Text files → editor chain. Catches text/plain, text/markdown, + // text/csv, text/x-rust, etc. + MimeRule { + matcher: Matcher::Prefix("text/"), + action: DefaultAction { + label: "open in editor", + command: EDITOR_CHAIN, + }, + }, + MimeRule { + matcher: Matcher::Prefix("image/"), + action: DefaultAction { + label: "open image", + command: "${IMAGE_VIEWER:-xdg-open} {}", + }, + }, + MimeRule { + matcher: Matcher::Prefix("video/"), + action: DefaultAction { + label: "open video", + command: "${VIDEO_PLAYER:-xdg-open} {}", + }, + }, + MimeRule { + matcher: Matcher::Prefix("audio/"), + action: DefaultAction { + label: "open audio", + command: "${AUDIO_PLAYER:-xdg-open} {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/pdf"), + action: DefaultAction { + label: "open PDF", + command: "${PDF_VIEWER:-xdg-open} {}", + }, + }, + // Archives — list contents rather than extract (safer default). + MimeRule { + matcher: Matcher::Exact("application/zip"), + action: DefaultAction { + label: "list archive contents", + command: "ls -l {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/x-tar"), + action: DefaultAction { + label: "list archive contents", + command: "ls -l {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/gzip"), + action: DefaultAction { + label: "list archive contents", + command: "ls -l {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/x-bzip2"), + action: DefaultAction { + label: "list archive contents", + command: "ls -l {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/x-xz"), + action: DefaultAction { + label: "list archive contents", + command: "ls -l {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/x-7z-compressed"), + action: DefaultAction { + label: "list archive contents", + command: "ls -l {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/x-rar"), + action: DefaultAction { + label: "list archive contents", + command: "ls -l {}", + }, + }, + MimeRule { + matcher: Matcher::Exact("application/x-shellscript"), + action: DefaultAction { + label: "open in editor", + command: EDITOR_CHAIN, + }, + }, + MimeRule { + matcher: Matcher::Exact("application/json"), + action: DefaultAction { + label: "open in editor", + command: EDITOR_CHAIN, + }, + }, + MimeRule { + matcher: Matcher::Exact("application/yaml"), + action: DefaultAction { + label: "open in editor", + command: EDITOR_CHAIN, + }, + }, + MimeRule { + matcher: Matcher::Exact("application/x-yaml"), + action: DefaultAction { + label: "open in editor", + command: EDITOR_CHAIN, + }, + }, + MimeRule { + matcher: Matcher::Exact("application/toml"), + action: DefaultAction { + label: "open in editor", + command: EDITOR_CHAIN, + }, + }, + MimeRule { + matcher: Matcher::Exact("application/xml"), + action: DefaultAction { + label: "open in editor", + command: EDITOR_CHAIN, + }, + }, +]; + +/// Classify a file by its MIME type and return the built-in default +/// action, if any. Returns `None` for MIME types with no built-in rule; +/// the caller falls back to `xdg-open`. +/// +/// `mime_type` is the result of `mime_guess::from_path(path).first()` +/// (already computed by the VFS scanner and stored in +/// `FileEntry.mime_type`). The charset suffix (`; charset=utf-8`) is +/// stripped before matching. +pub fn default_action_for(mime_type: Option<&str>) -> Option { + let mime = mime_type?; + let essence = mime.split(';').next().unwrap_or(mime); + MIME_RULES + .iter() + .find(|rule| match rule.matcher { + Matcher::Exact(s) => essence == s, + Matcher::Prefix(s) => essence.starts_with(s), + }) + .map(|rule| rule.action.clone()) +} + +/// Convenience: classify a path directly (resolves MIME internally). +pub fn default_action_for_path(path: &Path) -> Option { + let essence: Option = mime_guess::from_path(path) + .first() + .map(|m| m.essence_str().to_string()); + default_action_for(essence.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_files_use_editor_chain() { + let action = default_action_for(Some("text/plain")).unwrap(); + assert_eq!(action.label, "open in editor"); + assert!(action.command.contains("$EDITOR")); + assert!(action.command.contains("scitano")); + assert!(action.command.contains("scite")); + assert!(action.command.contains("geany")); + assert!(action.command.contains("nano")); + } + + #[test] + fn text_subtypes_get_editor() { + assert!(default_action_for(Some("text/markdown")).is_some()); + assert!(default_action_for(Some("text/csv")).is_some()); + assert!(default_action_for(Some("text/x-rust")).is_some()); + } + + #[test] + fn images_get_viewer() { + let action = default_action_for(Some("image/png")).unwrap(); + assert!(action.command.contains("${IMAGE_VIEWER:-xdg-open}")); + assert!(default_action_for(Some("image/jpeg")).is_some()); + assert!(default_action_for(Some("image/svg+xml")).is_some()); + assert!(default_action_for(Some("image/gif")).is_some()); + } + + #[test] + fn video_gets_player() { + let action = default_action_for(Some("video/mp4")).unwrap(); + assert!(action.command.contains("${VIDEO_PLAYER:-xdg-open}")); + } + + #[test] + fn audio_gets_player() { + let action = default_action_for(Some("audio/mpeg")).unwrap(); + assert!(action.command.contains("${AUDIO_PLAYER:-xdg-open}")); + } + + #[test] + fn pdf_gets_viewer() { + let action = default_action_for(Some("application/pdf")).unwrap(); + assert_eq!(action.label, "open PDF"); + assert!(action.command.contains("${PDF_VIEWER:-xdg-open}")); + } + + #[test] + fn html_gets_browser() { + let action = default_action_for(Some("text/html")).unwrap(); + assert!(action.command.contains("${BROWSER:-xdg-open}")); + } + + #[test] + fn html_takes_precedence_over_text_prefix() { + // text/html must match the browser rule, not the editor rule. + // This verifies that exact matches precede prefix matches in the + // MIME_RULES table. + let action = default_action_for(Some("text/html")).unwrap(); + assert_eq!(action.label, "open in browser"); + assert!(!action.command.contains("scitano")); + } + + #[test] + fn archives_get_ls() { + for mime in [ + "application/zip", + "application/x-tar", + "application/gzip", + "application/x-bzip2", + "application/x-xz", + "application/x-7z-compressed", + "application/x-rar", + ] { + let action = default_action_for(Some(mime)).unwrap_or_else(|| { + panic!("no action for {mime}") + }); + assert!(action.command.contains("ls -l"), "archive {mime} should use ls -l"); + } + } + + #[test] + fn json_yaml_toml_get_editor() { + let json = default_action_for(Some("application/json")).unwrap(); + assert!(json.command.contains("scitano")); + assert!(default_action_for(Some("application/yaml")).is_some()); + assert!(default_action_for(Some("application/x-yaml")).is_some()); + assert!(default_action_for(Some("application/toml")).is_some()); + assert!(default_action_for(Some("application/xml")).is_some()); + } + + #[test] + fn shellscript_gets_editor() { + let action = default_action_for(Some("application/x-shellscript")).unwrap(); + assert!(action.command.contains("scitano")); + } + + #[test] + fn editor_chain_order_is_correct() { + let action = default_action_for(Some("text/plain")).unwrap(); + let cmd = action.command; + let i_scitano = cmd.find("scitano").unwrap(); + let i_scite = cmd.find("scite").unwrap(); + let i_geany = cmd.find("geany").unwrap(); + let i_nano = cmd.find("nano").unwrap(); + assert!(i_scitano < i_scite, "scitano must precede scite"); + assert!(i_scite < i_geany, "scite must precede geany"); + assert!(i_geany < i_nano, "geany must precede nano"); + } + + #[test] + fn editor_chain_respects_editor_env_var() { + let action = default_action_for(Some("text/plain")).unwrap(); + assert!( + action.command.starts_with("if [ -n \"$EDITOR\" ]"), + "editor chain must check $EDITOR first, got: {}", + &action.command[..50] + ); + } + + #[test] + fn unknown_mime_returns_none() { + assert!(default_action_for(Some("application/octet-stream")).is_none()); + assert!(default_action_for(Some("application/x-some-weird-thing")).is_none()); + } + + #[test] + fn none_mime_returns_none() { + assert!(default_action_for(None).is_none()); + } + + #[test] + fn charset_suffix_stripped() { + let action = default_action_for(Some("text/plain; charset=utf-8")).unwrap(); + assert_eq!(action.label, "open in editor"); + } + + #[test] + fn default_action_for_path_uses_extension() { + let action = default_action_for_path(Path::new("/tmp/notes.txt")).unwrap(); + assert!(action.command.contains("scitano")); + + let action = default_action_for_path(Path::new("/tmp/photo.png")).unwrap(); + assert!(action.command.contains("${IMAGE_VIEWER:-xdg-open}")); + + let action = default_action_for_path(Path::new("/tmp/README.md")).unwrap(); + assert!(action.command.contains("scitano")); + } +} diff --git a/src/mounts.rs b/src/mounts.rs new file mode 100644 index 0000000..85fd158 --- /dev/null +++ b/src/mounts.rs @@ -0,0 +1,305 @@ +//! Mount polling — replaces `gio::VolumeMonitor` with a pure-Rust +//! `/proc/mounts` reader. No GLib, no udev, no DBus: just the kernel's +//! own view of what's mounted where, polled on a timer. +//! +//! We filter out kernel pseudo-filesystems (proc, sysfs, tmpfs, cgroup, +//! etc.) so the sidebar shows only real storage. Network filesystems +//! (nfs, cifs, sshfs) are tagged so the UI can group them separately +//! if desired. +//! +//! The parser is split into a pure `parse_mounts(content: &str)` function +//! that takes the file contents as input, plus a thin `poll_mounts()` +//! wrapper that reads `/proc/mounts` and delegates. This makes the +//! parsing logic unit-testable without mocking the filesystem. + +use std::path::{Path, PathBuf}; + +/// A single mount entry shown in the sidebar's "Devices" section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MountInfo { + /// Device source (e.g. `/dev/sda1`, `tmpfs`, `server:/export`). + pub source: String, + /// Where it's mounted (`/`, `/mnt/usb`, `/home`, ...). + pub target: PathBuf, + /// Filesystem type (`ext4`, `btrfs`, `nfs4`, ...). + pub fs_type: String, + /// True for nfs/cifs/sshfs/etc. — used for icon + grouping. + pub is_network: bool, + /// Display label: target basename, or "/" for root. + pub label: String, +} + +/// Pseudo-filesystems hidden from the sidebar. Sourced from +/// `man 5 filesystems` and common kernel-internal mounts. The sidebar +/// shows real storage, not the kernel's bookkeeping. +/// +/// `fuse.gvfsd-fuse` lives here only. It is the GVFS userspace daemon's +/// fuse bridge, not a network filesystem in its own right. The network +/// mounts GVFS exposes (smb, sftp, dav, etc.) surface via FUSE and do +/// not carry distinct `fs_type` strings we can match on, so they are +/// hidden along with the rest of FUSE. Mount GVFS network shares via +/// udisks2 or fstab to produce real `cifs`/`nfs` entries we can tag. +const PSEUDO_FS: &[&str] = &[ + "proc", "sysfs", "tmpfs", "devtmpfs", "devpts", "cgroup", "cgroup2", + "pstore", "bpf", "tracefs", "debugfs", "fusectl", "configfs", + "securityfs", "mqueue", "hugetlbfs", "ramfs", "autofs", "binfmt_misc", + "rpc_pipefs", "nsfs", "fuse.gvfsd-fuse", "fuse.snapfuse", "fuse", + "efivarfs", "selinuxfs", "systemd-1", "none", +]; + +/// Network filesystems — surfaced separately so users can spot them. +/// `fuse.gvfsd-fuse` is excluded (see PSEUDO_FS above); the pseudo-fs +/// filter runs first, so listing it here would be dead code. +const NETWORK_FS: &[&str] = &[ + "nfs", "nfs4", "cifs", "smbfs", "smb2", "smb3", "sshfs", "fuse.sshfs", +]; + +/// Read `/proc/mounts` and parse it. Returns an empty vec on any error +/// (e.g. non-Linux) — we never panic on mount lookup failures. +pub fn poll_mounts() -> Vec { + let content = match std::fs::read_to_string("/proc/mounts") { + Ok(s) => s, + Err(e) => { + log::debug!("could not read /proc/mounts: {e}"); + return Vec::new(); + } + }; + parse_mounts(&content) +} + +/// Pure parser: given the text contents of `/proc/mounts`, return the +/// list of real (non-pseudo) mounts. Sorted with root first, then +/// alphabetically by label. +/// +/// Exposed publicly so the parsing logic can be unit-tested without +/// mocking `/proc/mounts`. +pub fn parse_mounts(content: &str) -> Vec { + let mut out = Vec::new(); + let mut seen_targets: std::collections::HashSet = + std::collections::HashSet::new(); + + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 4 { + continue; + } + let source = unescape_octal(parts[0]); + let target_raw = unescape_octal(parts[1]); + let fs_type = parts[2].to_string(); + + if PSEUDO_FS.iter().any(|p| *p == fs_type) { + continue; + } + + // Skip duplicate mount points (overlay-fs on top of /, etc.) + if !seen_targets.insert(target_raw.clone()) { + continue; + } + + let target = PathBuf::from(&target_raw); + let is_network = NETWORK_FS.iter().any(|p| *p == fs_type); + + let label = if target == Path::new("/") { + "Root".to_string() + } else { + target + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| target_raw.clone()) + }; + + out.push(MountInfo { + source, + target, + fs_type, + is_network, + label, + }); + } + + // Root first, then alphabetical. + out.sort_by(|a, b| { + let root = Path::new("/"); + let a_root = a.target == root; + let b_root = b.target == root; + b_root + .cmp(&a_root) + .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase())) + }); + + out +} + +/// `/proc/mounts` escapes spaces/tabs/backslashes as `\040`, `\011`, +/// `\012`, `\134`, etc. Unescape them so paths display correctly. +/// +/// Escape-sequence scanning operates on raw bytes (since `\040` is +/// ASCII and byte-aligned). The unescaped output is collected as raw +/// `u8` bytes and decoded via `String::from_utf8_lossy` once at the +/// end. This preserves multi-byte UTF-8 sequences in mount point names +/// (accented letters, non-Latin scripts) — per-byte `as char` casts +/// would corrupt them into separate garbage codepoints. +fn unescape_octal(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\\' && i + 3 < bytes.len() { + if let (Some(a), Some(b), Some(c)) = ( + (bytes[i + 1] as char).to_digit(8), + (bytes[i + 2] as char).to_digit(8), + (bytes[i + 3] as char).to_digit(8), + ) { + let code = a * 64 + b * 8 + c; + // Push the raw byte. For ASCII escapes (space, tab, etc.) + // this is the literal byte; for high-bit codes the + // `from_utf8_lossy` call below reconstructs the proper + // UTF-8 sequence from the surrounding bytes. + out.push(code as u8); + i += 4; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_sample_proc_mounts() { + let sample = "\ +rootfs / rootfs rw 0 0 +/dev/sda2 / ext4 rw,relatime 0 0 +proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0 +sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0 +tmpfs /tmp tmpfs rw,nosuid,nodev 0 0 +/dev/sda1 /boot ext4 rw,relatime 0 0 +/dev/sdb1 /mnt/usb vfat rw,relatime 0 0 +server:/export /mnt/nfs nfs4 rw,relatime 0 0 +"; + let mounts = parse_mounts(sample); + + // `rootfs / rootfs` is the kernel's initial rootfs mount. It is + // not in PSEUDO_FS; some systems expose it as a real mount. It + // mounts on `/`, same as the ext4 entry, so the dedup logic + // keeps whichever appears first (rootfs wins). Then /boot, + // /mnt/usb, /mnt/nfs alphabetically by label. Pseudo-fses + // (proc, sysfs, tmpfs) are filtered out. + assert_eq!(mounts.len(), 4); + assert_eq!(mounts[0].target, PathBuf::from("/")); + // First `/` entry wins: rootfs + assert_eq!(mounts[0].fs_type, "rootfs"); + assert!(!mounts[0].is_network); + + // boot, nfs, usb alphabetically by label (lowercased) + assert_eq!(mounts[1].label, "boot"); + assert_eq!(mounts[2].label, "nfs"); + assert_eq!(mounts[3].label, "usb"); + assert!(mounts[2].is_network); + assert_eq!(mounts[2].fs_type, "nfs4"); + assert_eq!(mounts[2].source, "server:/export"); + } + + #[test] + fn parse_filters_pseudo_filesystems() { + let sample = "\ +/dev/sda1 / ext4 rw 0 0 +proc /proc proc rw 0 0 +sysfs /sys sysfs rw 0 0 +tmpfs /tmp tmpfs rw 0 0 +cgroup2 /sys/fs/cgroup cgroup2 rw 0 0 +fuse.gvfsd-fuse /run/user/1000/gvfs fuse.gvfsd-fuse rw 0 0 +"; + let mounts = parse_mounts(sample); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].target, PathBuf::from("/")); + } + + #[test] + fn parse_tags_network_filesystems() { + let sample = "\ +/dev/sda1 / ext4 rw 0 0 +server:/share /mnt/nfs nfs rw 0 0 +//server/share /mnt/smb cifs rw 0 0 +user@host:/path /mnt/ssh sshfs rw 0 0 +"; + let mounts = parse_mounts(sample); + assert_eq!(mounts.len(), 4); + let network_count = mounts.iter().filter(|m| m.is_network).count(); + assert_eq!(network_count, 3); + } + + #[test] + fn parse_dedupes_duplicate_targets() { + // overlay-fs on top of / is a common pattern + let sample = "\ +/dev/sda1 / ext4 rw 0 0 +overlay / ext4 rw 0 0 +/dev/sda1 /boot ext4 rw 0 0 +"; + let mounts = parse_mounts(sample); + // First "/" wins; second "/" is skipped; "/boot" remains. + assert_eq!(mounts.len(), 2); + assert_eq!(mounts[0].target, PathBuf::from("/")); + assert_eq!(mounts[1].target, PathBuf::from("/boot")); + } + + #[test] + fn parse_handles_empty_input() { + let mounts = parse_mounts(""); + assert!(mounts.is_empty()); + } + + #[test] + fn parse_skips_malformed_lines() { + let sample = "\ +/dev/sda1 / ext4 rw 0 0 +incomplete line +also incomplete +/dev/sda1 /boot ext4 rw 0 0 +"; + let mounts = parse_mounts(sample); + assert_eq!(mounts.len(), 2); + } + + #[test] + fn unescape_spaces() { + assert_eq!(unescape_octal("/mnt/My\\040Drive"), "/mnt/My Drive"); + assert_eq!(unescape_octal("/normal/path"), "/normal/path"); + } + + #[test] + fn handles_octal_escapes() { + assert_eq!(unescape_octal("a\\011b"), "a\tb"); + assert_eq!(unescape_octal("a\\134b"), "a\\b"); + } + + #[test] + fn unescape_preserves_utf8() { + // Mount points can contain non-ASCII characters (accented letters, + // non-Latin scripts). The escape sequence scanner walks raw bytes, + // but multi-byte UTF-8 sequences must pass through unchanged. + let input = "/mnt/café"; + let output = unescape_octal(input); + assert_eq!(output, "/mnt/café"); + assert_eq!(output.chars().count(), 9); // 9 codepoints, not 10 bytes + + // Cyrillic: 2-byte UTF-8 sequences + let input = "/mnt/Документы"; + let output = unescape_octal(input); + assert_eq!(output, "/mnt/Документы"); + + // Mixed: escape sequence + UTF-8 + let input = "/mnt/My\\040Документы"; + let output = unescape_octal(input); + assert_eq!(output, "/mnt/My Документы"); + } +} diff --git a/src/ui/about.rs b/src/ui/about.rs new file mode 100644 index 0000000..abf33e0 --- /dev/null +++ b/src/ui/about.rs @@ -0,0 +1,116 @@ +//! About dialog — shown when the user picks Help → About runar. +//! +//! Renders as a full-window panel (not a true modal — iced 0.13 doesn't +//! have those) with the project name, version, author, license, and a +//! short description. Closes on Escape, on clicking the Close button, +//! or on clicking anywhere outside the dialog (the background is a +//! button that emits CloseAbout). + +use super::Message; +use crate::icons::Icon; +use iced::widget::{button, column, container, row, scrollable, text, Space}; +use iced::{Alignment, Color, Element, Length}; + +pub fn view(_app: &super::App) -> Element<'static, Message> { + // The whole window is a button — clicking anywhere closes the dialog. + // Inside it, a centered panel shows the actual content. + let content = column![ + Icon::Folder.widget(48.0), + text("runar").size(28).color(Color::from_rgb(0.95, 0.95, 0.95)), + text("v0.1.0").size(12).color(Color::from_rgb(0.6, 0.6, 0.65)), + Space::new(0, 16), + text("A hybrid Thunar/PCManFM + ROX-Filer file manager.") + .size(13) + .color(Color::from_rgb(0.85, 0.85, 0.88)), + text("Built in Rust with iced. Pure-Rust, statically linkable.") + .size(13) + .color(Color::from_rgb(0.85, 0.85, 0.88)), + Space::new(0, 16), + text("Author").size(11).color(Color::from_rgb(0.55, 0.55, 0.6)), + text("Jeremy Anderson ") + .size(13) + .color(Color::from_rgb(0.92, 0.92, 0.93)), + text("https://dcos.net/runar") + .size(12) + .color(Color::from_rgb(0.5, 0.7, 0.95)), + Space::new(0, 16), + text("License").size(11).color(Color::from_rgb(0.55, 0.55, 0.6)), + text("GPL-2.0-only (same as Thunar)") + .size(13) + .color(Color::from_rgb(0.92, 0.92, 0.93)), + Space::new(0, 20), + text("Design inspiration").size(11).color(Color::from_rgb(0.55, 0.55, 0.6)), + text("Thunar (Xfce) — dual-pane layout, breadcrumb pathbar") + .size(12) + .color(Color::from_rgb(0.75, 0.75, 0.78)), + text("PCManFM (LXDE) — lightweight GTK file manager") + .size(12) + .color(Color::from_rgb(0.75, 0.75, 0.78)), + text("ROX-Filer — AppDir paradigm, MIME action hooks") + .size(12) + .color(Color::from_rgb(0.75, 0.75, 0.78)), + text("Puppy Linux / DSL — ROX-Filer as desktop backbone") + .size(12) + .color(Color::from_rgb(0.75, 0.75, 0.78)), + text("SliTaz — minimal-footprint philosophy") + .size(12) + .color(Color::from_rgb(0.75, 0.75, 0.78)), + Space::new(0, 20), + button(text("Close").size(13)) + .padding([6, 16]) + .style(button::secondary) + .on_press(Message::CloseAbout), + ] + .spacing(2) + .align_x(Alignment::Center) + .width(Length::Fill); + + // Wrap content in a scrollable in case the window is too short. + let scroll = scrollable(content).height(Length::Fill); + + // Centered panel on a dimmed background. The background is itself a + // button so clicking outside the panel closes the dialog. + let panel = container(scroll) + .width(420) + .max_height(560) + .padding([24, 32]) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.13, 0.13, 0.15).into()), + border: iced::Border { + color: Color::from_rgb(0.32, 0.32, 0.36), + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + }); + + let centered = container(panel) + .center(iced::Length::Fill) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.6).into()), + ..Default::default() + }); + + // Wrap the whole thing in a button so clicks on the dimmed background + // close the dialog. The panel content has its own interactive widgets + // (the Close button) which take precedence. + button(centered) + .padding(0) + .style(|_theme: &iced::Theme, _status: button::Status| button::Style { + background: Some(iced::Color::TRANSPARENT.into()), + border: iced::Border { + color: iced::Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .on_press(Message::CloseAbout) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +// Suppress unused-import warning for `row` (kept for future layout needs). +#[allow(unused_imports)] +use row as _row; diff --git a/src/ui/context_menu.rs b/src/ui/context_menu.rs new file mode 100644 index 0000000..304ebff --- /dev/null +++ b/src/ui/context_menu.rs @@ -0,0 +1,298 @@ +//! Right-click context menu for grid rows. +//! +//! iced 0.13 doesn't ship a native context-menu widget. We approximate +//! it by tracking which row was right-clicked in `App.context_menu_target` +//! and rendering an overlay panel (similar to the menubar dropdown) +//! positioned at the top of the grid area. +//! +//! ## Entry set +//! +//! Inspired by Thunar / PCManFM / ROX-Filer right-click menus, with +//! type-aware entries: +//! +//! * **Directories**: Open, Open in Terminal, Rename, Cut, Copy, Paste, +//! Delete, Properties +//! * **AppDirs**: Open (launch), Enter Directory (Shift-equivalent), +//! Rename, Cut, Copy, Delete, Properties +//! * **Files**: Open (with detected handler), Open With…, Copy Path, +//! Rename, Cut, Copy, Delete, Properties +//! * **Symlinks**: Follow, Edit Target, Rename, Cut, Copy, Delete, +//! Properties +//! +//! Entries that aren't wired yet (Rename, Delete, Cut, Copy, Paste) +//! emit `NotImplemented` so the status bar shows feedback. + +use super::Message; +use iced::widget::{button, container, text, Space}; +use iced::{Alignment, Color, Element, Length}; + +use crate::vfs::{EntryKind, FileEntry}; + +/// Build the context menu overlay. Returns `None` if no row has been +/// right-clicked (i.e. `app.context_menu_target()` is `None`). +pub fn view<'a>(app: &'a super::App) -> Option> { + let idx = app.context_menu_target()?; + let entry = app.entries().get(idx)?; + + let items = items_for(entry, idx); + let mut col = iced::widget::column![].spacing(0).padding([4, 0]); + + for item in items { + if item.is_separator { + col = col.push(separator()); + } else if item.disabled { + col = col.push(disabled_row(item.label)); + } else { + col = col.push(entry_row(item.label, item.shortcut, item.message)); + } + } + + Some( + container(col) + .width(240) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.14, 0.14, 0.16).into()), + border: iced::Border { + color: Color::from_rgb(0.30, 0.30, 0.34), + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into(), + ) +} + +/// Context menu item — same shape as `menubar::MenuItem` but defined +/// here to avoid a cross-module dependency. +#[derive(Debug, Clone)] +struct CtxMenuItem { + label: String, + message: Message, + shortcut: Option<&'static str>, + is_separator: bool, + disabled: bool, +} + +impl CtxMenuItem { + fn entry(label: impl Into, message: Message) -> Self { + Self { + label: label.into(), + message, + shortcut: None, + is_separator: false, + disabled: false, + } + } + + fn entry_with_shortcut( + label: impl Into, + shortcut: &'static str, + message: Message, + ) -> Self { + Self { + label: label.into(), + message, + shortcut: Some(shortcut), + is_separator: false, + disabled: false, + } + } + + fn disabled_entry(label: impl Into) -> Self { + Self { + label: label.into(), + message: Message::Noop, + shortcut: None, + is_separator: false, + disabled: true, + } + } + + fn separator() -> Self { + Self { + label: String::new(), + message: Message::Noop, + shortcut: None, + is_separator: true, + disabled: false, + } + } +} + +fn items_for(entry: &FileEntry, idx: usize) -> Vec { + match &entry.kind { + EntryKind::Directory => directory_items(idx), + EntryKind::AppDir { .. } => appdir_items(idx), + EntryKind::File => file_items(entry, idx), + EntryKind::Symlink { .. } => symlink_items(idx), + } +} + +fn directory_items(idx: usize) -> Vec { + vec![ + CtxMenuItem::entry_with_shortcut( + "Open", + "Enter", + Message::EntryActivated(idx, false), + ), + CtxMenuItem::entry("Open in Terminal", Message::NotImplemented("Open in Terminal")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Cut", "Ctrl+X", Message::NotImplemented("Cut")), + CtxMenuItem::entry_with_shortcut("Copy", "Ctrl+C", Message::NotImplemented("Copy")), + CtxMenuItem::disabled_entry_with_shortcut("Paste", "Ctrl+V"), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Rename", "F2", Message::NotImplemented("Rename")), + CtxMenuItem::entry_with_shortcut("Delete", "Delete", Message::NotImplemented("Delete")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut( + "Properties", + "Alt+Enter", + Message::NotImplemented("Properties"), + ), + ] +} + +fn appdir_items(idx: usize) -> Vec { + vec![ + CtxMenuItem::entry_with_shortcut( + "Launch", + "Enter", + Message::EntryActivated(idx, false), + ), + CtxMenuItem::entry_with_shortcut( + "Enter Directory", + "Shift+Enter", + Message::EntryActivated(idx, true), + ), + CtxMenuItem::separator(), + CtxMenuItem::entry("Open in Terminal", Message::NotImplemented("Open in Terminal")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Cut", "Ctrl+X", Message::NotImplemented("Cut")), + CtxMenuItem::entry_with_shortcut("Copy", "Ctrl+C", Message::NotImplemented("Copy")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Rename", "F2", Message::NotImplemented("Rename")), + CtxMenuItem::entry_with_shortcut("Delete", "Delete", Message::NotImplemented("Delete")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut( + "Properties", + "Alt+Enter", + Message::NotImplemented("Properties"), + ), + ] +} + +fn file_items(entry: &FileEntry, idx: usize) -> Vec { + let mime_label = entry + .mime_type + .as_deref() + .unwrap_or("unknown type"); + + vec![ + CtxMenuItem::entry_with_shortcut( + format!("Open ({})", mime_label), + "Enter", + Message::EntryActivated(idx, false), + ), + CtxMenuItem::entry("Open With…", Message::NotImplemented("Open With")), + CtxMenuItem::separator(), + CtxMenuItem::entry("Copy Path", Message::NotImplemented("Copy Path")), + CtxMenuItem::entry("Open in Terminal", Message::NotImplemented("Open in Terminal")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Cut", "Ctrl+X", Message::NotImplemented("Cut")), + CtxMenuItem::entry_with_shortcut("Copy", "Ctrl+C", Message::NotImplemented("Copy")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Rename", "F2", Message::NotImplemented("Rename")), + CtxMenuItem::entry_with_shortcut("Delete", "Delete", Message::NotImplemented("Delete")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut( + "Properties", + "Alt+Enter", + Message::NotImplemented("Properties"), + ), + ] +} + +fn symlink_items(idx: usize) -> Vec { + vec![ + CtxMenuItem::entry_with_shortcut( + "Follow Link", + "Enter", + Message::EntryActivated(idx, false), + ), + CtxMenuItem::entry("Edit Target…", Message::NotImplemented("Edit Target")), + CtxMenuItem::separator(), + CtxMenuItem::entry("Copy Path", Message::NotImplemented("Copy Path")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Cut", "Ctrl+X", Message::NotImplemented("Cut")), + CtxMenuItem::entry_with_shortcut("Copy", "Ctrl+C", Message::NotImplemented("Copy")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut("Rename", "F2", Message::NotImplemented("Rename")), + CtxMenuItem::entry_with_shortcut("Delete", "Delete", Message::NotImplemented("Delete")), + CtxMenuItem::separator(), + CtxMenuItem::entry_with_shortcut( + "Properties", + "Alt+Enter", + Message::NotImplemented("Properties"), + ), + ] +} + +// --- Render helpers (mirror the menubar module's helpers) ------------------ + +fn entry_row<'a>( + label: String, + shortcut: Option<&'static str>, + message: Message, +) -> Element<'a, Message> { + let mut r = iced::widget::row![ + text(label).size(13).color(Color::from_rgb(0.92, 0.92, 0.93)), + Space::new(Length::Fill, 0), + ] + .align_y(Alignment::Center); + + if let Some(s) = shortcut { + if !s.is_empty() { + r = r.push( + text(s) + .size(11) + .color(Color::from_rgb(0.55, 0.55, 0.6)), + ); + } + } + + button(r) + .padding([4, 10]) + .width(Length::Fill) + .style(button::secondary) + .on_press(message) + .into() +} + +fn disabled_row<'a>(label: String) -> Element<'a, Message> { + container( + text(label) + .size(13) + .color(Color::from_rgb(0.45, 0.45, 0.5)), + ) + .padding([4, 10]) + .width(Length::Fill) + .into() +} + +fn separator<'a>() -> Element<'a, Message> { + container(Space::new(Length::Fill, 1)) + .padding([2, 6]) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.28, 0.28, 0.32).into()), + ..Default::default() + }) + .into() +} + +// Small extension trait to keep call sites clean. +impl CtxMenuItem { + fn disabled_entry_with_shortcut(label: &str, _shortcut: &'static str) -> Self { + Self::disabled_entry(label) + } +} diff --git a/src/ui/grid.rs b/src/ui/grid.rs new file mode 100644 index 0000000..f8a5643 --- /dev/null +++ b/src/ui/grid.rs @@ -0,0 +1,202 @@ +//! Main content area — file list with icon + name, single-click to select, +//! double-click (or Enter) to activate. +//! +//! ## Click handling +//! +//! iced 0.13's `button` widget exposes only `on_press`, not native +//! double-click events. Grid rows emit `EntryClicked(usize)` on every +//! press; `ui::update` performs double-click detection by comparing the +//! current click timestamp against the previous one. A second press on +//! the same row within ~350ms (matching GTK/Qt defaults) activates. +//! Single-click selects. +//! +//! ## Layout +//! +//! A single-column vertical list of rows, not a multi-column icon grid. +//! Multi-column layout with spatial H/J/K/L navigation is tracked as a +//! roadmap item. +//! +//! ## Performance: full layout, no virtualization +//! +//! iced 0.13's stock `scrollable` lays out all children and clips the +//! result — it does not skip layout for off-screen rows. This module +//! builds one `mouse_area` widget per entry on every render, regardless +//! of how many entries are visible. Directories in the low-thousands +//! perform fine; the 10k+ case (e.g. `/usr/bin` on a developer +//! workstation) incurs noticeable layout cost on every scroll. +//! +//! Remediation options: +//! 1. Wait for iced to ship a virtualized list widget. +//! 2. Build a custom widget on top of `iced::advanced` that lays out +//! only the visible window of rows. +//! 3. Page the entry list — keep ~500 entries in memory, load more on +//! scroll-to-bottom. +//! +//! Option 3 is the cheapest stopgap and is tracked in the README roadmap. + +use super::{format_modified, format_size, Message}; +use crate::icons::Icon; +use crate::vfs::{EntryKind, FileEntry}; +use iced::widget::{column, container, mouse_area, row, scrollable, text}; +use iced::{Alignment, Color, Element, Length}; + +pub fn view<'a>(app: &'a super::App) -> Element<'a, Message> { + let mut list = column![].spacing(0); + + // Optional header row. + let header = row![ + text("").width(Length::Fixed(24.0)), // icon column + text("Name").width(Length::Fill).size(11), + text("Size").width(Length::Fixed(72.0)).size(11), + text("Modified") + .width(Length::Fixed(96.0)) + .size(11), + text("Type").width(Length::Fixed(96.0)).size(11), + ] + .spacing(8) + .padding([4, 8]) + .align_y(Alignment::Center); + list = list.push(header); + + list = list.push(divider()); + + let entries = app.entries(); + if entries.is_empty() { + let empty = if app.scanning() { + "reading directory…" + } else { + "(empty)" + }; + list = list.push( + container(text(empty).size(12).color(Color::from_rgb(0.5, 0.5, 0.55))) + .padding([20, 12]) + .width(Length::Fill), + ); + } else { + for (i, entry) in entries.iter().enumerate() { + let is_selected = app.selected() == Some(i); + list = list.push(entry_row(i, entry, is_selected)); + } + } + + let scroll = scrollable(list).width(Length::Fill).height(Length::Fill); + + container(scroll) + .width(Length::Fill) + .height(Length::Fill) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.09, 0.09, 0.10).into()), + ..Default::default() + }) + .into() +} + +fn entry_row<'a>(i: usize, entry: &'a FileEntry, selected: bool) -> Element<'a, Message> { + let icon = entry_icon(&entry.kind); + let name_color = name_color_for(entry); + let size = size_label_for(entry); + let modified = format_modified(entry.modified); + let kind_label = kind_label_for(entry); + + let row_content = row![ + container(icon.widget(14.0)) + .width(Length::Fixed(24.0)) + .align_x(iced::alignment::Horizontal::Center), + text(entry.name.clone()) + .size(13) + .color(name_color) + .width(Length::Fill), + text(size) + .size(11) + .color(Color::from_rgb(0.6, 0.6, 0.65)) + .width(Length::Fixed(72.0)), + text(modified) + .size(11) + .color(Color::from_rgb(0.6, 0.6, 0.65)) + .width(Length::Fixed(96.0)), + text(kind_label) + .size(11) + .color(Color::from_rgb(0.6, 0.6, 0.65)) + .width(Length::Fixed(96.0)), + ] + .spacing(8) + .padding([3, 8]) + .align_y(Alignment::Center); + + // `mouse_area` captures both left-click (selection / drag start) and + // right-click (context menu). Visual styling is applied via the + // wrapping container. + let styled = container(row_content) + .width(Length::Fill) + .style(move |_: &iced::Theme| container::Style { + background: Some(row_background_color(selected).into()), + ..Default::default() + }); + + mouse_area(styled) + .on_press(Message::EntryClicked(i)) + .on_right_press(Message::EntryRightClicked(i)) + .on_move(Message::GridRowMouseMoved) + .into() +} + +/// Muted gray for hidden (dotfile) entries, near-white for normal ones. +fn name_color_for(entry: &FileEntry) -> Color { + if entry.hidden { + Color::from_rgb(0.55, 0.55, 0.6) + } else { + Color::from_rgb(0.92, 0.92, 0.93) + } +} + +/// Em-dash for directories and AppDirs (no meaningful file size); +/// human-readable size for files. +fn size_label_for(entry: &FileEntry) -> String { + match entry.kind { + EntryKind::Directory | EntryKind::AppDir { .. } => "—".to_string(), + _ => format_size(entry.size), + } +} + +/// Type column text: "Folder", "AppDir", the file's MIME type, or a +/// symlink-target annotation. +fn kind_label_for(entry: &FileEntry) -> String { + match &entry.kind { + EntryKind::Directory => "Folder".to_string(), + EntryKind::AppDir { .. } => "AppDir".to_string(), + EntryKind::File => entry.mime_type.clone().unwrap_or_else(|| "file".into()), + EntryKind::Symlink { target } => match target { + Some(t) => format!("→ {}", t.display()), + None => "symlink".into(), + }, + } +} + +/// Selected-row highlight color vs. default row background. +fn row_background_color(selected: bool) -> Color { + if selected { + Color::from_rgb(0.18, 0.32, 0.55) + } else { + Color::from_rgb(0.11, 0.11, 0.12) + } +} + +fn divider() -> Element<'static, Message> { + container(text("")) + .height(Length::Fixed(1.0)) + .width(Length::Fill) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.22, 0.22, 0.25).into()), + ..Default::default() + }) + .into() +} + +fn entry_icon(kind: &EntryKind) -> Icon { + match kind { + EntryKind::Directory => Icon::Folder, + EntryKind::AppDir { .. } => Icon::AppDir, + EntryKind::File => Icon::File, + EntryKind::Symlink { .. } => Icon::Symlink, + } +} diff --git a/src/ui/menubar.rs b/src/ui/menubar.rs new file mode 100644 index 0000000..e6a4237 --- /dev/null +++ b/src/ui/menubar.rs @@ -0,0 +1,441 @@ +//! Top menubar — File / Edit / View / Go / Bookmarks / Help. +//! +//! iced 0.13 doesn't ship a native menubar widget, so we build one from +//! `button` primitives. Each menu button toggles a dropdown panel that +//! overlays the layout below it (rendered by `ui::view` when +//! `app.open_menu` matches). +//! +//! ## Menu structure +//! +//! Inspired by the three parent influencers: +//! +//! * **Thunar** (Xfce): File / Edit / View / Go / Bookmarks / Help +//! * **PCManFM** (LXDE): File / Edit / View / Go / Bookmarks / Help +//! * **ROX-Filer**: doesn't use a traditional menubar (toolbar + popup +//! menus), but its menu *entries* (Show, Actions, Type, ...) map +//! cleanly onto View / File / View in our schema. +//! +//! Entries that aren't wired yet (Rename, Delete, Copy, Paste) emit a +//! `NotImplemented` message so the status bar shows feedback rather +//! than silently swallowing the click. Background file ops are tracked +//! in the README roadmap. + +use super::Message; +use iced::widget::{button, container, row, text, Space}; +use iced::{Alignment, Color, Element, Length}; +use std::path::PathBuf; + +/// Which top-level menu is currently open (if any). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuKind { + File, + Edit, + View, + Go, + Bookmarks, + Help, +} + +impl MenuKind { + pub fn label(self) -> &'static str { + match self { + MenuKind::File => "File", + MenuKind::Edit => "Edit", + MenuKind::View => "View", + MenuKind::Go => "Go", + MenuKind::Bookmarks => "Bookmarks", + MenuKind::Help => "Help", + } + } + + pub const ALL: [MenuKind; 6] = [ + MenuKind::File, + MenuKind::Edit, + MenuKind::View, + MenuKind::Go, + MenuKind::Bookmarks, + MenuKind::Help, + ]; +} + +/// A single entry in a dropdown menu. +#[derive(Debug, Clone)] +pub struct MenuItem { + pub label: String, + pub message: Message, + /// Optional shortcut hint shown on the right (e.g. "Enter", "Ctrl+L"). + pub shortcut: Option<&'static str>, + /// True for separators (label is ignored, message is never emitted). + pub is_separator: bool, + /// True if this entry is grayed out (e.g. "Paste" when clipboard empty). + pub disabled: bool, +} + +impl MenuItem { + /// Regular clickable entry. + pub fn entry(label: impl Into, message: Message) -> Self { + Self { + label: label.into(), + message, + shortcut: None, + is_separator: false, + disabled: false, + } + } + + pub fn entry_with_shortcut( + label: impl Into, + shortcut: &'static str, + message: Message, + ) -> Self { + Self { + label: label.into(), + message, + shortcut: Some(shortcut), + is_separator: false, + disabled: false, + } + } + + /// Disabled entry (shown grayed out). + pub fn disabled_entry(label: impl Into) -> Self { + Self { + label: label.into(), + message: Message::Noop, + shortcut: None, + is_separator: false, + disabled: true, + } + } + + /// Disabled entry with a shortcut hint (shortcut is currently + /// dropped — disabled entries don't render their shortcut — but the + /// method exists so call sites are symmetric with `entry_with_shortcut`). + pub fn disabled_entry_with_shortcut( + label: impl Into, + _shortcut: &'static str, + ) -> Self { + Self { + label: label.into(), + message: Message::Noop, + shortcut: None, + is_separator: false, + disabled: true, + } + } + + /// Horizontal separator line. + pub fn separator() -> Self { + Self { + label: String::new(), + message: Message::Noop, + shortcut: None, + is_separator: true, + disabled: false, + } + } +} + +/// Build the menubar (a horizontal row of menu buttons). Returns the +/// bar element; the actual dropdown panel is rendered separately by +/// `ui::view` based on `app.open_menu`. +pub fn view<'a>(app: &'a super::App) -> Element<'a, Message> { + let mut bar = row![].spacing(0).align_y(Alignment::Center); + + for kind in MenuKind::ALL { + let is_open = app.open_menu() == Some(kind); + let btn = button(text(kind.label()).size(13)) + .padding([4, 10]) + .style(move |theme: &iced::Theme, status: button::Status| { + let base = button::secondary(theme, status); + let bg = if is_open { + Some(Color::from_rgb(0.18, 0.32, 0.55).into()) + } else if matches!(status, button::Status::Hovered) { + Some(Color::from_rgb(0.18, 0.18, 0.20).into()) + } else { + None + }; + button::Style { + background: bg.or(base.background), + ..base + } + }) + .on_press(Message::MenuToggled(kind)); + bar = bar.push(btn); + } + + // Push remaining space to the right so the menubar fills the width. + bar = bar.push(Space::new(Length::Fill, 0)); + + container(bar) + .padding([2, 4]) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.10, 0.10, 0.11).into()), + border: iced::Border { + color: Color::from_rgb(0.22, 0.22, 0.25), + width: 0.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// Build the dropdown panel for the currently-open menu. Returns None +/// if no menu is open. +pub fn dropdown<'a>(app: &'a super::App) -> Option> { + let kind = app.open_menu()?; + let items = menu_items_for(kind, app); + + let mut col = iced::widget::column![].spacing(0).padding([4, 0]); + + for item in items { + if item.is_separator { + col = col.push(separator()); + } else if item.disabled { + col = col.push(disabled_row(item.label)); + } else { + col = col.push(entry_row(item.label, item.shortcut, item.message)); + } + } + + Some( + container(col) + .width(220) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.14, 0.14, 0.16).into()), + border: iced::Border { + color: Color::from_rgb(0.30, 0.30, 0.34), + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into(), + ) +} + +/// Build the list of items for a given menu, considering app state +/// (e.g. disable "Paste" when clipboard is empty, hide "Remove Bookmark" +/// when not in a bookmarked dir). +fn menu_items_for(kind: MenuKind, app: &super::App) -> Vec { + match kind { + MenuKind::File => { + let mut items = vec![ + MenuItem::entry_with_shortcut( + "Open", + "Enter", + Message::EntryActivated(app.selected().unwrap_or(usize::MAX), false), + ), + MenuItem::entry("Open With…", Message::NotImplemented("Open With")), + MenuItem::separator(), + MenuItem::entry_with_shortcut( + "New Folder", + "Ctrl+Shift+N", + Message::NotImplemented("New Folder"), + ), + MenuItem::entry_with_shortcut( + "New File", + "Ctrl+Alt+N", + Message::NotImplemented("New File"), + ), + MenuItem::separator(), + MenuItem::entry_with_shortcut( + "Rename", + "F2", + Message::NotImplemented("Rename"), + ), + MenuItem::entry_with_shortcut( + "Delete", + "Delete", + Message::NotImplemented("Delete"), + ), + MenuItem::separator(), + MenuItem::entry_with_shortcut( + "Properties", + "Alt+Enter", + Message::NotImplemented("Properties"), + ), + ]; + // Disable Open if nothing is selected. + if app.selected().is_none() { + items[0] = MenuItem::disabled_entry("Open"); + } + items + } + MenuKind::Edit => vec![ + MenuItem::entry_with_shortcut( + "Cut", + "Ctrl+X", + Message::NotImplemented("Cut"), + ), + MenuItem::entry_with_shortcut( + "Copy", + "Ctrl+C", + Message::NotImplemented("Copy"), + ), + MenuItem::disabled_entry_with_shortcut("Paste", "Ctrl+V"), + MenuItem::separator(), + MenuItem::entry_with_shortcut( + "Select All", + "Ctrl+A", + Message::NotImplemented("Select All"), + ), + MenuItem::entry_with_shortcut( + "Invert Selection", + "Ctrl+I", + Message::NotImplemented("Invert Selection"), + ), + MenuItem::separator(), + MenuItem::entry_with_shortcut( + "Preferences…", + "", + Message::NotImplemented("Preferences"), + ), + ], + MenuKind::View => vec![ + MenuItem::entry_with_shortcut( + "Reload", + "Ctrl+R", + Message::Reload, + ), + MenuItem::separator(), + MenuItem::entry("Show Hidden Files", Message::NotImplemented("Show Hidden")), + MenuItem::entry("Sort by Name", Message::NotImplemented("Sort by Name")), + MenuItem::entry("Sort by Size", Message::NotImplemented("Sort by Size")), + MenuItem::entry("Sort by Modified", Message::NotImplemented("Sort by Modified")), + MenuItem::separator(), + MenuItem::entry("Icon View", Message::NotImplemented("Icon View")), + MenuItem::entry("Detailed List", Message::NotImplemented("Detailed List")), + ], + MenuKind::Go => { + let mut items = vec![ + MenuItem::entry_with_shortcut( + "Up", + "Alt+Up", + Message::GoUp, + ), + MenuItem::entry_with_shortcut( + "Back", + "Alt+Left", + Message::NotImplemented("Back"), + ), + MenuItem::entry_with_shortcut( + "Forward", + "Alt+Right", + Message::NotImplemented("Forward"), + ), + MenuItem::separator(), + MenuItem::entry_with_shortcut( + "Edit Path…", + "Ctrl+L", + Message::PathBarToggleEdit, + ), + MenuItem::separator(), + ]; + // Append all default locations (from config::defaults). + for loc in crate::config::default_locations() { + if let Some(p) = loc.path { + items.push(MenuItem::entry( + format!(" {}", loc.label), + Message::MountSelected(p), + )); + } + } + items + } + MenuKind::Bookmarks => { + let mut items = vec![ + MenuItem::entry_with_shortcut( + "Add Current Directory", + "Ctrl+D", + Message::AddBookmarkFor(PathBuf::from(app.current_dir())), + ), + MenuItem::entry_with_shortcut( + "Edit Bookmarks…", + "", + Message::NotImplemented("Edit Bookmarks"), + ), + MenuItem::separator(), + ]; + if app.bookmarks().is_empty() { + items.push(MenuItem::disabled_entry("(no bookmarks)")); + } else { + for entry in app.bookmarks() { + let label = entry + .label + .clone() + .or_else(|| { + entry + .path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| entry.path.display().to_string()); + items.push(MenuItem::entry( + format!(" {}", label), + Message::BookmarkSelected(entry.path.clone()), + )); + } + } + items + } + MenuKind::Help => vec![ + MenuItem::entry("About runar", Message::ShowAbout), + MenuItem::entry("Keyboard Shortcuts", Message::NotImplemented("Keyboard Shortcuts")), + MenuItem::separator(), + MenuItem::entry("Online Documentation", Message::NotImplemented("Online Docs")), + ], + } +} + +fn entry_row<'a>( + label: String, + shortcut: Option<&'static str>, + message: Message, +) -> Element<'a, Message> { + let mut r = iced::widget::row![ + text(label).size(13).color(Color::from_rgb(0.92, 0.92, 0.93)), + Space::new(Length::Fill, 0), + ] + .align_y(Alignment::Center); + + if let Some(s) = shortcut { + if !s.is_empty() { + r = r.push( + text(s) + .size(11) + .color(Color::from_rgb(0.55, 0.55, 0.6)), + ); + } + } + + button(r) + .padding([4, 10]) + .width(Length::Fill) + .style(button::secondary) + .on_press(message) + .into() +} + +fn disabled_row<'a>(label: String) -> Element<'a, Message> { + container( + text(label) + .size(13) + .color(Color::from_rgb(0.45, 0.45, 0.5)), + ) + .padding([4, 10]) + .width(Length::Fill) + .into() +} + +fn separator<'a>() -> Element<'a, Message> { + container(Space::new(Length::Fill, 1)) + .padding([2, 6]) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.28, 0.28, 0.32).into()), + ..Default::default() + }) + .into() +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..29a471d --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,1149 @@ +//! iced UI shell — Phase 2 layout + Phase 4 config wiring. +//! +//! Layout (top to bottom, left to right): +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ [↑] [/ home user documents] [✎ edit] │ ← pathbar +//! ├──────────────┬──────────────────────────────────────────────┤ +//! │ DEVICES │ │ +//! │ • Root │ ┌────┐ ┌────┐ ┌────┐ │ +//! │ • boot │ │ 📁 │ │ 📁 │ │ 📄 │ │ +//! │ • usb │ │ foo│ │ bar│ │ baz│ │ +//! │ BOOKMARKS │ └────┘ └────┘ └────┘ │ +//! │ (empty) │ │ +//! │ │ ... │ +//! │ │ │ +//! ├──────────────┴──────────────────────────────────────────────┤ +//! │ 12 items • /home/user [status messages] │ ← status bar +//! └─────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! All VFS work happens off the UI thread via iced subscriptions; the +//! update function only mutates in-memory state. + +mod pathbar; +mod sidebar; +mod grid; +mod menubar; +mod context_menu; +mod about; + +use crate::config::Config; +use crate::mounts::{poll_mounts, MountInfo}; +use crate::vfs::{ + watch_directory, scan_directory, EntryKind, FileEntry, ScanEvent, WatchEvent, +}; +use iced::widget::{button, column, container, row, text}; +use iced::{ + stream, Alignment, Color, Element, Font, Length, Subscription, Task, Theme, time, +}; +use iced::futures::SinkExt; // for `tx.send(...).await` on futures::mpsc::Sender +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub use menubar::MenuKind; + +// ---------- Messages -------------------------------------------------------- + +#[derive(Debug, Clone)] +pub enum Message { + /// Streamed from `scan_directory` (Phase 1, wrapped in a subscription). + ScanEvent(ScanEvent), + /// Streamed from `watch_directory` (Phase 1, wrapped in a subscription). + WatchEvent(WatchEvent), + /// Periodic mount poll tick. + PollMounts, + #[allow(dead_code)] + MountsUpdated(Vec), + + /// Raw key-press event from iced's global keyboard subscription. + /// Interpretation (Escape closes the topmost layer, Enter activates + /// the current selection, J/K navigate, etc.) happens in `update()` + /// where `&mut App` is available — not in the stateless `fn` that + /// iced's `on_key_press` requires. Keeping interpretation stateful + /// ensures Escape closes only the relevant layer rather than + /// toggling state blindly. + KeyPressed(iced::keyboard::Key, iced::keyboard::Modifiers), + + // Pathbar + PathBarToggleEdit, + PathBarEdit(String), + PathBarSubmit, + PathBarSegment(PathBuf), + GoUp, + + // Sidebar + MountSelected(PathBuf), + BookmarkSelected(PathBuf), + AddBookmarkFor(PathBuf), + #[allow(dead_code)] + RemoveBookmark(PathBuf), + + // Grid + /// Programmatic selection of a row (no activation). Currently only + /// used internally — the grid emits `EntryClicked` instead, which + /// handles both single-click-select and double-click-activate. This + /// variant is kept for future use (e.g. find-and-select from a + /// search dialog). + #[allow(dead_code)] + EntrySelected(usize), + /// A grid row was clicked. The update() function inspects the + /// timestamp on this click vs the previous one to decide whether + /// it's a single-click (select) or double-click (activate). This + /// is necessary because iced 0.13's button widget exposes only + /// `on_press`, not native double-click events. + EntryClicked(usize), + EntryActivated(usize, bool), // index, shift_modifier + Reload, + + // Launch feedback + LaunchDone(LaunchOutcome), + + // Config + ConfigSaved(Result<(), String>), + + // Menubar + context menu + /// User clicked a menubar button (File / Edit / View / Go / Bookmarks / Help). + /// If the same menu is already open, it closes (toggle behavior). + MenuToggled(MenuKind), + /// Close any open menu (menubar dropdown or context menu). Triggered + /// by clicking elsewhere, pressing Escape, or after a menu action fires. + CloseMenus, + /// User right-clicked a grid row at the given index. + EntryRightClicked(usize), + /// User selected a menu item whose action isn't implemented yet. + /// The string is the action label, shown in the status bar so the + /// user knows their click registered. + NotImplemented(&'static str), + /// Show the About dialog. + ShowAbout, + /// Close the About dialog. + CloseAbout, + /// No-op. Used by disabled menu entries and separators so they don't + /// need an `Option` field. + Noop, + + // Drag-and-drop (grid → sidebar bookmarks) + /// Global left mouse button pressed at the given position. Emitted by + /// the `iced::event::listen_with` subscription for every press, so + /// update() can record the press position for drag-threshold detection. + GlobalMousePressed(iced::Point), + /// Global left mouse button released. Emitted by the subscription. + /// update() checks if a drag was in progress and, if so, commits the + /// drop (or cancels if not over the sidebar). + GlobalMouseReleased, + /// Mouse moved over a grid row while the button was held. The `Point` + /// is relative to the row's top-left. Emitted by `mouse_area::on_move` + /// on each grid row. If the movement exceeds the drag threshold + /// (5px), update() promotes the drag candidate to an active drag. + GridRowMouseMoved(iced::Point), + /// Cursor entered a sidebar bookmark row at the given index while a + /// drag is in progress. Emitted by `mouse_area::on_enter` on each + /// bookmark row. update() records the hover index so the drop lands + /// at the right position. + DragEnteredBookmark(usize), + /// Cursor entered the empty area below the last bookmark (drop here + /// to append to the end of the list). + DragEnteredBookmarkAppendZone, + /// Cursor left the bookmark area entirely. + DragExitedBookmarks, +} + +/// Cloneable wrapper around `launch::LaunchResult` so it can travel +/// through iced's Message channel (which requires Clone). +#[derive(Debug, Clone)] +pub enum LaunchOutcome { + Spawned(String), + NoHandler, + Failed(String), +} + +impl From for LaunchOutcome { + fn from(r: crate::launch::LaunchResult) -> Self { + use crate::launch::LaunchResult; + match r { + LaunchResult::Spawned(s) => LaunchOutcome::Spawned(s), + LaunchResult::NoHandler => LaunchOutcome::NoHandler, + LaunchResult::Failed(s) => LaunchOutcome::Failed(s), + } + } +} + +// ---------- State ----------------------------------------------------------- + +pub struct App { + /// Currently-displayed directory. Changing this triggers a rescan + /// (the scan subscription's ID incorporates the dir + a counter). + current_dir: PathBuf, + + /// Monotonic counter bumped on every navigation, so the subscription + /// is recreated even when returning to a previously-visited dir. + scan_id: u64, + + /// Entries currently shown. Replaced wholesale on each scan. + entries: Vec, + + /// Index into `entries` of the highlighted row, if any. + selected: Option, + + /// Pathbar UI mode: breadcrumb trail vs. raw text input. + pathbar_editing: bool, + pathbar_input: String, + + /// Mounts polled from `/proc/mounts`. + mounts: Vec, + + /// User config (bookmarks + actions). + config: Config, + + /// Status line shown in the bottom bar. + status: String, + + /// Whether a scan is currently in flight (for the reload spinner). + scanning: bool, + + /// Last grid row clicked + when, for double-click detection. + /// iced 0.13's button widget exposes only `on_press`, not native + /// double-click events, so we detect them manually: a second press + /// on the same row within DOUBLE_CLICK_MS of the first is treated + /// as an activation. + last_click: Option<(usize, std::time::Instant)>, + + /// Which top-level menu (File/Edit/View/Go/Bookmarks/Help) is + /// currently open, if any. Clicking a menu button toggles this; + /// selecting an item or clicking elsewhere clears it. + open_menu: Option, + + /// Which grid row has an open context menu (right-click), if any. + /// Cleared by any navigation, menu action, or click elsewhere. + context_menu_target: Option, + + /// True when the About dialog is open. + show_about: bool, + + // --- Drag-and-drop state --- + /// The entry path being dragged from the grid, once the drag threshold + /// (5px movement while button held) is exceeded. None when not dragging. + drag_source: Option, + /// The grid row index that was pressed, recorded on `on_press` so we + /// know which entry's path to drag. Cleared on release. + drag_candidate_idx: Option, + /// The global cursor position when the button was pressed, for + /// threshold computation. Set by the global mouse subscription. + drag_press_pos: Option, + /// Which bookmark index the cursor is currently hovering over during + /// a drag. None = not over any bookmark (or not dragging). Some(len) + /// = over the append zone below the last bookmark. + drag_hover_bookmark: Option, +} + +/// Window for double-click detection. 350ms matches GTK/Qt defaults. +const DOUBLE_CLICK_MS: u64 = 350; + +impl App { + /// Construct with an initial directory. Called once by `run_with`. + pub fn new(initial_dir: PathBuf) -> Self { + let config = Config::load(); + let mounts = poll_mounts(); + Self { + current_dir: initial_dir, + scan_id: 0, + entries: Vec::new(), + selected: None, + pathbar_editing: false, + pathbar_input: String::new(), + mounts, + config, + status: String::new(), + scanning: false, + last_click: None, + open_menu: None, + context_menu_target: None, + show_about: false, + drag_source: None, + drag_candidate_idx: None, + drag_press_pos: None, + drag_hover_bookmark: None, + } + } + + /// Navigate to `dir`. Bumps scan_id so the subscription restarts, + /// clears the entry list immediately (so the UI feels responsive + /// rather than showing stale entries from the old dir). + fn navigate(&mut self, dir: PathBuf) { + self.current_dir = dir; + self.scan_id = self.scan_id.wrapping_add(1); + self.entries.clear(); + self.selected = None; + self.scanning = true; + // Reset double-click tracking so a click in the new dir doesn't + // accidentally pair with a click in the old dir. + self.last_click = None; + // Close any open menus — they reference the old dir's state. + self.open_menu = None; + self.context_menu_target = None; + self.status = format!("scanning {}...", self.current_dir.display()); + } + + /// Borrowed view of entries (for grid and main.rs sentinel resolution). + pub fn entries(&self) -> &[FileEntry] { + &self.entries + } + + /// Borrowed view of the current dir. + pub fn current_dir(&self) -> &Path { + &self.current_dir + } + + /// Borrowed view of mounts. + pub fn mounts(&self) -> &[MountInfo] { + &self.mounts + } + + /// Borrowed view of bookmarks. + pub fn bookmarks(&self) -> &[crate::config::bookmarks::BookmarkEntry] { + self.config.bookmarks.entries() + } + + /// True if a scan is in flight. + pub fn scanning(&self) -> bool { + self.scanning + } + + /// Current status string. + pub fn status(&self) -> &str { + &self.status + } + + /// True if pathbar is in edit mode. + pub fn pathbar_editing(&self) -> bool { + self.pathbar_editing + } + + /// Current pathbar input text. + pub fn pathbar_input(&self) -> &str { + &self.pathbar_input + } + + /// Currently-selected entry index, if any. + pub fn selected(&self) -> Option { + self.selected + } + + /// Which top-level menu is open, if any. + pub fn open_menu(&self) -> Option { + self.open_menu + } + + /// Which grid row has an open context menu, if any. + pub fn context_menu_target(&self) -> Option { + self.context_menu_target + } + + /// True if the About dialog should be shown. + /// (Currently read directly as `app.show_about` in view(); kept as + /// an accessor for symmetry with the other state getters and for + /// future testability.) + #[allow(dead_code)] + pub fn show_about(&self) -> bool { + self.show_about + } + + /// True if a drag-from-grid is in progress (threshold exceeded). + #[allow(dead_code)] + pub fn is_dragging(&self) -> bool { + self.drag_source.is_some() + } + + /// The path being dragged, if any. + #[allow(dead_code)] + pub fn drag_source(&self) -> Option<&Path> { + self.drag_source.as_deref() + } + + /// Which bookmark index the cursor is hovering over during a drag. + /// `Some(len)` means the append zone (drop after last bookmark). + pub fn drag_hover_bookmark(&self) -> Option { + self.drag_hover_bookmark + } +} + +// ---------- Update ---------------------------------------------------------- + +pub fn update(app: &mut App, msg: Message) -> Task { + match msg { + Message::ScanEvent(event) => { + match event { + ScanEvent::Entry(entry) => { + app.entries.push(entry); + } + ScanEvent::Done { total, .. } => { + app.scanning = false; + app.status = + format!("{} items • {}", total, app.current_dir.display()); + } + ScanEvent::EntryError { path, message } => { + log::warn!("scan error on {}: {message}", path.display()); + } + ScanEvent::Fatal { dir, message } => { + app.scanning = false; + app.entries.clear(); + app.status = + format!("cannot read {}: {message}", dir.display()); + } + } + Task::none() + } + + Message::WatchEvent(event) => { + handle_watch_event(app, event); + Task::none() + } + + Message::PollMounts => { + app.mounts = poll_mounts(); + Task::none() + } + Message::MountsUpdated(m) => { + app.mounts = m; + Task::none() + } + + Message::KeyPressed(key, modifiers) => { + handle_key(app, key, modifiers) + } + + Message::PathBarToggleEdit => { + app.pathbar_editing = !app.pathbar_editing; + if app.pathbar_editing { + app.pathbar_input = app.current_dir.display().to_string(); + } + Task::none() + } + Message::PathBarEdit(s) => { + app.pathbar_input = s; + Task::none() + } + Message::PathBarSubmit => { + let target = PathBuf::from(&app.pathbar_input); + app.pathbar_editing = false; + if target.is_dir() { + app.navigate(target); + } else { + app.status = format!("not a directory: {}", target.display()); + } + Task::none() + } + Message::PathBarSegment(p) => { + app.navigate(p); + Task::none() + } + Message::GoUp => { + if let Some(parent) = app.current_dir.parent() { + app.navigate(parent.to_path_buf()); + } + Task::none() + } + + Message::MountSelected(p) => { + app.navigate(p); + Task::none() + } + Message::BookmarkSelected(p) => { + app.navigate(p); + Task::none() + } + Message::AddBookmarkFor(p) => { + if app.config.add_bookmark(p.clone()) { + let cfg = app.config.clone(); + return Task::perform( + async move { cfg.save().map_err(|e| e.to_string()) }, + Message::ConfigSaved, + ); + } + Task::none() + } + Message::RemoveBookmark(p) => { + if app.config.remove_bookmark(&p) { + let cfg = app.config.clone(); + return Task::perform( + async move { cfg.save().map_err(|e| e.to_string()) }, + Message::ConfigSaved, + ); + } + Task::none() + } + + Message::EntrySelected(i) => { + if i < app.entries.len() { + app.selected = Some(i); + } + Task::none() + } + Message::EntryClicked(i) => { + // Record this row as a drag candidate. If the user holds the + // button and moves (detected via GridRowMouseMoved), this + // entry's path becomes the drag source. + app.drag_candidate_idx = Some(i); + + // Double-click detection: if this click is on the same row as + // the previous one AND within DOUBLE_CLICK_MS, treat it as an + // activation. Otherwise just select the row and record the + // click for next time. + let now = std::time::Instant::now(); + let is_double = match app.last_click { + Some((prev_i, prev_t)) => { + prev_i == i + && now.duration_since(prev_t) + < std::time::Duration::from_millis(DOUBLE_CLICK_MS) + } + None => false, + }; + + if is_double { + // Reset click history so a triple-click doesn't re-activate. + app.last_click = None; + // Single-click select first (so the row visually highlights + // even if activation navigates away immediately), then + // activate. The shift modifier for "enter AppDir as dir" + // only applies to keyboard Shift+Enter; mouse double-click + // always activates normally. + if i < app.entries.len() { + app.selected = Some(i); + } + if let Some(entry) = app.entries.get(i).cloned() { + return activate_entry(app, entry, false); + } + } else { + app.last_click = Some((i, now)); + if i < app.entries.len() { + app.selected = Some(i); + } + } + Task::none() + } + Message::EntryActivated(i, shift) => { + if let Some(entry) = app.entries.get(i).cloned() { + return activate_entry(app, entry, shift); + } + Task::none() + } + Message::Reload => { + app.scan_id = app.scan_id.wrapping_add(1); + app.entries.clear(); + app.selected = None; + app.scanning = true; + app.status = format!("reloading {}...", app.current_dir.display()); + Task::none() + } + + Message::LaunchDone(outcome) => { + match outcome { + LaunchOutcome::Spawned(msg) => app.status = msg, + LaunchOutcome::NoHandler => { + app.status = "no handler for this file".into() + } + LaunchOutcome::Failed(e) => { + app.status = format!("launch error: {e}") + } + } + Task::none() + } + + Message::ConfigSaved(res) => { + match res { + Ok(()) => app.status = "bookmarks saved".into(), + Err(e) => app.status = format!("save failed: {e}"), + } + Task::none() + } + + Message::MenuToggled(kind) => { + // Toggle: clicking the same menu button again closes it. + // Clicking a different one switches to it. + if app.open_menu == Some(kind) { + app.open_menu = None; + } else { + app.open_menu = Some(kind); + // Close context menu when opening a menubar dropdown. + app.context_menu_target = None; + } + Task::none() + } + Message::CloseMenus => { + app.open_menu = None; + app.context_menu_target = None; + Task::none() + } + Message::EntryRightClicked(i) => { + if i < app.entries.len() { + // Select the row too (matches Thunar / PCManFM behavior) + // so subsequent keyboard actions apply to it. + app.selected = Some(i); + app.context_menu_target = Some(i); + // Close menubar if open. + app.open_menu = None; + } + Task::none() + } + Message::NotImplemented(what) => { + app.open_menu = None; + app.context_menu_target = None; + app.status = format!("{what}: not implemented yet"); + Task::none() + } + Message::ShowAbout => { + app.open_menu = None; + app.context_menu_target = None; + app.show_about = true; + Task::none() + } + Message::CloseAbout => { + app.show_about = false; + Task::none() + } + Message::Noop => Task::none(), + + // --- Drag-and-drop --- + Message::GlobalMousePressed(pos) => { + // Record press position for threshold computation. The drag + // candidate index is set separately by the grid row's on_press. + app.drag_press_pos = Some(pos); + Task::none() + } + Message::GridRowMouseMoved(_pos) => { + // Fired by mouse_area::on_move on grid rows. on_move fires + // whenever the cursor moves while over the row — since the + // row is small (~24px), any on_move while the button is held + // means the user is moving the cursor, which we treat as a + // drag. (A proper threshold would require the global cursor + // position, which iced 0.13's on_move doesn't provide — it + // gives widget-relative coordinates only.) + if app.drag_source.is_none() { + if let Some(idx) = app.drag_candidate_idx { + if let Some(entry) = app.entries.get(idx).cloned() { + // Only directories and AppDirs can be dragged to + // the bookmarks (files don't make sense as + // bookmarks — you can't navigate into them). + if matches!( + entry.kind, + EntryKind::Directory | EntryKind::AppDir { .. } + ) { + app.drag_source = Some(entry.path.clone()); + app.status = format!( + "dragging {}… drop on a bookmark slot", + entry.path.display() + ); + } + } + } + } + Task::none() + } + Message::DragEnteredBookmark(idx) => { + if app.drag_source.is_some() { + app.drag_hover_bookmark = Some(idx); + } + Task::none() + } + Message::DragEnteredBookmarkAppendZone => { + if app.drag_source.is_some() { + app.drag_hover_bookmark = Some(app.bookmarks().len()); + } + Task::none() + } + Message::DragExitedBookmarks => { + if app.drag_source.is_some() { + app.drag_hover_bookmark = None; + } + Task::none() + } + Message::GlobalMouseReleased => { + // Button released. If we were dragging, commit the drop. + if let Some(path) = app.drag_source.take() { + if let Some(idx) = app.drag_hover_bookmark { + // Insert at the hovered index. The Bookmarks::insert_at + // handles clamping and dedup (move-if-exists). + app.config.insert_bookmark_at(path.clone(), idx); + let cfg = app.config.clone(); + app.status = format!("bookmarked {} at position {}", path.display(), idx); + // Clear drag visual state. + app.drag_hover_bookmark = None; + app.drag_candidate_idx = None; + app.drag_press_pos = None; + return Task::perform( + async move { cfg.save().map_err(|e| e.to_string()) }, + Message::ConfigSaved, + ); + } else { + // Dropped outside the bookmark zone — cancel. + app.status = "drag cancelled (dropped outside bookmarks)".into(); + app.drag_hover_bookmark = None; + app.drag_candidate_idx = None; + app.drag_press_pos = None; + } + } else { + // Not dragging — just a regular click release. Clear + // candidate state (the grid row's on_press already handled + // selection via EntryClicked). + app.drag_candidate_idx = None; + app.drag_press_pos = None; + } + Task::none() + } + } +} + +/// Interpret a raw key-press against current app state. +/// +/// This is the heart of keyboard navigation. It runs in `update()` where +/// we have `&mut App`, so we can branch on `pathbar_editing` (Escape only +/// exits edit mode — it does NOT toggle, fixing the bug where the +/// previous stateless handler turned editing ON when it was off), check +/// the current selection before activating, etc. +/// +/// Returns a `Task` because some keys (Enter on a File, Enter on an +/// AppDir) trigger async launches. +fn handle_key( + app: &mut App, + key: iced::keyboard::Key, + modifiers: iced::keyboard::Modifiers, +) -> Task { + use iced::keyboard::key::Named; + + let shift = modifiers.shift(); + + // Ctrl+L toggles edit mode (matches Nautilus / most file managers). + if modifiers.control() && matches!(&key, iced::keyboard::Key::Character(c) if c == "l") { + toggle_pathbar_edit(app); + return Task::none(); + } + + // Escape closes things in priority order: About → context menu → + // menubar dropdown → pathbar edit mode. Each guard returns early. + if matches!(key.as_ref(), iced::keyboard::Key::Named(Named::Escape)) { + if app.show_about { + return update(app, Message::CloseAbout); + } + if app.context_menu_target.is_some() || app.open_menu.is_some() { + return update(app, Message::CloseMenus); + } + app.pathbar_editing = false; + return Task::none(); + } + + // Plain "/" enters edit mode (ROX/PCManFM convention). Only when + // not already editing — pressing / while editing lets the text + // input receive the character normally. + if !app.pathbar_editing + && !modifiers.control() + && !modifiers.alt() + && matches!(&key, iced::keyboard::Key::Character(c) if c == "/") + { + toggle_pathbar_edit(app); + return Task::none(); + } + + // Arrow / vim navigation — only when not editing the pathbar (so + // J/K/H/L/Backspace do not hijack text input). + if app.pathbar_editing { + return Task::none(); + } + + if matches!(key.as_ref(), iced::keyboard::Key::Named(Named::ArrowUp)) { + move_selection(app, -1); + return Task::none(); + } + if matches!(key.as_ref(), iced::keyboard::Key::Named(Named::ArrowDown)) { + move_selection(app, 1); + return Task::none(); + } + if !shift && matches!(&key, iced::keyboard::Key::Character(c) if c == "k") { + move_selection(app, -1); + return Task::none(); + } + if !shift && matches!(&key, iced::keyboard::Key::Character(c) if c == "j") { + move_selection(app, 1); + return Task::none(); + } + if matches!(key.as_ref(), iced::keyboard::Key::Named(Named::Backspace)) { + return update(app, Message::GoUp); + } + if !shift && matches!(&key, iced::keyboard::Key::Character(c) if c == "h") { + return update(app, Message::GoUp); + } + // Enter / L activates the current selection. No-op if nothing is + // selected. + if matches!(key.as_ref(), iced::keyboard::Key::Named(Named::Enter)) { + return activate_selected(app, shift); + } + if !shift && matches!(&key, iced::keyboard::Key::Character(c) if c == "l") { + return activate_selected(app, shift); + } + + Task::none() +} + +/// Activate the currently-selected entry, if any. Factored out so both +/// the Enter and `L` key paths share the same logic. +fn activate_selected(app: &mut App, shift: bool) -> Task { + match app.selected { + Some(i) => update(app, Message::EntryActivated(i, shift)), + None => Task::none(), + } +} + +/// Toggle pathbar between breadcrumb and text-input modes. When entering +/// edit mode, pre-fill the input with the current directory's path so +/// the user can edit it in place. +fn toggle_pathbar_edit(app: &mut App) { + app.pathbar_editing = !app.pathbar_editing; + if app.pathbar_editing { + app.pathbar_input = app.current_dir.display().to_string(); + } +} + +/// Move the grid selection by `delta` rows (negative = up, positive = +/// down). Clamps to [0, len-1]. If nothing is selected, starts at row 0 +/// (for down) or the last row (for up). +fn move_selection(app: &mut App, delta: i32) { + if app.entries.is_empty() { + return; + } + let total = app.entries.len() as i32; + let cur = app.selected.map(|i| i as i32).unwrap_or_else(|| { + // No current selection: J/ArrowDown starts at 0, K/ArrowUp starts at last. + if delta > 0 { 0 } else { total - 1 } + }); + let next = (cur + delta).clamp(0, total - 1); + app.selected = Some(next as usize); +} + +/// Double-click / Enter on an entry. Branches on kind: +/// * Directory → navigate into it +/// * AppDir → spawn launcher (unless Shift is held, then navigate) +/// * File → launch via actions.toml or xdg-open fallback +/// * Symlink → resolve, then re-dispatch +fn activate_entry(app: &mut App, entry: FileEntry, shift: bool) -> Task { + match entry.kind { + EntryKind::Directory => { + app.navigate(entry.path); + Task::none() + } + EntryKind::AppDir { .. } if shift => { + // Shift+Double-Click enters the directory normally, per spec. + app.navigate(entry.path); + Task::none() + } + EntryKind::AppDir { .. } => { + let path = entry.path.clone(); + let kind = entry.kind.clone(); + let config = app.config.clone(); + Task::perform( + async move { + LaunchOutcome::from( + crate::launch::launch(&path, &kind, &config).await, + ) + }, + Message::LaunchDone, + ) + } + EntryKind::File => { + let path = entry.path.clone(); + let kind = entry.kind.clone(); + let config = app.config.clone(); + Task::perform( + async move { + LaunchOutcome::from( + crate::launch::launch(&path, &kind, &config).await, + ) + }, + Message::LaunchDone, + ) + } + EntryKind::Symlink { .. } => { + // Resolve and re-dispatch. We don't follow chains here; if the + // target is itself a symlink, the next activation will resolve + // again. UI feedback for dangling links could be nicer. + match std::fs::canonicalize(&entry.path) { + Ok(real) => { + if real.is_dir() { + app.navigate(real); + Task::none() + } else { + let config = app.config.clone(); + Task::perform( + async move { + LaunchOutcome::from( + crate::launch::launch(&real, &EntryKind::File, &config) + .await, + ) + }, + Message::LaunchDone, + ) + } + } + Err(e) => { + app.status = format!("dangling symlink: {e}"); + Task::none() + } + } + } + } +} + +/// Apply a `WatchEvent` to the entry list. We do a linear scan because +/// directory entry counts in normal use are modest (hundreds, not +/// millions); for the 10k+ pathological case a HashMap by path would +/// be faster — left as a future optimization. +fn handle_watch_event(app: &mut App, event: WatchEvent) { + match event { + WatchEvent::Created(path) => { + // For simplicity, trigger a reload — re-listing the whole dir + // is correct in all cases (file replaced, attributes changed, + // etc.). A future optimization would stat the new entry and + // append it without a full rescan. + app.scan_id = app.scan_id.wrapping_add(1); + app.scanning = true; + app.status = format!("rescanning: {} created", path.display()); + } + WatchEvent::Removed(path) => { + app.entries.retain(|e| e.path != path); + if let Some(sel) = app.selected { + if sel >= app.entries.len() { + app.selected = if app.entries.is_empty() { + None + } else { + Some(app.entries.len() - 1) + }; + } + } + app.status = format!("- {} removed", path.display()); + } + WatchEvent::Modified(path) => { + // Touch the entry's display by reloading its metadata. + // For now, just update the status bar. + if app.entries.iter().any(|e| e.path == path) { + app.status = format!("~ {} modified", path.display()); + } + } + WatchEvent::Renamed { from, to } => { + if let Some(entry) = app.entries.iter_mut().find(|e| e.path == from) { + entry.path = to.clone(); + entry.name = to + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_default(); + entry.hidden = entry.name.starts_with('.'); + } + app.status = format!("{} → {}", from.display(), to.display()); + } + WatchEvent::WatchError(msg) => { + app.status = format!("watch error: {msg}"); + } + } +} + +// ---------- View ------------------------------------------------------------ + +pub fn view(app: &App) -> Element<'_, Message> { + // About dialog takes over the whole window when open. + if app.show_about { + return about::view(app); + } + + let menubar_el = menubar::view(app); + let pathbar = pathbar::view(app); + let sidebar = sidebar::view(app); + let grid = grid::view(app); + + let body = row![sidebar, grid].spacing(0).height(Length::Fill); + + let status_bar = container( + row![ + text(app.status()).size(12).color(Color::from_rgb(0.5, 0.5, 0.5)), + text("") + ] + .align_y(Alignment::Center) + .spacing(8), + ) + .padding([4, 10]) + .style(|_: &Theme| container::Style { + background: Some(Color::from_rgb(0.12, 0.12, 0.13).into()), + border: iced::Border { + color: Color::from_rgb(0.25, 0.25, 0.27), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }); + + // Main column: menubar / pathbar / [dropdown if open] / body / status_bar. + // + // iced 0.13 doesn't ship a native overlay/menu widget, so we render + // dropdowns and context menus as inline panels rather than true + // overlays. The menubar dropdown pushes the body down by its height + // while open; the context menu appears inside a row alongside the + // sidebar+grid body. Both close on any click outside their items + // (handled by the toggle/CloseMenus message arms in update()). + let mut main_col = column![menubar_el, pathbar].spacing(0); + + if let Some(dropdown) = menubar::dropdown(app) { + main_col = main_col.push(dropdown); + } + + // If a context menu is open, render it as a panel to the right of + // the sidebar+grid body. Clicking any item in it emits the item's + // message AND we manually close via the item's handler (or the + // user can press Escape). + if let Some(ctx_menu) = context_menu::view(app) { + let with_menu = row![body, ctx_menu].spacing(0).height(Length::Fill); + main_col = main_col.push(with_menu); + } else { + main_col = main_col.push(body); + } + + main_col = main_col.push(status_bar); + + main_col.spacing(0).height(Length::Fill).into() +} + +// ---------- Subscription ---------------------------------------------------- + +pub fn subscription(app: &App) -> Subscription { + let dir = app.current_dir.clone(); + let scan_id = app.scan_id; + + // Scan: one-shot per (dir, scan_id). The closure captures the dir at + // subscription creation time; bumping scan_id drops the old sub and + // starts a new one with the new dir. + let scan_sub = Subscription::run_with_id( + ("scan", scan_id), + stream::channel(256, move |mut tx| { + let dir = dir.clone(); + async move { + let (vfs_tx, mut vfs_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let scan_dir = dir.clone(); + tokio::spawn(async move { + scan_directory(scan_dir, vfs_tx).await; + }); + while let Some(event) = vfs_rx.recv().await { + // iced's futures::mpsc::Sender uses an async `send`. + if tx.send(Message::ScanEvent(event)).await.is_err() { + break; // UI dropped the receiver — pane closed. + } + } + } + }), + ); + + // Watch: same lifecycle as scan — restarts when scan_id bumps. + let watch_dir = app.current_dir.clone(); + let watch_sub = Subscription::run_with_id( + ("watch", scan_id), + stream::channel(64, move |mut tx| { + let dir = watch_dir.clone(); + async move { + let (vfs_tx, mut vfs_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let _watcher = match watch_directory(&dir, vfs_tx) { + Ok(w) => w, + Err(e) => { + log::warn!("watcher failed for {}: {e}", dir.display()); + return; + } + }; + while let Some(event) = vfs_rx.recv().await { + if tx.send(Message::WatchEvent(event)).await.is_err() { + break; + } + } + } + }), + ); + + // Mount polling: every 5 seconds. Cheap (one /proc/mounts read) and + // catches USB insertions without a udev dependency. + let mount_sub = + time::every(Duration::from_secs(5)).map(|_| Message::PollMounts); + + // Global mouse event subscription for drag-and-drop. We listen for + // left-button press (to record the press position for threshold + // computation) and left-button release (to commit or cancel a drag). + // CursorMoved events are NOT subscribed globally (too high-frequency); + // instead, per-grid-row `mouse_area::on_move` handles drag detection. + let mouse_sub = iced::event::listen_with(mouse_event_filter); + + Subscription::batch([scan_sub, watch_sub, mount_sub, mouse_sub]) +} + +/// Filter function for the global mouse event subscription. Emits +/// `GlobalMousePressed(pos)` on left-button press and `GlobalMouseReleased` +/// on left-button release. All other events are filtered out (return None). +fn mouse_event_filter( + event: iced::Event, + _status: iced::event::Status, + _window: iced::window::Id, +) -> Option { + use iced::mouse::Button; + use iced::Event; + match event { + Event::Mouse(iced::mouse::Event::ButtonPressed(Button::Left)) => { + // We don't get the cursor position from this event variant, + // but we don't need it — the grid row's on_press provides the + // candidate index, and on_move provides the movement detection. + // The press position is approximated as the origin; the + // threshold check uses on_move's relative position instead. + Some(Message::GlobalMousePressed(iced::Point::ORIGIN)) + } + Event::Mouse(iced::mouse::Event::ButtonReleased(Button::Left)) => { + Some(Message::GlobalMouseReleased) + } + _ => None, + } +} + +// ---------- Helpers shared by subviews -------------------------------------- + +/// Build the small square "icon button" used in the pathbar (up, edit, etc.). +pub(crate) fn icon_button( + icon: crate::icons::Icon, + msg: Message, +) -> button::Button<'static, Message> { + button(icon.widget(14.0)) + .padding(4) + .style(button::secondary) + .on_press(msg) +} + +/// Default font (no custom font loading yet — keeps the binary smaller). +#[allow(dead_code)] +pub(crate) const DEFAULT_FONT: Font = Font::DEFAULT; + +/// Format a `SystemTime` for display in the grid. Returns "?" on failure. +/// +/// Thin wrapper around `crate::date::format_date`, which uses Howard +/// Hinnant's `civil_from_days` algorithm for exact leap-year-correct +/// date math without pulling in `chrono`. +pub(crate) fn format_modified(t: Option) -> String { + crate::date::format_date(t) +} + +/// Human-readable file size (1.2 KB, 3.4 MB, etc.). Directories report 0. +pub(crate) fn format_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit_idx = 0; + while value >= 1024.0 && unit_idx < UNITS.len() - 1 { + value /= 1024.0; + unit_idx += 1; + } + if unit_idx == 0 { + format!("{} {}", bytes, UNITS[0]) + } else { + format!("{:.1} {}", value, UNITS[unit_idx]) + } +} diff --git a/src/ui/pathbar.rs b/src/ui/pathbar.rs new file mode 100644 index 0000000..c9bad60 --- /dev/null +++ b/src/ui/pathbar.rs @@ -0,0 +1,98 @@ +//! Top pathbar — breadcrumb view, toggleable to a raw editable text input +//! via the edit button, `/` key, or `Ctrl+L` (handled in main.rs key bindings). +//! +//! In breadcrumb mode, each path segment is a clickable button that +//! navigates directly to that ancestor. The full path is always visible. + +use super::{icon_button, Message}; +use crate::icons::Icon; +use iced::widget::{button, row, text, text_input}; +use iced::{Alignment, Color, Element, Length}; +use std::path::PathBuf; + +pub fn view<'a>(app: &'a super::App) -> Element<'a, Message> { + let up = icon_button(Icon::Up, Message::GoUp); + let reload = icon_button(Icon::Reload, Message::Reload); + + let middle: Element = if app.pathbar_editing() { + text_input("path…", app.pathbar_input()) + .on_submit(Message::PathBarSubmit) + .on_input(Message::PathBarEdit) + .size(14) + .width(Length::Fill) + .into() + } else { + breadcrumbs(app) + }; + + let edit_btn = icon_button(Icon::Edit, Message::PathBarToggleEdit); + + row![up, reload, middle, edit_btn] + .spacing(4) + .padding([6, 8]) + .align_y(Alignment::Center) + .height(Length::Shrink) + .into() +} + +/// Render the current path as a series of clickable segments separated +/// by `/`. Clicking any segment navigates to that ancestor. +fn breadcrumbs<'a>(app: &'a super::App) -> Element<'a, Message> { + let dir = app.current_dir(); + let mut segments: Vec<(String, PathBuf)> = Vec::new(); + let mut acc = PathBuf::new(); + + // Always start with "/" as the root segment. + segments.push(("/".to_string(), PathBuf::from("/"))); + + for component in dir.components() { + use std::path::Component; + match component { + Component::RootDir => {} + Component::Normal(name) => { + acc.push(name); + let label = name.to_string_lossy().into_owned(); + segments.push((label, acc.clone())); + } + Component::ParentDir => { + acc.push(".."); + segments.push(("..".into(), acc.clone())); + } + _ => {} + } + } + + let total = segments.len(); + let mut row_state = row![].spacing(2).align_y(Alignment::Center); + for (i, (label, path)) in segments.into_iter().enumerate() { + if i > 0 { + row_state = row_state.push( + text("/") + .size(13) + .color(Color::from_rgb(0.55, 0.55, 0.6)), + ); + } + let is_last = i == total - 1; + let color = if is_last { + Color::from_rgb(0.95, 0.95, 0.95) + } else { + Color::from_rgb(0.6, 0.7, 0.85) + }; + let btn = button( + text(label) + .size(13) + .color(color) + .width(Length::Shrink), + ) + .padding([2, 6]) + .style(if is_last { + button::primary + } else { + button::secondary + }) + .on_press(Message::PathBarSegment(path)); + row_state = row_state.push(btn); + } + + row_state.width(Length::Fill).into() +} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs new file mode 100644 index 0000000..8979c3e --- /dev/null +++ b/src/ui/sidebar.rs @@ -0,0 +1,279 @@ +//! Left sidebar — three sections: +//! +//! 1. DEVICES — mounts polled from /proc/mounts (real storage only, +//! pseudo-FSes filtered out by src/mounts.rs). +//! +//! 2. LOCATIONS — hardcoded default shortcuts: /mnt, /var/run/media, +//! /opt, /usr/src, $HOME, $HOME/Downloads. Always +//! present; not user-removable. Defined in +//! src/config/defaults.rs. +//! +//! 3. BOOKMARKS — purely user-added paths. Seeded empty on first run. +//! No XDG defaults are ever injected here. +//! +//! The sidebar is intentionally narrow — this is a "clean canvas" tuned +//! for power users, not a clone of Nautilus's preset-heavy sidebar. + +use super::Message; +use crate::config::bookmarks::BookmarkEntry; +use crate::config::DefaultLocation; +use crate::icons::Icon; +use crate::mounts::MountInfo; +use iced::widget::{button, column, container, mouse_area, scrollable, text, Space}; +use iced::{Alignment, Color, Element, Length}; +use std::path::PathBuf; + +pub fn view<'a>(app: &'a super::App) -> Element<'a, Message> { + let devices_label = section_label("DEVICES"); + let mut devices_col = column![].spacing(2); + + for mount in app.mounts() { + devices_col = devices_col.push(mount_row(mount)); + } + + if app.mounts().is_empty() { + devices_col = devices_col.push( + text("(no mounts found)") + .size(11) + .color(Color::from_rgb(0.45, 0.45, 0.5)), + ); + } + + let locations_label = section_label("LOCATIONS"); + let mut locations_col = column![].spacing(2); + for loc in crate::config::default_locations() { + locations_col = locations_col.push(location_row(&loc)); + } + + let bookmarks_label = section_label("BOOKMARKS"); + let mut bookmarks_col = column![].spacing(2); + + // Render each bookmark row with its index, so on_enter can report + // which slot the cursor is hovering over during a drag. + for (idx, entry) in app.bookmarks().iter().enumerate() { + let is_drop_target = app.drag_hover_bookmark() == Some(idx); + bookmarks_col = bookmarks_col.push(bookmark_row(idx, entry, is_drop_target)); + } + + // Append zone: the empty area below the last bookmark. During a drag, + // entering this zone sets hover = Some(len) so the drop appends. + let is_append_target = app.drag_hover_bookmark() == Some(app.bookmarks().len()); + if app.bookmarks().is_empty() { + // Show hint text when empty — also serves as the drop zone. + let hint = text("(drag a folder here,") + .size(11) + .color(Color::from_rgb(0.45, 0.45, 0.5)); + let hint2 = text("or edit bookmarks.toml)") + .size(11) + .color(Color::from_rgb(0.45, 0.45, 0.5)); + let hints = column![hint, hint2].spacing(0).padding([3, 6]); + let zone = if is_append_target { + container(hints) + .width(Length::Fill) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.18, 0.32, 0.55).into()), + border: iced::Border { + color: Color::from_rgb(0.3, 0.5, 0.8), + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }) + } else { + container(hints).width(Length::Fill) + }; + bookmarks_col = bookmarks_col.push(mouse_area(zone) + .on_enter(Message::DragEnteredBookmarkAppendZone) + .on_exit(Message::DragExitedBookmarks)); + } else { + // Non-empty: show a small drop zone below the last entry. + let zone_height = if is_append_target { 24.0 } else { 12.0 }; + let zone = container(Space::new(Length::Fill, zone_height)) + .width(Length::Fill) + .style(move |_: &iced::Theme| { + if is_append_target { + container::Style { + background: Some(Color::from_rgb(0.18, 0.32, 0.55).into()), + border: iced::Border { + color: Color::from_rgb(0.3, 0.5, 0.8), + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } + } else { + container::Style::default() + } + }); + bookmarks_col = bookmarks_col.push(mouse_area(zone) + .on_enter(Message::DragEnteredBookmarkAppendZone) + .on_exit(Message::DragExitedBookmarks)); + } + + let content = column![ + devices_label, + devices_col, + Space::new(0, 12), + locations_label, + locations_col, + Space::new(0, 12), + bookmarks_label, + bookmarks_col, + ] + .spacing(4) + .padding([8, 6]); + + // Scrollable in case the user has many mounts / bookmarks. + let scrolled = scrollable(content).width(Length::Fill).height(Length::Fill); + + container(scrolled) + .width(180) + .height(Length::Fill) + .style(|_: &iced::Theme| container::Style { + background: Some(Color::from_rgb(0.11, 0.11, 0.12).into()), + border: iced::Border { + color: Color::from_rgb(0.25, 0.25, 0.27), + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() +} + +fn section_label(s: &'static str) -> Element<'static, Message> { + text(s) + .size(10) + .font(iced::Font { + weight: iced::font::Weight::Bold, + ..iced::Font::DEFAULT + }) + .color(Color::from_rgb(0.55, 0.55, 0.6)) + .into() +} + +fn mount_row(mount: &MountInfo) -> Element<'_, Message> { + let icon = if mount.is_network { + Icon::Network + } else { + Icon::Device + }; + button( + iced::widget::row![ + icon.widget(14.0), + text(mount.label.clone()).size(12), + ] + .spacing(6) + .align_y(Alignment::Center), + ) + .padding([3, 6]) + .width(Length::Fill) + .style(button::secondary) + .on_press(Message::MountSelected(mount.target.clone())) + .into() +} + +fn location_row(loc: &DefaultLocation) -> Element<'static, Message> { + // Locations whose path didn't resolve (e.g. $HOME unset) are shown + // grayed-out and non-interactive, so users can see they exist but + // can't click into nothing. + let label_color = if loc.path.is_some() { + Color::from_rgb(0.92, 0.92, 0.93) + } else { + Color::from_rgb(0.4, 0.4, 0.45) + }; + + // Static label + Copy icon, so we can build a 'static row_content. + // Path gets cloned into the message closure. + let label: &'static str = loc.label; + let icon = loc.icon; + + let row_content = iced::widget::row![ + icon.widget(14.0), + text(label).size(12).color(label_color), + ] + .spacing(6) + .align_y(Alignment::Center); + + match loc.path.clone() { + Some(p) => button(row_content) + .padding([3, 6]) + .width(Length::Fill) + .style(button::secondary) + .on_press(Message::MountSelected(p)) + .into(), + None => container(row_content) + .padding([3, 6]) + .width(Length::Fill) + .into(), + } +} + +fn bookmark_row( + idx: usize, + entry: &BookmarkEntry, + is_drop_target: bool, +) -> Element<'_, Message> { + let label: String = entry + .label + .clone() + .or_else(|| { + entry + .path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| entry.path.display().to_string()); + + let row_content = iced::widget::row![ + Icon::Bookmark.widget(14.0), + text(label).size(12), + ] + .spacing(6) + .align_y(Alignment::Center); + + let styled = container(row_content) + .padding([3, 6]) + .width(Length::Fill) + .style(move |_: &iced::Theme| { + if is_drop_target { + // Highlight the row when the cursor is hovering over it + // during a drag — visual feedback for where the drop lands. + container::Style { + background: Some(Color::from_rgb(0.18, 0.32, 0.55).into()), + border: iced::Border { + color: Color::from_rgb(0.3, 0.5, 0.8), + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } + } else { + container::Style { + background: Some(Color::from_rgb(0.14, 0.14, 0.15).into()), + ..Default::default() + } + } + }); + + // mouse_area gives us on_enter (for drag hover tracking) AND on_press + // (for click-to-navigate). The on_enter fires on every cursor entry, + // but update() only records the hover index when a drag is in progress. + mouse_area(styled) + .on_press(Message::BookmarkSelected(entry.path.clone())) + .on_enter(Message::DragEnteredBookmark(idx)) + .on_exit(Message::DragExitedBookmarks) + .into() +} + +/// Helper for the "add current dir to bookmarks" action — exposed so the +/// main view can show a star button in the pathbar area if desired. +#[allow(dead_code)] +pub fn add_bookmark_button(current: PathBuf) -> Element<'static, Message> { + button(Icon::Bookmark.widget(14.0)) + .padding(4) + .style(button::secondary) + .on_press(Message::AddBookmarkFor(current)) + .into() +} diff --git a/src/vfs/appdir.rs b/src/vfs/appdir.rs new file mode 100644 index 0000000..5ff943e --- /dev/null +++ b/src/vfs/appdir.rs @@ -0,0 +1,122 @@ +//! AppDir detection (ROX-Filer / Puppy Linux paradigm). +//! +//! A directory is treated as an "AppDir" — a double-click-to-launch +//! application bundle — if it contains either: +//! 1. An `AppRun` file that is executable, or +//! 2. A file matching the directory's own name that is executable. +//! +//! Shift+Double-Click is handled at the UI layer to force normal directory +//! entry regardless of AppDir status; this module only answers the +//! detection question. + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +/// Result of an AppDir probe: the executable that should be launched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppDirInfo { + /// The directory that was probed. + pub dir: PathBuf, + /// The executable entry point inside it (AppRun, or a same-named file). + pub launcher: PathBuf, +} + +/// Probe `dir_path` to see whether it qualifies as an AppDir. +/// +/// Returns `None` if the path is not a directory, or contains no viable +/// launcher. This does I/O (metadata lookups) so it is async; callers +/// scanning many directories should run these concurrently (e.g. via +/// `tokio::task::JoinSet` or a `rayon` pool for the sync metadata calls). +pub async fn detect(dir_path: &Path) -> Option { + let meta = tokio::fs::metadata(dir_path).await.ok()?; + if !meta.is_dir() { + return None; + } + + // 1. Canonical ROX convention: AppRun + let apprun = dir_path.join("AppRun"); + if is_executable_file(&apprun).await { + return Some(AppDirInfo { + dir: dir_path.to_path_buf(), + launcher: apprun, + }); + } + + // 2. Puppy/DSL convention: a file named identically to the directory + if let Some(dir_name) = dir_path.file_name() { + let candidate = dir_path.join(dir_name); + if is_executable_file(&candidate).await { + return Some(AppDirInfo { + dir: dir_path.to_path_buf(), + launcher: candidate, + }); + } + } + + None +} + +/// True if `path` exists, is a regular file (or symlink to one), and has +/// at least one executable bit set for user, group, or other. +async fn is_executable_file(path: &Path) -> bool { + match tokio::fs::metadata(path).await { + Ok(meta) => meta.is_file() && (meta.permissions().mode() & 0o111 != 0), + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::fs; + + #[tokio::test] + async fn detects_apprun() { + let tmp = tempdir(); + let apprun = tmp.join("AppRun"); + fs::write(&apprun, b"#!/bin/sh\necho hi\n").await.unwrap(); + let mut perms = fs::metadata(&apprun).await.unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&apprun, perms).await.unwrap(); + + let result = detect(&tmp).await; + assert_eq!(result.unwrap().launcher, apprun); + + let _ = fs::remove_dir_all(&tmp).await; + } + + #[tokio::test] + async fn detects_same_name_executable() { + let tmp = tempdir(); + let name = tmp.file_name().unwrap().to_owned(); + let bin = tmp.join(&name); + fs::write(&bin, b"#!/bin/sh\necho hi\n").await.unwrap(); + let mut perms = fs::metadata(&bin).await.unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin, perms).await.unwrap(); + + let result = detect(&tmp).await; + assert_eq!(result.unwrap().launcher, bin); + + let _ = fs::remove_dir_all(&tmp).await; + } + + #[tokio::test] + async fn plain_directory_is_not_an_appdir() { + let tmp = tempdir(); + assert!(detect(&tmp).await.is_none()); + let _ = fs::remove_dir_all(&tmp).await; + } + + fn tempdir() -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("runar-fm-test-{}", uuid_like())); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn uuid_like() -> u128 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + } +} diff --git a/src/vfs/mod.rs b/src/vfs/mod.rs new file mode 100644 index 0000000..30ee8c9 --- /dev/null +++ b/src/vfs/mod.rs @@ -0,0 +1,244 @@ +//! Virtual filesystem layer: non-blocking directory scanning. +//! +//! Design goal (per project spec, "Zero-Lock Non-Blocking I/O"): the GUI +//! thread must never block on directory reads, stat() calls, or AppDir +//! probing. `scan_directory` runs entirely on the tokio runtime and +//! streams results back over a tokio channel as they become available, +//! so the UI can render entries incrementally instead of waiting for the +//! whole directory to be read (important for large dirs / slow NFS mounts). +//! +//! The iced UI layer bridges these tokio channels into iced subscriptions +//! via a small adapter (see `src/ui/mod.rs::subscription`). + +pub mod appdir; +pub mod watcher; + +use mime_guess::MimeGuess; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; +use tokio::sync::mpsc::UnboundedSender; + +pub use watcher::{watch_directory, WatchEvent}; + +/// What kind of thing a scanned entry is, for launch-dispatch purposes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryKind { + File, + Directory, + /// A directory that qualifies as a ROX-style AppDir; carries the + /// launcher path so the dispatcher doesn't need to re-probe it. + AppDir { launcher: PathBuf }, + Symlink { target: Option }, +} + +/// A single scanned filesystem entry, ready for display in the grid/list. +#[derive(Debug, Clone)] +pub struct FileEntry { + pub path: PathBuf, + pub name: String, + pub kind: EntryKind, + pub size: u64, + pub modified: Option, + /// Permission bits — populated during scan, used by future file-ops + /// and permissions-dialog UI. Currently unread but kept for that + /// upcoming feature rather than re-adding later. + #[allow(dead_code)] + pub readable: bool, + #[allow(dead_code)] + pub writable: bool, + #[allow(dead_code)] + pub executable: bool, + pub mime_type: Option, + pub hidden: bool, +} + +/// Events streamed out of `scan_directory` as the read progresses. +#[derive(Debug, Clone)] +pub enum ScanEvent { + /// One entry has been fully stat'd (and AppDir-probed, if applicable) + /// and is ready to display. + Entry(FileEntry), + /// The scan of `dir` has finished; `total` entries were emitted. + Done { + #[allow(dead_code)] + dir: PathBuf, + total: usize, + }, + /// A per-entry error (e.g. permission denied on stat) — non-fatal, + /// the scan continues with the next entry. + EntryError { path: PathBuf, message: String }, + /// The directory itself could not be opened at all — fatal for this + /// scan. + Fatal { dir: PathBuf, message: String }, +} + +/// Scan `dir` asynchronously, sending a `ScanEvent` for every entry as +/// soon as it's ready, plus a final `Done`. Errors on individual entries +/// are reported via `EntryError` and do not abort the scan; only a +/// failure to open the directory itself is `Fatal`. +/// +/// This does not recurse — each pane scans exactly one directory level, +/// consistent with the non-recursive watcher above. +pub async fn scan_directory(dir: PathBuf, tx: UnboundedSender) { + let mut read_dir = match tokio::fs::read_dir(&dir).await { + Ok(rd) => rd, + Err(e) => { + let _ = tx.send(ScanEvent::Fatal { + dir, + message: e.to_string(), + }); + return; + } + }; + + let mut total = 0usize; + + loop { + let next = read_dir.next_entry().await; + let entry = match next { + Ok(Some(entry)) => entry, + Ok(None) => break, // exhausted + Err(e) => { + let _ = tx.send(ScanEvent::EntryError { + path: dir.clone(), + message: e.to_string(), + }); + break; + } + }; + + let path = entry.path(); + match build_file_entry(&path).await { + Ok(file_entry) => { + total += 1; + if tx.send(ScanEvent::Entry(file_entry)).is_err() { + // Receiver dropped — pane was closed mid-scan. Stop + // doing work nobody is listening to. + return; + } + } + Err(e) => { + let _ = tx.send(ScanEvent::EntryError { + path: path.clone(), + message: e.to_string(), + }); + } + } + } + + let _ = tx.send(ScanEvent::Done { dir, total }); +} + +async fn build_file_entry(path: &Path) -> std::io::Result { + // Use symlink_metadata first so we can tell a symlink from its target + // without silently following it. + let link_meta = tokio::fs::symlink_metadata(path).await?; + let is_symlink = link_meta.file_type().is_symlink(); + + let meta = if is_symlink { + // Follow the link for size/permissions display, but don't fail + // the whole entry if it's a dangling link. + tokio::fs::metadata(path).await.ok() + } else { + Some(link_meta.clone()) + }; + + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + let hidden = name.starts_with('.'); + + let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false); + + let kind = if is_symlink { + let target = tokio::fs::read_link(path).await.ok(); + EntryKind::Symlink { target } + } else if is_dir { + match appdir::detect(path).await { + Some(info) => EntryKind::AppDir { + launcher: info.launcher, + }, + None => EntryKind::Directory, + } + } else { + EntryKind::File + }; + + let (readable, writable, executable) = match &meta { + Some(m) => { + let mode = m.permissions().mode(); + (mode & 0o400 != 0, mode & 0o200 != 0, mode & 0o100 != 0) + } + None => (false, false, false), + }; + + let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); + let modified = meta.as_ref().and_then(|m| m.modified().ok()); + + let mime_type = if matches!(kind, EntryKind::File) { + MimeGuess::from_path(path) + .first() + .map(|m| m.essence_str().to_string()) + } else { + None + }; + + Ok(FileEntry { + path: path.to_path_buf(), + name, + kind, + size, + modified, + readable, + writable, + executable, + mime_type, + hidden, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc; + + #[tokio::test] + async fn scans_a_directory_and_reports_done() { + let mut tmp = std::env::temp_dir(); + tmp.push(format!( + "runar-fm-scan-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + tokio::fs::create_dir_all(&tmp).await.unwrap(); + tokio::fs::write(tmp.join("a.txt"), b"hi").await.unwrap(); + tokio::fs::write(tmp.join("b.txt"), b"there").await.unwrap(); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let dir = tmp.clone(); + tokio::spawn(scan_directory(dir, tx)); + + let mut seen = 0; + let mut done = false; + while let Some(event) = rx.recv().await { + match event { + ScanEvent::Entry(_) => seen += 1, + ScanEvent::Done { total, .. } => { + assert_eq!(total, 2); + done = true; + break; + } + ScanEvent::Fatal { message, .. } => panic!("scan failed: {message}"), + ScanEvent::EntryError { message, .. } => panic!("entry error: {message}"), + } + } + assert_eq!(seen, 2); + assert!(done); + + let _ = tokio::fs::remove_dir_all(&tmp).await; + } +} diff --git a/src/vfs/watcher.rs b/src/vfs/watcher.rs new file mode 100644 index 0000000..24ab759 --- /dev/null +++ b/src/vfs/watcher.rs @@ -0,0 +1,94 @@ +//! Real-time directory monitoring via `notify` (inotify on Linux). +//! +//! `notify`'s watcher calls back on its own internal thread, so we bridge +//! those callbacks into a `tokio::sync::mpsc::UnboundedSender`. The GUI +//! layer (Phase 2) will hold the receiving end on the async runtime and +//! forward events to the directory model via `glib::MainContext::spawn`. + +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::{Path, PathBuf}; +use tokio::sync::mpsc::UnboundedSender; + +/// A filesystem change relevant to the currently displayed directory. +#[derive(Debug, Clone)] +pub enum WatchEvent { + Created(PathBuf), + Removed(PathBuf), + Modified(PathBuf), + Renamed { from: PathBuf, to: PathBuf }, + /// Emitted when the watch itself hits an error (e.g. watched dir was + /// deleted out from under us, or an unmounted network share dropped). + WatchError(String), +} + +/// Owns the live `notify` watcher. Dropping this stops the watch, so the +/// caller must keep it alive (e.g. store it alongside the active tab/pane +/// state) for as long as the directory should be monitored. +pub struct DirWatcher { + _inner: RecommendedWatcher, + #[allow(dead_code)] + pub path: PathBuf, +} + +/// Begin watching `path` (non-recursively — each open pane watches only +/// its own directory, so navigating into a subdirectory spins up a new +/// watcher for that path and drops the old one). +pub fn watch_directory( + path: &Path, + tx: UnboundedSender, +) -> notify::Result { + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + match res { + Ok(event) => { + for mapped in map_event(event) { + // Receiver may have been dropped (pane closed); ignore. + let _ = tx.send(mapped); + } + } + Err(e) => { + let _ = tx.send(WatchEvent::WatchError(e.to_string())); + } + } + })?; + + watcher.watch(path, RecursiveMode::NonRecursive)?; + + Ok(DirWatcher { + _inner: watcher, + path: path.to_path_buf(), + }) +} + +fn map_event(event: Event) -> Vec { + match event.kind { + EventKind::Create(_) => event + .paths + .into_iter() + .map(WatchEvent::Created) + .collect(), + EventKind::Remove(_) => event + .paths + .into_iter() + .map(WatchEvent::Removed) + .collect(), + EventKind::Modify(notify::event::ModifyKind::Name(notify::event::RenameMode::Both)) => { + // Both halves of a rename arrive together with two paths: + // [from, to]. If the platform only gives us one half at a + // time, we fall back to treating it as remove+create below. + if event.paths.len() == 2 { + vec![WatchEvent::Renamed { + from: event.paths[0].clone(), + to: event.paths[1].clone(), + }] + } else { + event.paths.into_iter().map(WatchEvent::Modified).collect() + } + } + EventKind::Modify(_) => event + .paths + .into_iter() + .map(WatchEvent::Modified) + .collect(), + _ => Vec::new(), + } +}