feat: Finder-style mouse selection (Ctrl-click, Shift-click, double-click, right-click)

Add macOS Finder-style mouse interaction to the file list:

- Ctrl/Cmd-click toggles individual file selection
- Shift-click range-selects from anchor point via toggle_all
- Double-click opens files / enters directories
- Right-click opens interactive picker ("Open with...")
- Click dismisses any open dialog and falls through to normal handling
- Selections clear and anchor resets on directory change

Exposes is_ctrl, is_shift, is_super modifier fields on MouseEvent
Lua binding (crossterm already provides them, just not exposed).

See FINDER_MOUSE_SELECTION.md for full design rationale.
This commit is contained in:
Von Bravo 2026-02-16 21:34:16 -08:00
parent 7f339381f1
commit 3139275c09
4 changed files with 185 additions and 4 deletions

115
FINDER_MOUSE_SELECTION.md Normal file
View file

@ -0,0 +1,115 @@
# Finder-Style Mouse Selection
## What this does
This adds macOS Finder-style mouse selection to Yazi's file list:
- **Left-click** — moves cursor to the clicked file (unchanged behavior)
- **Ctrl-click** (or Cmd/Super-click) — toggles individual file selection
- **Shift-click** — range-selects all files between the last anchor point and the clicked file
- **Double-click** — opens the file or enters the directory
- **Right-click** — opens the "Open with..." interactive picker
The **anchor point** tracks the last file you explicitly interacted with. It's set on every click and reset when you navigate to a new directory. When entering a directory, the anchor defaults to whichever file is highlighted, so Shift-click works immediately without a prior click.
Selections are cleared automatically on directory change to prevent accidental operations on files you can no longer see.
## Why
Yazi already has excellent keyboard-driven selection (visual mode, Space to toggle), but mouse users have no way to select files without the keyboard. Every major file manager — Finder, Nautilus, Dolphin, Windows Explorer — supports modifier-click selection. This brings Yazi up to parity with those conventions.
The feature is entirely opt-in: if you don't use a mouse, nothing changes. All existing keyboard workflows are unaffected.
## What changed (3 files)
### 1. `yazi-binding/src/mouse.rs` — Expose modifier keys to Lua
crossterm's `MouseEvent` already carries a `modifiers: KeyModifiers` field, but it wasn't exposed to Lua. Added three boolean fields following the exact pattern of the existing `is_left`/`is_right`/`is_middle` fields:
```rust
fields.add_field_method_get("is_ctrl", |_, me| {
Ok(me.modifiers.contains(KeyModifiers::CONTROL))
});
fields.add_field_method_get("is_shift", |_, me| {
Ok(me.modifiers.contains(KeyModifiers::SHIFT))
});
fields.add_field_method_get("is_super", |_, me| {
Ok(me.modifiers.contains(KeyModifiers::SUPER))
});
```
`KeyModifiers` is imported at the top level alongside the existing `MouseButton` import, matching the file's convention.
This is a general-purpose addition — any plugin or preset component can now read modifier state on mouse events.
### 2. `yazi-plugin/preset/components/current.lua` — Click handler
Replaced the minimal `Current:click` with a modifier-aware handler. Key decisions:
- **State lives on the class table** (`Current._anchor`, `Current._last_click`, `Current._cwd`), not on instances. This is consistent with how `Entity._inc` and `Entity._children` persist across render cycles — instances are recreated each frame via `Root:build()`, but class-level fields survive.
- **Batch selection uses `toggle_all`** with a URL array and `state = "on"`, the same pattern used by `fzf.lua` (line 32-33). No per-file toggle loop, no new Rust actors.
- **Double-click detection** uses `ya.time()` with a 0.4s threshold on the same row, consistent with how `mime-local.lua` and `folder.lua` use `ya.time()` for timing.
- **Both Ctrl and Super modifiers** are accepted for toggle-click. Super (Cmd) is the Finder convention on macOS, but many terminals intercept it. Ctrl is the universal fallback and matches Windows/Linux conventions.
- **Directory change detection** compares `tostring(self._folder.cwd)` against `Current._cwd` and resets the anchor + clears selections when it changes. This prevents stale anchors from a previous directory and avoids accidentally operating on invisible selections.
### 3. `yazi-plugin/preset/components/root.lua` — Dialog dismiss on click
Previously, `Root:click` returned early when `cx.layer ~= "mgr"` (i.e., when any dialog was open), swallowing all mouse events. Now it dismisses the dialog and falls through to normal click handling:
```lua
if tostring(cx.layer) ~= "mgr" then
if up then return end
ya.emit(tostring(cx.layer) .. ":close", {})
end
```
This uses the `layer:command` routing syntax from `Cmd::new` — the command name prefix selects the target layer. So `pick:close` dismisses the "Open with..." picker, `input:close` dismisses text inputs, `confirm:close` dismisses confirmations, etc. No hardcoded layer names.
The `up` guard prevents the mouse-up event from the same click that opened the dialog from immediately dismissing it.
The fall-through (no `return` after close) means right-clicking a different file while a dialog is open will: close the current dialog → reveal the new file → open the dialog for it — all in one click, matching Finder's behavior.
## Problems solved along the way
1. **Modifier keys weren't available in Lua** — crossterm provides them, but yazi-binding didn't expose them. A 9-line addition to `mouse.rs`.
2. **`ya.emit()` always routes to the Mgr layer** — discovered via `call.rs` line 11: `Some(Layer::Mgr)`. Initially tried `ya.emit("escape", {})` and `ya.emit("close", {})` to dismiss dialogs, both hit the wrong layer. The `layer:command` prefix syntax (from `Cmd::new`'s name parsing) solved it.
3. **Mouse-up from the same click dismissed the dialog** — right-click fires both Down (opens dialog) and Up (was dismissing it). Added an `up` guard so only Down events trigger close.
4. **Terminal intercepting Shift-click** — Shift-click is typically reserved for terminal text selection. Solved with XTSHIFTESCAPE escape sequences in the shell launcher wrapper:
```sh
printf '\e[>1s' # tell terminal to pass shift-clicks to application
yazi "$@"
printf '\e[>0s' # restore terminal shift-click handling on exit
```
5. **Terminal intercepting right-click** — Ghostty (and many terminals) show a context menu on right-click, consuming the event before it reaches the application. Solved with a Ghostty config setting:
```
right-click-action = ignore
```
This passes right-click through to all terminal applications globally. Other terminals will have equivalent settings.
## Cross-platform compatibility
The implementation is fully cross-platform. No macOS-specific code.
- **Ctrl-click** — works in all terminals on all platforms (primary modifier)
- **Super/Cmd-click** — works where the terminal passes Super through (Ghostty, Kitty, iTerm2 on macOS; some Linux terminals)
- **Shift-click** — works where the terminal supports XTSHIFTESCAPE or doesn't intercept Shift-click for text selection. This is a terminal configuration concern, not a Yazi concern
- **Double-click and right-click** — standard mouse events, universally supported
- **Right-click passthrough** — requires terminal not intercepting it for a context menu (e.g., Ghostty's `right-click-action = ignore`)
Terminal-level configuration (XTSHIFTESCAPE, right-click passthrough) is external to Yazi and can be handled in wrapper scripts or terminal config. Yazi itself just reads the modifier flags that crossterm provides.
## What stays unchanged
- **entity.lua** — untouched; plain left-click and right-click delegate to `Entity:click` for the default behavior path
- **Rust selection system**`Selected`, `toggle`, `toggle_all`, `visual_mode` all used as-is
- **No new Rust actors, structs, or event types**
- **Keyboard workflows** — visual mode, Space toggle, all keybindings work exactly as before
- **Scroll and touch handlers** — unchanged

View file

@ -1,6 +1,6 @@
use std::ops::Deref;
use crossterm::event::MouseButton;
use crossterm::event::{KeyModifiers, MouseButton};
use mlua::{UserData, UserDataFields};
#[derive(Clone, Copy)]
@ -32,5 +32,14 @@ impl UserData for MouseEvent {
use crossterm::event::MouseEventKind as K;
Ok(matches!(me.kind, K::Down(b) | K::Up(b) | K::Drag(b) if b == MouseButton::Middle))
});
fields.add_field_method_get("is_ctrl", |_, me| {
Ok(me.modifiers.contains(KeyModifiers::CONTROL))
});
fields.add_field_method_get("is_shift", |_, me| {
Ok(me.modifiers.contains(KeyModifiers::SHIFT))
});
fields.add_field_method_get("is_super", |_, me| {
Ok(me.modifiers.contains(KeyModifiers::SUPER))
});
}
}

