Skip to content

Add ruixen.shelf: a standalone drop-pocket overlay with notch quick-drop - #93

Merged
gitcoder89431 merged 16 commits into
masterfrom
claude/modest-dijkstra-dhf701
Oct 2, 2026
Merged

gitcoder89431 merged 16 commits into
masterfrom
claude/modest-dijkstra-dhf701

Conversation

@gitcoder89431

@gitcoder89431 gitcoder89431 commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

What

ruixen.shelf — a drop pocket / Inbox for file references, as its own overlay plugin (not a notch dashboard tab). It is a panel that hangs from the frame at the notch's position in the notch's expanded silhouette (concave wing shoulders, rounded bottom). Drag a file or folder over the compact notch and the Shelf opens so you can drop it in and see it land; drag back out without dropping and it hides again. Drag files back out into another app or a terminal. It never copies, moves or deletes anything on disk. An agent can read what you dropped and hand you files back over IPC.

omarchy-shell ruixen.shelf toggle|open|close          # or: omarchy-shell shell toggle ruixen.shelf  (SUPER+D, opt-in)
omarchy-shell ruixen.shelf openFromDrag               # what the notch calls when a local-file drag enters it
omarchy-shell ruixen.shelf list                       # JSON: id/path/name/source/addedAt/exists/kind/size
omarchy-shell ruixen.shelf add /abs/path              # agent adds a file for you to drag out (shown with an "agent" badge)
omarchy-shell ruixen.shelf addMany $'/a\n/b' user     # one batched call, NEWLINE-delimited
omarchy-shell ruixen.shelf remove <id-or-path>
omarchy-shell ruixen.shelf clear

addMany is newline-delimited, not a JSON array: a bracketed array does not survive the host's IPC boundary as one argument (found in live testing).

What's in it

  • ruixen.shelf/ — overlay plugin, keepLoaded. Shelf.qml: window, host lifecycle, ruixen.shelf IPC target, and the drag-open/auto-hide logic. ShelfService.qml: the only writer of ~/.local/state/ruixen/shelf.json (versioned, atomic, cap 200); async bounded single-worker stat pass. ShelfModel.js (pure, unit-tested). ShelfContent.qml: Inbox chip + search + Clear above a horizontal strip of cards (thumbnails, hover copy/open/remove, agent badge, missing-file handling); wheel and Left/Right/Home/End scroll the strip.
  • Drag onto the notch — the collapsed pill's DropArea draws no highlight; a local-file drag entering it makes one IPC call, openFromDrag, and the Shelf opens under the drag (no dwell timer). A drop that lands keeps it open; the drag leaving hides it after a 350 ms grace (cancelled if the drag returns); a drag that never reaches it is caught by a 2.5 s watchdog; a Shelf opened any other way (keybind, open) is never auto-hidden; a 3 s cooldown after an auto-hide stops a parked drag flapping it. The whole panel — wings and padding included — is a drop target. A drop that lands on the pill before the Shelf takes over is still accepted and relayed (addMany).
  • Keyboard (decision to review) — the Shelf holds the keyboard exclusively while open (OnDemand while shut), so Escape dismisses it with no click first. Under Wayland an OnDemand window only receives keys after a click, so Escape was going to the app behind it; forceActiveFocus() can't change that. It lets go of the keyboard while a card is being dragged out (so you can drop into a terminal and type) and takes it back when the drag ends; it holds no keyboard while closed. Trade-off: while the Shelf is open, typing goes to the Shelf, not the app underneath, until it is dismissed (Escape, the toggle keybind, or close). This supersedes the earlier "OnDemand, never Exclusive" rule; the tests and docs state the conditional rule.
  • Silhouette — built the way Overlay.qml builds the expanded notch (flanks + square-topped center, 28 shoulders, 44 bottom radius, seamOverlap 2), 900 body, fixed 236 height, flush under the frame at the notch's resting offset (frameInset 4). ShelfRoundCorner.qml is a shelf-local copy of the notch's inline RoundCorner. Shadow is notchShadowBlur's recipe with an outward clip; the window is padded for the halo and its input region is only the visible shape.
  • Window — shelf-sized, top-anchored only, ExclusionMode.Ignore, Overlay layer; no fullscreen surface, no click-away catcher.
  • Copy semantics only — drops are accepted with Qt.CopyAction, never acceptProposedAction(), and only once something was added; drag-out advertises copy only and never auto-removes.
  • Installer — enables ruixen.shelf in shell.json; install.sh --with-launcher-keybind also offers SUPER+D when free (uninstall removes every ruixen.* plugin id generically). docs/KEYBINDS.md moves Display Settings to SUPER+SHIFT+D.
  • Docs / rules — README, docs/CONTROL.md, docs/KEYBINDS.md; AGENTS.md §9 lists the shelf as a fourth surface consumer whose shape is a copy of the notch's.
  • Tests — tests/js/ShelfModel.test.js; tests/shelf-plugin.sh (manifest, IPC, window semantics, copy-only drops, notch wiring, silhouette, scroll/focus, drag-open/auto-hide, keyboard rule — mutation-checked); tests/qml-dup-anchor.py (a duplicate anchors.* silently kills the whole plugin; neither omarchy plugin validate nor the greps see it); tests/live-shelf-ipc.sh drives the real IPC boundary (needs a running shell, so outside run-all.sh/CI).

Not in this PR

  • Click-away dismissal — not feasible with this window design: Wayland delivers a click only to the surface under the pointer, a shelf-sized window never sees clicks outside itself, and a fullscreen catcher would swallow every drag-out. A focus-change approach (Hyprland.activeToplevel) doesn't fire when you click the already-active window or empty desktop. (Escape / toggle / close dismiss instead.)
  • Browser-image downloads, MIME carrying, MCP, AI actions, auto-remove after drag-out, undo/multi-select/pinning.

Verified

CI green on the previous head; ./tests/run-all.sh green on this one. Live (real Hyprland/Quickshell): plugin validates and loads with a clean journal, drag into the open Shelf, addMany/add/list/remove over real IPC (this found and fixed the JSON-array bug), layout and scrolling, Escape dismissing on both the keybind and openFromDrag paths, and typing landing back in the terminal after a close.

Not verified by anyone yet