View file

@ -1,5 +1,7 @@
Current = {
_id = "current",
_anchor = nil,
_last_click = nil,
}
function Current:new(area, tab)
@ -49,9 +51,63 @@ end
-- Mouse events
function Current:click(event, up)
if up or event.is_middle then return end
local y = event.y - self._area.y + 1
if self._folder.window[y] then
Entity:new(self._folder.window[y]):click(event, up)
local file = self._folder.window[y]
if not file then return end
local abs = self._folder.offset + y
-- Reset anchor and clear selection on directory change
local cwd = tostring(self._folder.cwd)
if Current._cwd ~= cwd then
Current._cwd = cwd
Current._anchor = nil
ya.emit("escape", { select = true })
end
-- Double-click detection
local last = Current._last_click
if event.is_left and last and last.y == y and ya.time() - last.time < 0.4 then
Current._last_click = nil
ya.emit("reveal", { file.url })
ya.emit("open", {})
return
end
if event.is_left then
Current._last_click = { y = y, time = ya.time() }
end
if event.is_left and (event.is_super or event.is_ctrl) then
-- Cmd/Ctrl-click: toggle individual selection
ya.emit("toggle", { file.url })
ya.emit("reveal", { file.url })
Current._anchor = abs
elseif event.is_left and event.is_shift then
-- Shift-click: range select from anchor
local anchor = Current._anchor or (self._folder.cursor + 1)
local lo = math.min(anchor, abs)
local hi = math.max(anchor, abs)
local urls = {}
local files = self._folder.files
for i = lo, hi do
local f = files[i]
if f then urls[#urls + 1] = f.url end
end
urls.state = "on"
ya.emit("toggle_all", urls)
ya.emit("reveal", { file.url })
Current._anchor = abs
elseif event.is_right then
-- Right-click: open interactive ("Open with...")
ya.emit("reveal", { file.url })
ya.emit("open", { interactive = true })
Current._anchor = abs
else
-- Plain left-click: reveal + set anchor
ya.emit("reveal", { file.url })
Current._anchor = abs
end
end

View file

@ -51,7 +51,8 @@ end
-- Mouse events
function Root:click(event, up)
if tostring(cx.layer) ~= "mgr" then
return
if up then return end
ya.emit(tostring(cx.layer) .. ":close", {})
end
local c = ya.child_at(ui.Rect { x = event.x, y = event.y }, self:reflow())
return c and c:click(event, up)