Per AGENTS.md §8, after a full omarchy restart shell (new PID): journalctl --user -b 0 for is not a type / Type … unavailable / TypeError / Property value set multiple times, then ./tests/live-shelf-ipc.sh.

  • Mid-drag-out handover (called out as untested in the commit): drag a card out of the Shelf into a terminal, drop it, type a command — the keyboard must be back with the terminal; after the drag ends, Escape must work on the Shelf again
  • The drag-in handover: drag a file over the compact notch → the Shelf opens under the drag; moving down into it and releasing adds the file. If nothing in the opened Shelf reacts, remove the single root.relayShelfOpen() line in onEntered in Overlay.qml (the plain drop-on-the-pill path keeps working)
  • Drag out of the notch without dropping → it hides itself; dragging over again right away doesn't flap it
  • After a drop it stays open showing the item; Escape then dismisses it; a keybind-opened Shelf never auto-hides
  • The trade-off above is acceptable: with the Shelf open, typing in another app does nothing until you dismiss it
  • Multiple files and a folder; weird names (spaces, #, %, Unicode); a source app that proposes move must not lose its file
  • Drag out into a file manager, browser upload, chat app, terminal (path as text)
  • Black and Theme surface modes both look right; the Shelf covers the collapsed notch pill while open

Unrelated, pre-existing (not from this PR): ruixen.quickactions / ruixen.stayawake throw TypeError: Property 'firstPartyServiceFor' … is not a function on shell start.

Please don't bump COMPATIBILITY.md reviewed versions for this.

🤖 Generated with Claude Code

https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu

A 5th dashboard tab. Drag files in from any app (DropArea), drag them
back out into other apps or terminals (Drag.Automatic, text/uri-list +
text/plain). It holds absolute-path references only -- never copies,
moves or deletes anything.

Agent-native like Kanban: ShelfService is the single writer of
~/.local/state/ruixen/shelf.json, and shelfList/shelfAdd/shelfRemove/
shelfClear/toggleShelf are IPC functions on the ruixen.notch target.
Agent-added items are tagged and shown with a badge. Existence/size come
from a bounded, async, single-worker stat pass merged by path.

Also tightens the notch tab rail's spacing (8 -> 6) so six 56px buttons
fit the 368px of usable height.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu

Copy link
Copy Markdown
Owner Author

Design pivot after reviewing the current implementation and comparing it against the DnD behavior this feature actually needs:

Recommendation: detach Shelf from the dashboard and make it its own ruixen.shelf overlay plugin

The current Shelf code itself is in good shape (ShelfModel.js / ShelfService.qml / ShelfContent.qml), but I do not think the Shelf should remain dashboard tab #5 inside ruixen.notch.

The reason is architectural, not cosmetic.

The existing expanded notch deliberately behaves like a modal dashboard:

expanded notch
→ fullscreen layer surface
→ fullscreen input mask
→ Exclusive keyboard focus
→ click-away dismissal

That is correct for Widgets / Wallpapers / Metrics / Kanban / launcher-like interactions.

A drag shelf wants almost the opposite:

Shelf
→ outside applications must remain reachable
→ pointer must leave the Shelf and actually reach the target app
→ no fullscreen invisible input surface covering the destination
→ should not require Exclusive keyboard focus
→ should be able to stay open while switching apps/workspaces
→ drag-out must work without click-away infrastructure intercepting the drop

Rather than adding a growing pile of Shelf-specific exceptions to Overlay.qml (special mask, special focus, special dismissal, special lifetime, etc.), give Shelf the window semantics it actually needs.

Target architecture

Create a dedicated plugin:

ruixen.shelf/
├── manifest.json
├── Shelf.qml
├── ShelfService.qml
├── ShelfContent.qml
└── ShelfModel.js

with a normal Omarchy overlay manifest:

{
  "id": "ruixen.shelf",
  "kinds": ["overlay"],
  "keepLoaded": true
}

Shelf.qml should own:

ShelfService
+
the Shelf PanelWindow / lifecycle
+
IPC

The state can remain where it is now:

~/.local/state/ruixen/shelf.json

No need to migrate the file path just because the UI moved.

Notch remains the Ruixen-native entry point

Do not make a second fake notch that permanently sits on top of ruixen.notch.

The collapsed top-center notch remains the only always-present hit target.

The idea is:

ruixen.notch
    │
    │ external drag enters notch
    ▼
small DropArea / drag affordance
    │
    ├── quick drop → send paths to ruixen.shelf
    │
    └── dwell (~500–700ms) → ask ruixen.shelf to open

Then the actual Shelf window appears immediately below / visually attached to the notch.

Visually:

             ╭────────────╮
             │   Notch    │
             ╰─────┬──────╯
                   │
           ╭───────┴───────╮
           │               │
           │     Shelf     │
           │               │
           ╰───────────────╯

Technically they are separate layer surfaces/plugins.

That is intentional.

Ruixen already uses separate windows/plugins that visually behave like one surface. Reuse the same visual language (surface colors, Theme/Black identity, glass treatment where appropriate, shadows, corner geometry, deliberate overlap/seam coverage) rather than sharing one live object/window.

Do not share live plugin objects

Do not make ruixen.notch reach into a live ShelfService object from another plugin.

Use the existing repo contract:

  • plugin-owned IPC
  • persisted state where needed

Give Shelf its own stable IPC target.

Suggested shape:

omarchy-shell ruixen.shelf open
omarchy-shell ruixen.shelf close
omarchy-shell ruixen.shelf toggle
omarchy-shell ruixen.shelf add /abs/path
omarchy-shell ruixen.shelf remove <id-or-path>
omarchy-shell ruixen.shelf clear
omarchy-shell ruixen.shelf list

Exact verb names can differ, but the target should be ruixen.shelf, not ruixen.notch.

Notch can relay through omarchy-shell ruixen.shelf ... using the same fire-and-forget IPC pattern already used elsewhere in Ruixen.

For multi-file drops, avoid spawning one process per path if possible. Prefer one Shelf IPC call that can accept the complete batch cleanly.

Remove Shelf from the notch dashboard

Once the separate plugin exists, revert the dashboard to four tabs:

Widgets
Wallpapers
Metrics
Kanban

That means removing the Shelf-specific pieces from Overlay.qml:

  • ShelfService { ... }
  • ShelfContent { ... }
  • dashboard tab index 4
  • % 5 tab cycling
  • openDashboardTab shelf
  • toggleShelf
  • shelfAdd/shelfRemove/shelfClear/shelfList IPC functions
  • tab-rail spacing changes made only to fit the extra button

Do not disturb the existing behavior of the four remaining tabs.

Shelf PanelWindow requirements

The Shelf window should be sized to the actual Shelf, not the screen.

Critical behavior:

pointer leaves Shelf rectangle
        ↓
Wayland sees the real destination application

Avoid a fullscreen click-away/input-mask surface around it.

Prefer:

  • Overlay layer if needed for visibility
  • exclusiveZone: 0
  • input region only where the visible Shelf exists
  • keyboard focus None or OnDemand, not permanently Exclusive
  • no fullscreen outside-click catcher
  • explicit close button / Escape when the Shelf actually has focus
  • optional leave-to-close can come later; do not make that part of the first structural move

The first goal is reliable cross-app DnD, not fancy auto-dismiss.

Activation behavior

Build this in stages.

Stage 1 — dedicated Shelf + keybind/IPC

First get:

omarchy-shell ruixen.shelf toggle

opening a standalone Shelf under the notch.

Verify drag in/out across real apps before touching spring loading.

Stage 2 — notch as quick-drop target

Add a small DropArea to the collapsed notch.

Dragging local file/folder URIs over the notch should visibly highlight the notch.

Dropping there should add the paths to Shelf without requiring the Shelf window to already be open.

This is the primary fallback and must work even if spring-loading turns out to be compositor-sensitive.

Stage 3 — spring-loaded Shelf

While an external drag remains over the notch for ~500–700ms:

notch highlights
→ progress/dwell
→ ruixen.shelf opens underneath

Then the user can continue the same drag down into the Shelf.

This must be verified live under Wayland/Hyprland. Do not assume an already-running external drag will automatically transfer cleanly into a newly-mapped layer surface.

If that handoff is unreliable, keep quick-drop-on-notch as the canonical interaction and leave spring-loading out.

Keep the current Shelf product contract narrow

Do not expand this refactor into a general MIME carrier.

For this PR/branch keep the existing concept:

Shelf stores local file/folder references.

That preserves the clean user ↔ agent workflow:

User drops file
→ Shelf has path
→ agent can shelf list + read it

Agent creates file
→ shelf add /path
→ user can drag it into another app

Do not add browser-image downloading, HTML/MIME payload retention, native C++ drag components, etc. as part of this move.

Those can be separate future features if actually needed.

Preserve the good parts already implemented

Please keep/reuse:

  • ShelfModel.js path normalization / URI encoding / dedupe / caps
  • ShelfService.qml as the single writer of shelf.json
  • atomic persistence
  • async bounded stat refresh
  • source: "user" | "agent"
  • agent badge
  • missing-file handling
  • image thumbnails
  • current IPC semantics, migrated to the new target
  • existing JS tests, moved/updated rather than discarded

Two small correctness fixes while moving the UI

Please also fix these while touching ShelfContent.qml:

  1. The row drag lives inside a ListView. Add:
preventStealing: true

to the drag MouseArea so the ListView does not steal the press/gesture while an external drag is trying to begin.

  1. Image thumbnails currently build URLs with:
"file://" + row.entry.path

Use the already-tested encoder instead:

ShelfModel.uriFor(row.entry.path)

so spaces, #, ?, %, Unicode, etc. survive correctly.

Drag-out semantics

Keep current copy semantics.

Do not auto-remove an item after drag-out in this refactor.

Wayland/Qt drag completion/action reporting is not reliable enough across targets to make deletion depend on the returned action, and the Ruixen agent-handoff use case benefits from being able to drag the same generated file into multiple destinations.

Follow-up ideas, not blockers for this architectural move

Do not hold this refactor for these, but they are good future Shelf issues:

  • Undo Clear / Undo Remove
  • Copy as file (text/uri-list) in addition to Copy Path
  • multi-select + drag bundle
  • search if the persistent shelf is allowed to grow large
  • pinned items that survive Clear
  • optional auto-close / keep-open settings

Acceptance criteria for this PR pivot

Before this branch is considered done:

  • ruixen.shelf is a separate installed/enabled overlay plugin
  • Shelf no longer exists as notch dashboard tab [P1] Make install/update transactional with validation and rollback #5
  • Widgets / Wallpapers / Metrics / Kanban behavior is unchanged
  • omarchy-shell ruixen.shelf toggle works
  • add/list/remove/clear work against the new IPC target
  • existing shelf.json persistence still works
  • drag local file/folder into Shelf works
  • drag out from Shelf into file manager works
  • drag out into browser upload field works
  • drag out into chat app works
  • drag out into terminal works (verify text/plain vs text/uri-list behavior; if offering both causes destination ambiguity, keep file drag canonical and make Copy Path explicit)
  • outside applications remain interactive while Shelf is open
  • no fullscreen invisible Shelf input surface blocks destination drops
  • notch quick-drop works before attempting spring-load
  • spring-loaded open is only kept if verified live
  • omarchy restart shell + journal verification is clean
  • focused tests + ./tests/run-all.sh remain green
  • omarchy plugin validate ruixen.shelf passes

Product framing

The desired end state is not “oShelf copied into Ruixen” and not “another permanent bar widget.”

The Ruixen-specific interaction should be:

                  NOTCH
                    │
        ┌───────────┴───────────┐
        │                       │
  user drops files        agent adds files
        │                       │
        └───────────┬───────────┘
                    ▼
              RUIXEN SHELF
                    │
           user drags them out

The notch is the portal/trigger.

The Shelf is the purpose-built transfer surface.

That boundary keeps the already-complicated notch stable while giving cross-application drag-and-drop the window behavior it actually needs.

Design pivot (PR #93 comment): the expanded notch is a modal surface
(fullscreen layer + input mask + Exclusive focus + click-away), which is
wrong for cross-app drag and drop. The Shelf is now its own overlay
plugin whose window is only as big as the shelf, anchored top-only under
the notch, OnDemand keyboard focus, no mask, no screen space reserved.

- ruixen.shelf: manifest, Shelf.qml (window, host lifecycle, own IPC
  target: open/close/toggle/add/addMany/remove/clear/list), plus the
  existing ShelfModel/ShelfService/ShelfContent moved over unchanged in
  behavior; state path (shelf.json) unchanged
- ruixen.notch: Shelf tab removed (back to four tabs, tab-rail spacing
  restored); the collapsed pill gains a DropArea that highlights on a
  local-file drag and relays one batched addMany call to ruixen.shelf
  over IPC (no shared live objects). No spring-loading: unverified
- ShelfContent: preventStealing on the row drag, thumbnails use
  ShelfModel.uriFor instead of string concatenation
- Installer enables ruixen.shelf; tests updated; docs + AGENTS.md section
  9 note the new surface consumer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu
@gitcoder89431 gitcoder89431 changed the title Add Shelf: a drop pocket tab in the expanded notch Add ruixen.shelf: a standalone drop-pocket overlay with notch quick-drop Oct 2, 2026

Copy link
Copy Markdown
Owner Author

Implemented the pivot in df3935b (PR title/description updated to match). Status against the acceptance criteria:

Done and covered by tests here (./tests/run-all.sh green):

  • ruixen.shelf is a separate overlay plugin (manifest, keepLoaded, enabled by the installer's shell.json merge)
  • Shelf is no longer notch dashboard tab [P1] Make install/update transactional with validation and rollback #5; the notch is back to four tabs and nothing else in Overlay.qml changed except the quick-drop zone + its relay
  • IPC target is ruixen.shelf: open/close/toggle/add/addMany/remove/clear/list (addMany takes a JSON array + explicit source so a multi-file drop is one call)
  • shelf.json path/format unchanged; ShelfModel/ShelfService/ShelfContent kept, tests moved and extended
  • Window semantics are pinned by tests/shelf-plugin.sh: top-anchored only, sized to the shelf, no mask, no click-away catcher, ExclusionMode.Ignore, OnDemand keyboard focus (never Exclusive), no pointer-leave dismissal
  • Both small fixes: preventStealing: true on the row drag, thumbnails via ShelfModel.uriFor(); drag-out stays copy-only (no auto-remove)
  • Notch quick-drop is implemented (DropArea on the notchHoverZone footprint, highlight + reveal-in-hover-mode, one batched relay call, queued behind a running relay)

Not verifiable from the cloud container — on the PR's "check on the real machine" list: omarchy plugin validate ruixen.shelf, a real omarchy restart shell + clean journal, drag in/out against real apps (file manager, browser upload, chat app, terminal), outside apps staying interactive while the Shelf is open, and the quick-drop behaving under Hyprland.

Intentionally left out: Stage 3 spring-loading. Per the comment it should only be kept if verified live, and I can't verify that an in-flight drag carries into a newly mapped layer surface, so quick-drop on the notch is the only notch activation for now (tests/shelf-plugin.sh asserts there's no dwell timer so it can't sneak in unverified).

One judgment call to flag: the Shelf surface reads the same Black/Theme state as the notch (own copy of the resolve logic, Solid only, per AGENTS.md §9) but its exact attachment to the notch (400px wide, 52px top margin, uniform corner radius) is a first guess — expect to tune shelfWidth / margins.top live. I also added a short note to AGENTS.md §9 listing ruixen.shelf as a fourth surface consumer.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Pre-live-test implementation pass: the architecture looks good now. I would not redesign the Shelf again before testing it live. There are just two code fixes I want landed first, plus one focus behavior to treat as a live-test gate.

1. Force incoming drops to CopyAction

This is the most important pre-live-test fix.

The Shelf is explicitly a reference/copy-semantics feature: it never moves or deletes the source file. Right now the incoming drop paths use acceptProposedAction().

That means if a source application proposes MoveAction, Ruixen may report that proposed action as accepted even though it only stored a path reference. That is the wrong contract for this feature and could make a source app behave as if a move succeeded.

Please explicitly accept Qt.CopyAction for both:

  • drops into the open Shelf
  • quick-drops onto the collapsed notch

Suggested shape:

onEntered: drag => {
    if (/* acceptable local file/folder */)
        drag.accept(Qt.CopyAction)
}

onDropped: drop => {
    ...
    var result = root.shelfService.addPaths(paths, "user")
    if (result.added.length > 0)
        drop.accept(Qt.CopyAction)
}

For the notch quick-drop:

onEntered: drag => {
    if (shelfQuickDrop.localUrls(drag.urls).length > 0) {
        drag.accept(Qt.CopyAction)
        root.notchHoverEntered()
    }
}

onDropped: drop => {
    var urls = shelfQuickDrop.localUrls(drop.urls)
    root.notchHoverExited()
    if (urls.length === 0) return

    root.relayToShelf(urls)
    drop.accept(Qt.CopyAction)
}

Also avoid accepting a drop that did not actually produce an acceptable local Shelf entry.

The intended guarantee should be:

Ruixen Shelf always advertises/accepts copy semantics for incoming and outgoing file-reference DnD. It never implies that the source should be moved or deleted.

Please add a static contract check for this so acceptProposedAction() does not creep back into the Shelf/notch quick-drop path later.

2. Fix the current CI failure before live testing

The current PR head's feature tests are green:

  • tests/shelf-plugin.sh: 47 passed
  • ShelfModel.test.js: 44 passed

But the overall GitHub Actions run is red because ShellCheck flags tests/shelf-plugin.sh line 97 with SC2126.

It is the count implemented through grep ... | wc -l.

Please clean that up with the appropriate grep -c / equivalent form, run:

./tests/run-all.sh

and make sure the actual GitHub Actions run is green before treating the branch as ready for live validation.

This looks like a test-lint-only failure, not a Shelf behavior failure.

3. Focus behavior: don't preemptively redesign, but make it an explicit gate

The standalone window correctly uses:

WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand

but open() currently immediately does:

Qt.callLater(function() { focusScope.forceActiveFocus() })

That may be fine because Qt Quick item focus and compositor keyboard focus are not exactly the same thing, but it may also undermine the reason we chose OnDemand: opening the Shelf should not unexpectedly take typing away from the application the user is currently working in.

I would not change this blindly before the first real test unless there is an obvious documented reason it is required.

Instead make this the first behavioral focus test:

  1. Put the cursor in a terminal/text field.
  2. Open Shelf using the keybind or IPC without clicking Shelf.
  3. Immediately type.
  4. The text should still go to the original application.

If opening Shelf steals keyboard focus, first remove the automatic focusScope.forceActiveFocus() from open().

Then let focus move into Shelf only after actual pointer interaction with it. Escape only needs to work after Shelf has focus; the explicit close button and toggle keybind remain available otherwise.

If OnDemand itself still causes focus problems after that, then consider WlrKeyboardFocus.None as a follow-up based on the live result — not speculatively.

After those fixes: live test, don't add more features yet

Once CopyAction + CI are fixed, I think this branch is ready for real-machine testing.

Please do not add spring-loading, undo, search, pinning, browser-image import, auto-dismiss, etc. before the first live DnD pass.

The key things the live test needs to prove are:

  • Shelf plugin actually loads after a full omarchy restart shell
  • no new QML/type errors in journal
  • Shelf-sized layer surface only; no invisible fullscreen blocker
  • opening Shelf does not steal typing unexpectedly
  • local file/folder drag-in works
  • original source remains untouched
  • drag-out works into file manager
  • drag-out works into browser upload
  • drag-out works into an Electron/chat app
  • drag-out into terminal produces a usable path
  • multi-file drop works
  • weird filenames (space, #, %, Unicode) survive
  • notch quick-drop works while Shelf is closed
  • notch quick-drop accepts copy semantics
  • quick-drop adds the batch without opening Shelf
  • existing four notch dashboard tabs remain unchanged

At this point the architecture is healthy enough that the next useful information has to come from real Hyprland/Wayland behavior, not another structural pass.

- The open Shelf and the notch quick-drop now accept/advertise
  Qt.CopyAction explicitly instead of acceptProposedAction(), which would
  echo back a MoveAction a source app proposed even though the Shelf only
  stores a reference
- A drop is only accepted once the shelf actually added something; drag
  enter and drop share the service's own normalizePath gate (and plain-text
  absolute paths still work); the notch rejects file:/// (root)
- Static contract: acceptProposedAction() can't creep back in; copy
  semantics asserted for drag-in (shelf + notch) and drag-out
- tests/shelf-plugin.sh: replace grep|wc -l with grep -c (ShellCheck SC2126,
  the CI failure on this PR)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu

Copy link
Copy Markdown
Owner Author

Landed in 2dfb841, addressing the three points:

1. CopyAction — both incoming paths now accept with Qt.CopyAction explicitly:

  • open Shelf: drag.accept(Qt.CopyAction) on enter, drop.accept(Qt.CopyAction) on drop, and the drop is only accepted once shelfService.addPaths() actually reported added.length > 0
  • notch quick-drop: drag.accept(Qt.CopyAction) + hover reveal on enter, drop.accept(Qt.CopyAction) after the relay
  • "acceptable" is now one gate: drag-enter and drop both run candidates through ShelfModel.normalizePath (the same check the service applies), so we don't advertise a drop the Shelf would then reject; plain-text absolute paths still work, file:/// (root) and http(s) URLs are refused
  • tests/shelf-plugin.sh now asserts: no .acceptProposedAction( call anywhere in ruixen.shelf/*.qml or the notch, the two explicit accept calls in each of the Shelf and the quick-drop, drag-out only advertises Qt.CopyAction, and accept-after-added

2. CI — fixed the ShellCheck SC2126 in tests/shelf-plugin.sh (grep … | wc -l → cat … | grep -c … || true). I can't run ShellCheck in my container (not installed), so I reviewed the other shell files I touched for the same pattern rather than reproducing it; I'll watch the new run on 2dfb841 and fix anything it flags. ./tests/run-all.sh is green locally.

3. Focus — left as-is deliberately, and made it Gate 1 at the top of the PR's live-test list with your exact procedure (cursor in a terminal, open without clicking, type immediately). The fallback ladder is in the description: first drop focusScope.forceActiveFocus() from open(), only then consider WlrKeyboardFocus.None.

No other features added; PR description updated to match (copy-semantics section, 53 checks, focus gate).


Generated by Claude Code

Found live: addMany never added anything over IPC, which broke the
notch quick-drop entirely -- the feature's primary entry point.

Two bugs, one root cause (a bracketed JSON array does not survive the
IPC boundary as a single argument):

- addMany '["/a","/b"]' arrived as THREE arguments and was refused by
  the host with "Too many arguments provided (2 required but 3 were
  provided)"; the count tracked the array length exactly
- a one-element array arrived as a scalar, so Array.isArray() was
  false, paths stayed empty, and it silently reported
  {"ok":false,"added":0,"rejected":0} -- added:0 AND rejected:0 is
  the tell that the paths never reached the normalizePath gate

addMany's argument is now newline-delimited. Newline rather than comma
or semicolon because ShelfModel.normalizePath already rejects any path
containing \n or \r, so the delimiter cannot collide with a real path.
A leading "[" is still parsed as JSON so an in-process caller can keep
passing an array literal.

Verified live after redeploy: 3 files -> {"ok":true,"added":3,
"rejected":0}; mixed folder/file/bad-path -> added:2, rejected:1;
paths with spaces, '#', parentheses and Unicode add fine; folders and
symlinks come back kind:"folder"/"file" with exists:true.

tests/shelf-plugin.sh pins the encoding on both sides (4 new checks)
and the signature check follows the renamed parameter. Note these are
still grep-based contract checks -- they cannot prove the host's
splitting behavior, which is why this needed a live shell to find.

Drag-into-the-open-shelf was never affected: ShelfContent calls
service.addPaths in-process, no IPC involved.

Co-Authored-By: opencode <noreply@opencode.ai>
@gitcoder89431

Copy link
Copy Markdown
Owner Author

Live testing found addMany broken for every real drop — fixed in f4836e3

Ran this on a real Hyprland/Quickshell machine. The Shelf window, window
semantics, and drag-into-the-open-shelf all work. But notch quick-drop was
completely broken
, because its one addMany call per drop never worked.

Repro (all live, against PID 77671)

$ omarchy-shell ruixen.shelf addMany '["/a","/b"]' user
Too many arguments provided (2 required but 3 were provided).

$ omarchy-shell ruixen.shelf addMany '["a","b","c"]' user
Too many arguments provided (2 required but 4 were provided).

$ omarchy-shell ruixen.shelf addMany '["/tmp/real/existing/file.txt"]' user
{"ok":false,"added":0,"rejected":0}

$ omarchy-shell ruixen.shelf add /tmp/real/existing/file.txt
{"ok":true,"id":"smuqrgxrn"}

Same path, same service, opposite results. Note added:0 and rejected:0
together — that combination is the diagnostic: paths aren't failing the
normalizePath gate (rejected would be >0), they never reach it.

Root cause

A bracketed JSON array does not survive the IPC boundary as one argument.
omarchy-shell forwards "$@" verbatim and the error string is Quickshell's
own, so the split happens on the host side of IpcHandler — the arg count
tracks the array length exactly. A one-element array arrives as a scalar,
so Array.isArray(parsed) is false, paths stays [], and it silently adds
nothing.

Confirmed by contrast (all arrive intact as a single argument): 'x,y',
'/tmp/a;/tmp/b', '/tmp/a\n/tmp/b'. Only the bracketed form is torn apart.

I could not read Quickshell's source to pin the exact mechanism — only
strings on the binary — so the "host parses the arg as a sequence of JSON
values" part is inference. The observed behavior above is not.

Fix

addMany now takes a newline-delimited argument. Newline rather than
comma or semicolon because ShelfModel.normalizePath already rejects any
path containing \n or \r, so the delimiter cannot collide with a real
path. A leading [ still parses as JSON so an in-process caller can keep
passing an array literal.

Changed: ruixen.shelf/Shelf.qml (handler), bars/widgets/ruixen.notch/Overlay.qml
(relay now urls.join("\n") instead of JSON.stringify(urls)), plus 4 new
encoding checks in tests/shelf-plugin.sh.

Verified live after redeploy

3 files                                -> {"ok":true,"added":3,"rejected":0}
folder + file + bad relative path      -> {"ok":true,"added":2,"rejected":1}
trailing newline                       -> {"ok":true,"added":1,"rejected":0}
"dir with spaces/file #1 (2).txt"      -> {"ok":true,"added":1,"rejected":0}
symlink to file + symlink to folder    -> {"ok":true,"added":2,"rejected":0}

list reports correct kind (file/folder) and exists:true for all of
them. ./tests/run-all.sh green; shelf-plugin.sh 57 passed.
omarchy plugin validate ruixen.shelf clean; window confirmed as a real
shelf-sized layer (xywh: 760 52 400 250, namespace: ruixen-shelf) — so
the pivot's window semantics hold up in practice.

Note on the test gap

tests/shelf-plugin.sh only ever grepped for the signature and the
exec([...]) call — nothing ever called addMany over real IPC, which is
exactly why three rounds of tests were green while the primary entry point was
100% broken. Same shape as the stubbed install.sh in tests/update-safety.sh
(issue #90). The new checks pin the encoding contract, but they're still
grep-based and can't prove host-side behavior — that gap is worth closing with
something that actually drives the IPC boundary when it can be done without a
live shell.

Unrelated pre-existing issue, not from this PR

ruixen.quickactions and ruixen.stayawake throw
TypeError: Property 'firstPartyServiceFor' ... is not a function on every
shell start. Present before this branch, unrelated to the Shelf — looks like
the stale-facade signature from #67.

Still unverified (needs a human at the actual screen)

  • Gate 1, focus — the highest-risk item and still the top open question
  • Drag out into a real file manager / browser upload / chat app / terminal
  • The quick-drop highlight + hover-mode reveal under a real Hyprland drag
  • Surface color in Theme mode on a light theme

claude and others added 2 commits October 2, 2026 10:00
Follow-up to f4836e3 (addMany's newline-delimited argument, found live).

- ShelfModel.parsePathsArg/joinPathsArg: the addMany argument decoding
  moves out of the QML handler into the pure model, so it has behavioral
  tests (newline batch, single path, CRLF/trailing newline, blank lines,
  commas/semicolons are not delimiters, legacy [ JSON, invalid JSON, and a
  round trip through awkward filenames) instead of only grep checks.
  Shelf.qml now imports ShelfModel.js and calls it
- tests/live-shelf-ipc.sh: drives the REAL omarchy-shell ruixen.shelf IPC
  boundary (addMany/add/list/remove with spaces, #, parens, %, Unicode,
  folders, symlinks). Needs a running shell, so it is deliberately outside
  run-all.sh/CI like host-contract-regression.sh; skips cleanly without one.
  Only touches its own temp paths, never calls clear
- docs/CONTROL.md: addMany example was still the broken JSON-array form
- Overlay.qml: comment typo (ruixin -> ruixen)
- tests/shelf-plugin.sh: checks follow the refactor; resolves the stale
  'see the real end-to-end check below' pointer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu
Two changes to how the shelf looks and how it is laid out, keeping
the drop-pocket contract (cross-app drag in/out, no fullscreen
surface, OnDemand focus) exactly as it was.

Shape, copied from ruixin.notch's own surface rather than invented:

- 420 wide, the same width as the notch's launcherOpen mode, so the
  shelf reads as a step of the notch's size progression instead of its
  own family. 420 over anything wider on purpose: Overlay.qml's own
  history is a history of NEW sizes breaking the notch mask
  non-deterministically at the larger end.
- Fixed 236 height, 28 shoulders, 44 bottom radius (the notch's own
  expanded radius).
- The notch's shadowShadowBlur arrangement: a blurred duplicate of
  the shape behind a separately clipped Item, flush to the top edge so
  it meets the notch. No shadow properties on the masked fill --
  Overlay.qml:1770-1787 records that combination reproducibly
  destroying the silhouette.
- One plain rounded Rectangle per surface, not the notch's 3-piece
  RoundCorner/centerMask split: that split exists for the notch's
  flank wings, which this window does not have. Same radii, no seam.

Layout, as an inbox:

- Rows are a horizontal strip of 124px cards, so the panel keeps a
  fixed height no matter how full the shelf is and stays out of the
  way while dragging. It was capped at 420px tall and then silently
  stopped showing new items, which is the one thing a drop pocket
  must not do.
- Wheel scrolls the strip; the right edge fades, only when there is
  more to scroll to.
- A search box filters by name, folder or full path, via a tested
  ShelfModel.filterEntries. The header shows "2 of 9" while
  filtering so a shrinking count does not read as deletions, and
  "no match" is its own state, distinct from an empty shelf.
- Clear-the-text is a separate affordance from the shelf-wide Clear.

Verified: run-all green (90 shelf checks), omarchy plugin validate,
a real omarchy restart shell with a new PID and no shelf warnings in
the journal, live layer at 420x236, and the filter checked against
the live shelf.json contents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gitcoder89431

Copy link
Copy Markdown
Owner Author

Pushed 5ef1e60 for review — notch silhouette + horizontal inbox strip with search. Please review the shape work especially; I made one deliberate departure from the notch.

Shape

Copied from Overlay.qml rather than invented, so it should read as the same family:

  • 420 wide, the same width as launcherOpen — 284 collapsed → 420 launcher → [shelf, also 420] → 900 pinned.
  • 420 over anything wider on purpose: the "almost square edges" report is about the notch's own mask at new/larger sizes, and reusing a proven number seemed like the safer way to start.
  • Fixed 236 height, 28 shoulders, 44 bottom radius (the notch's own expanded radius).
  • Shadow uses the notchShadowBlur arrangement: a blurred duplicate of the shape behind a separately clipped Item, flush to the top edge so it meets the notch. No shadow* on the masked fill, per the comment at Overlay.qml:1770-1787.

The one departure — worth a look

The notch builds its shape from two RoundCorner shoulders + a square-topped centerMask, overlapped by a seamOverlap of 2. I skipped all of that and used a single plain Rectangle with all four radii set instead.

Reason: that 3-piece split exists so the notch's flank wings can tuck under the shoulders. This window has no flank pieces, so the split buys nothing and costs a seam to tune. Same radii, same silhouette. If you'd rather the shelf stay a literal copy of the notch's construction for consistency, say so and I'll switch it.

Related: my first attempt imported RoundCorner from qs.Commons on the assumption it was shared. It is not — it is an inline component inside the notch's own Overlay.qml (Overlay.qml:874). That produced a live RoundCorner is not a type warning and a completely missing surface, which is the is not a type trap AGENTS.md §8 warns about. Worth knowing before anyone else reaches for it.

Layout — an inbox

  • Rows are a horizontal strip of 124px cards, so the panel holds a fixed height no matter how full the shelf is and stays out of the way while dragging. It was capped at 420px tall and then silently stopped showing new items, which is the one thing a drop pocket must not do.
  • Wheel scrolls the strip (WheelHandler, target: null so it does not fight the view's own handling). The right edge fades, only when there is more to scroll to.
  • Search box filters by name, folder or full path through a new tested ShelfModel.filterEntries. The header reads 2 of 9 while filtering, so a shrinking count does not look like deletions, and "no match" is a separate state from an empty shelf.
  • Clear-the-text is deliberately a different affordance from the shelf-wide Clear — one button apart with no label difference would be a footgun.

Verification

./tests/run-all.sh green (90 shelf contract checks, 18 new ones), omarchy plugin validate clean, real omarchy restart shell with a new PID and no shelf warnings in the journal, live layer confirmed at 750 52 420 236, and filterEntries run against the live shelf.json.

Two things I did not verify and would like eyes on:

  1. Whether the search box behaves under OnDemand focus when opened from a terminal without clicking it first.
  2. Whether 236 is the right fixed height — it was picked to fit the new header + search + strip, so it is a guess, not a tuned number.

claude and others added 10 commits October 2, 2026 10:46
The panel was a rounded box floating 52px below the notch. It now grows
out of the frame like the notch's expanded launcher shape:

- left flank + square-topped center + right flank, concave wing shoulders
  flaring out to the frame, built the way Overlay.qml builds it (seam
  overlap, 28 shoulders, 44 bottom radius, 420 body) from a shelf-local
  ShelfRoundCorner copy -- the notch's RoundCorner is an inline component,
  not a shared type
- flush under the frame at the notch's own resting offset (frameInset 4,
  was a 52px gap); content inset by the shoulders and clears the frame edge
- shadow now actually shows: the clip extends OUT by a halo pad (it was
  inset, hiding the halo under the fill); the window is padded for it and
  its input region (mask) is only the visible shape, so the halo stays
  click-through and no fullscreen/blocking surface exists
- contract tests updated from 'single rounded box' to the real
  construction (wings, flush attach, outward shadow clip, shape-only input
  region); AGENTS.md section 9 / docs describe the new placement

The inbox/search/strip content from 5ef1e60 is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu
The journal reported, on every shell restart:

  QML ListView at ShelfContent.qml[416:5]: Binding loop detected for
  property "contentWidth"

from the `contentWidth: count > 0 ? childrenRect.width : 0` line in 5ef1e60.
That binding is circular: a ListView sizes its own content item FROM
contentWidth, so asking for childrenRect.width back off it is asking a
loop for a number.

The bound value was never needed. The view already derives contentWidth
from its delegates' positions, and the window is a fixed size (the shape
plus its shadow halo pad), so an unbounded strip cannot stretch it --
what keeps the list from resizing the window is its anchors, not a
content width. Removed the line and pinned the absence in the contract
test instead, since the old test asserted the presence of the exact line
that was causing it.

Verified on a real `omarchy restart shell`, new PID 430756, with the
loop gone from the journal and nothing shelf-related left in it. The
image-decode warnings that remain in older entries were my own
`echo x > file.png` demo stubs, not real files; with real PNGs the
thumbnail path is clean too.

100 shelf contract checks, run-all green, plugin validate clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
420 showed about four cards of the horizontal strip and spent the rest
of its width on empty surface. 900 shows about seven, which is what
makes the shelf read as an inbox you can scan rather than a strip you
have to scroll to find anything on.

900 is the notch's own EXPANDED (pinned) width, not the launcher's 420
(Overlay.qml:1715), so the shelf now matches the biggest silhouette the
notch itself has, rather than a step below it.

This is the exact knob the notch's own history warns about, so the
reasoning is recorded in the code next to the number: Overlay.qml:1687
calls 900 "untested territory for this notch (only 44 and 190 are
proven safe against the masking bug below)" -- the bug being the
non-deterministic flat-bottom-corner masking failure at 1770-1787. The
notch then took 900 for its pinned dashboard anyway and it holds up
live, so this is a working value and not a hypothetical. The contract
test now pins 900 specifically so reverting it is a visible act rather
than a quiet edit.

Verified on a real `omarchy restart shell`, new PID 442756, live layer
442 4 1036 276 (956 shape + 40 halo per side), and no silhouette
flattening or shelf warnings in the journal at the wider size -- the
failure mode that history warns about would show here first.

100 shelf checks, run-all green, plugin validate clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SUPER+D goes through `shell toggle ruixen.shelf` like every other
overlay's bind, and only under --with-launcher-keybind: a plugin that
takes a key without asking can clobber the user's own bind, so the
installer still leaves keys alone unless asked.

Clear (which empties the whole shelf) moves off the header down to
the right end of the search row. It stays a sibling of searchBox
rather than a child, so it does not read as part of the text field,
and searchBox gives that width back when Clear hides so an empty shelf
has no leftover gap.

Note: no click-to-dismiss. The focus-change approach was tried and
removed rather than left in dead -- Hyprland.activeToplevel did not
change on click-away here, so the root cause is still open.
…olling

Layout:
- The count moves into a "Shelf 16" chip on the left of the search row, as
  its own shape beside the field rather than nested inside it (a chip drawn
  inside the rounded box reads as part of the text input). While filtering
  it reads "3 of 16", because a bare number falling from 16 to 1 as you type
  looks like items are being deleted.
- The title/count/close bar above the search row is gone; the chip already
  carried the same count. Escape is the dismiss.
- Clear (which empties the whole shelf) sits at the right end of that row,
  outside the field, and gives the width back when it hides.
- Chip, field and Clear are bound to one height and one corner radius.
- Padding is per-edge (padTop 12, padSide/padBottom 24) instead of
  anchors.margins, which would have tied the top to the sides. The top stays
  tight on purpose: it butts up against the notch.

Scrolling: left/right/Home/End now scroll the strip, from the field or from
the strip itself (clicking bare strip focuses it). Both go through one
scrollStrip() clamp helper so the two inputs cannot drift apart.

KNOWN BROKEN: the wheel still does not scroll, and that is unresolved. The
strip is ~2104px of content in an ~850px view, so there is room to move, and
the arrow keys work against the same helper. It could not be reproduced
headlessly -- synthesizing a wheel event needs /dev/uinput write access,
which is not available -- so it is unknown whether the WheelHandler is never
invoked or fires and the math is wrong. The exact one-line probe to settle
it is documented above the handler, along with how to read the result.

Also: qml-dup-anchor.py, because a bulk anchor edit gave `list` two topMargin
lines and QML's answer to that is a warning plus a dropped type, taking the
whole plugin down. Neither `omarchy plugin validate` nor any grep-based test
catches it, because it is a compile-time diagnostic.
Reported live: with ~12 items nothing past the first screenful was
reachable -- the wheel did nothing and the arrow keys did nothing.

- Wheel: replace the WheelHandler declared inside the ListView with a
  wheel-only MouseArea laid over the strip as a sibling (NoButton, so it
  takes no presses and card clicks / double-click / drag-out still reach
  the delegates; it passes the event on when it cannot scroll). A pointer
  handler declared in a Flickable is attached to its content item, so it
  depended on event delivery order and never visibly fired
- Keyboard: arrows only worked when the search field or the list held item
  focus, and clicking a card focused neither (and a full shelf has no bare
  strip left to click). Pressing a card now focuses the list, and the
  Shelf's focus scope forwards arrows/Home/End to the strip so they work
  after a click anywhere on the shelf
- Contract tests pin the wheel overlay (sibling, NoButton, after the
  list, no WheelHandler), the pass-through, and both focus paths

Not reproduced here (no display, no wheel injection): the root cause of
the old handler not firing is unconfirmed; this routes around it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu
The chip is labelled Inbox rather than Shelf, which reads better next to
the cards.

Its counter is now a plain total instead of "3 of 12" while filtering.
That reverses the original reasoning -- a count falling from 12 to 1 as you
type reads like items are being deleted -- but the chip is sized to its
text, so the count changed width on every keystroke and the whole search
row visibly jumped. A fixed total keeps the row still; the results
underneath already show that a filter is active. The reversal and its
reason are recorded on chipCount so it does not get "fixed" back.

Also corrects a padding comment still claiming 12px after the move to 24,
and a test comment claiming a click on the chip focuses the field. It does
not: the chip has no MouseArea, so clicks fall through to the panel.
Live feedback: the highlight drawn behind the compact notch isn't wanted.
Dragging a file or folder over the notch should open the Shelf so the drop
lands in it and you can see it arrive; leaving without dropping should put
it away again.

- ruixen.notch: no highlight. A local-file drag entering the pill asks
  ruixen.shelf to open via one IPC call (openFromDrag). A drop that lands on
  the pill before the Shelf takes over the drag is still relayed (addMany)
- ruixen.shelf: new openFromDrag IPC. A shelf opened this way
  (openedByDrag) stays open once a drop lands (Escape/toggle/close dismiss
  it), hides itself after a 350ms grace if the drag leaves (cancelled if it
  comes back), and after a 2.5s watchdog if no drag ever reaches it. A shelf
  opened any other way is never auto-hidden. A 3s cooldown after an
  auto-hide stops a drag parked on the notch flapping it open and shut
- the whole panel (wings and padding included) is now a drop target, so a
  drag over a dead strip is not read as having left; drag lifecycle reaches
  Shelf.qml through explicit signals, not a Connections on containsDrag
- contract tests pin all of the above (and were mutation-checked); docs
  describe the new behavior

Unverified: whether a drag in progress carries into the freshly mapped
Shelf window is compositor behavior, and Escape after a drop needs the
Shelf to have keyboard focus (OnDemand only grants it on click).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu
Escape only worked if you clicked the shelf first. Opening it with SUPER+D
or a drag over the notch gave a window that looked focused -- open() calls
focusScope.forceActiveFocus(), so Qt-level focus was set -- and swallowed
Escape, which went to the app behind it. Under Wayland that is not a Qt focus
bug. OnDemand means the compositor withholds the keyboard until a click, so
Qt never sees the keystroke at all; forceActiveFocus cannot conjure it.

Hold Exclusive while open so Escape has something to arrive at. OnDemand
alone was chosen deliberately, to keep the shelf from grabbing the keyboard
from whatever the user drags into, so Exclusive is released again for the
one case that actually needs it: a card being dragged out. "Drag a file into
a terminal, then type a command" goes back to working, and focus is retaken
when the drag ends so Escape works again afterwards. Each delegate now
reports its own Drag.active up to the window, which is what the decision is
made from -- asked at that moment, not bound to.

The shelf still holds no keyboard while shut, and the notch drag-in path is
unaffected apart from also gaining a working Escape.

Verified live: activewindow flips to org.quickshell on open, Escape
dismisses on both the keybind and openFromDrag paths, and typing lands back
in kitty after a close. 148 checks, run-all green, plugin validate clean.

Supersedes the "keyboard focus is on demand, never exclusive" test and the
"OnDemand only" premise behind the search-focus test; both were rewritten to
state the conditional rule instead, since the old invariant is now wrong.

Untested by me: the actual drag-out mid-drag handover, which needs a real
pointer drag and a foreign drop target.
…oard focus

5999ff6 made the Shelf hold the keyboard exclusively while open (released
during a drag-out) so Escape works without a click. Several places still
said the opposite: CONTROL.md ('never takes focus on its own, so it can't
steal typing', 'keyboard focus on demand only'), AGENTS.md section 9 ('no
Exclusive focus'), the Shelf.qml header and a ShelfContent comment.

Docs/comments only, no behavior change. CONTROL.md now states the actual
trade-off: typing goes to the Shelf, not the app underneath, until it is
dismissed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fvp9wxQkTMN71NeS4JRu
@gitcoder89431
gitcoder89431 marked this pull request as ready for review October 2, 2026 16:13
@gitcoder89431
gitcoder89431 merged commit fb2a4e4 into master Oct 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants