Skip to content

Mouse-First v0.2: event-driven rewrite and drag/snap fixes - #1

Merged
DjaboDev merged 10 commits into
mainfrom
refactor/v0.2
Sep 28, 2026
Merged

DjaboDev merged 10 commits into
mainfrom
refactor/v0.2

Conversation

@DjaboDev

Copy link
Copy Markdown
Owner

Summary

This rewrites Mouse-First's internals. Every v0.1 feature is kept, and existing ~/.config/omarchy/mouse-first.json files keep working. See CHANGELOG.md for the user-facing list.

Architecture

  • Service + thin bar widget. Omarchy creates one bar widget per monitor, so v0.1 drew one overlay and one set of controls per monitor. All state now lives in Service.qml, and BarWidget.qml only renders app icons.
  • Compositor-side Lua module (src/lua/mousefirst.lua, loaded with hyprctl eval). It reports the active window, the monitor layout (with reserved space) and the left-button state as custom>>mousefirst>>… events. The shell no longer polls or spawns processes. The module is reinstalled after hyprctl reload, and a generation token keeps a stale shell instance from stopping a newer one's module.
  • Explicit window state machine (src/WindowStates.qml): free / snapped / maximized, plus restore geometry. It replaces the old size caches and the browser-specific magic numbers.
  • Drag controller in global coordinates (src/DragController.qml). Drags work across monitors, restore snapped or maximized windows under the cursor, and pause window animations while dragging.
  • Pure, tested geometry (src/js/geometry.js + tests/).
  • Hyprland command builders (src/js/hypr.js): every window-targeting command goes through a single place that validates the address.
  • Theme through qs.Commons. Config through FileView/JsonAdapter (atomic writes, live reload).

Bugs fixed

  • The controls lagged or detached during SUPER + drag and resizes. Cause: hyprctl polling every 120 ms, plus a 300 ms settle lock.
  • Un-snapping with SUPER + drag left the window away from the cursor. The plugin resized the window during the compositor's drag. It now waits for the drop.
  • Maximize and restore raced Hyprland (a toggle followed immediately by geometry).
  • Coordinates on non-primary monitors were wrong, and reserved space was hard-coded (26 px top, 69 px bottom).
  • "Disable tiling" floated windows after they had already opened tiled. It is now a Hyprland window rule.
  • Focus was stolen whenever the active window was on a special workspace, which broke the scratchpad.
  • Two minimize workspaces were in use, and v0.1's hid windows from omadock.
  • The config was written through a bash heredoc, and some maps were never cleared.
  • install.sh created a stray $tmp_cfg file and overwrote looknfeel.lua.

Removed: the bin/* Python/Bash helpers, install.sh (replaced by scripts/dev-link.sh for development) and the duplicated screenshot.

New: a language selector, and IPC actions for keybinds (qs -p /usr/share/omarchy/shell ipc call mousefirst snap left, …), including status for bug reports.

Test plan

Automated / scripted (done):

  • node --test tests/: 14 geometry tests pass.
  • luac -p src/lua/mousefirst.lua
  • Via IPC on Hyprland 0.56.2: snap left and top-right, maximize, restore to the exact original geometry, minimize (window goes to special:minimized, focus moves to the next window, origin saved), restoreLast (window returns to its origin workspace), Hyprland-maximized window restored.
  • The float rule toggles when mouse-first.json is edited by hand; the module and rule survive hyprctl reload.
  • Bar icons resolve with the current icon theme; the settings panel renders.

Manual, needs a real mouse:

  • Controls stay attached during SUPER + drag and border resizes.
  • Drag by the top strip, including between monitors.
  • Snap in all 8 zones with the preview shown.
  • Un-snap and un-maximize from the top strip: the window stays under the cursor.
  • Un-snap with SUPER + drag: the window resizes on drop and does not detach.
  • Double-clicking the top strip maximizes and restores.
  • Minimize and restore from the bar and from omadock.
  • Moving the capsule by its handle; double-clicking the handle resets it.
  • Settings toggles, button order, language.
  • omarchy restart shell keeps settings and minimized origins.

🤖 Generated with Claude Code

DjaboDev and others added 10 commits September 27, 2026 17:38
- README documents real requirements (no jq/socat), usage, IPC actions,
  every config key and how the plugin works.
- CHANGELOG for 0.2.0.
- scripts/dev-link.sh replaces install.sh for development; it never
  touches Hyprland config files.
- CI runs the geometry tests, validates the manifest, checks Lua syntax
  and shellchecks scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging a window without the top-edge zone (SUPER + drag, or an app's
own title bar) is a Hyprland drag, which places the window at "start
position + pointer delta". Snaps could not be previewed or applied, and
a snapped window only regained its size after the button was released.

- Track the left button with two non-consuming binds (Hyprland's Lua API
  cannot read mouse buttons; hl.is_key_down only sees keyboard keys).
- When a floating window starts moving under a held button, end
  Hyprland's drag (the drag dispatcher ends the current drag when called
  outside a button press) and hand it to the shell's DragController,
  which then follows cursor reports until release. Native drags now get
  the same restore-under-cursor, snap preview and snap-on-drop as the
  top-edge zone.
- WindowStates waits while the button is held instead of treating the
  first moved frame as an un-snap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v0.1 plugin was a single 2600-line bar widget that polled hyprctl,
parsed Python/Bash output and drew a fullscreen overlay once per monitor.
This rewrite keeps every feature but changes how it is built:

- Service + thin bar widget: all state lives in one service; the bar
  widget only renders app icons, so multi-monitor setups no longer get
  one overlay (and one set of controls) per bar.
- Compositor-side Lua module (src/lua/mousefirst.lua) loaded with
  `hyprctl eval`: reports the active window, monitor layout and button
  state as custom IPC events. No polling, no spawned processes; the
  controls follow windows during SUPER+drag and border resizes.
- Explicit window state machine (free / snapped / maximized + restore
  geometry) replacing size caches and browser-specific magic numbers.
- Drag controller in global coordinates: works across monitors, restores
  snapped/maximized windows under the cursor, pauses window animations
  while dragging, and never resizes a window during a native drag.
- Pure, unit-tested geometry (src/js/geometry.js, tests/).
- "Disable tiling" is a real window rule instead of re-floating windows.
- Minimize uses special:minimized (shared with omadock), remembers the
  origin workspace on disk and migrates v0.1's special:mf-minimized.
- Theme via qs.Commons, settings via FileView/JsonAdapter with atomic
  writes and live reload, icon lookup that survives missing themes.
- Removes bin/* scripts, install.sh and the duplicated screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Resizing a snapped window by its border drags the snapped windows that
  share that edge along, live, like a split view; both stay snapped.
  The edge math is a pure, tested helper (geometry.followEdge).
- New options: drag to the top edge to maximize, center new windows
  (the floating rule is now two named rules, one per mode), optional
  snapping shortcuts (Super+Ctrl+Shift + arrows, off by default), and
  hiding the controls for chosen apps. Picture-in-picture players and
  windows narrower than the capsule never get controls.
- Remove the old "wait until the window stops" fallback; the button is
  now always known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hyprland 0.56.2 aborted in Config::Lua::Objects::CLuaKeybind (crash
reports 697, 221205): its keybind methods null-check an
std::optional<SP<SKeybind>> instead of the pointer, so calling any
method on a handle whose bind is gone dereferences null. HL.Keybind
:remove() also drops every bind with the same key and modifiers, so
removing the first of our two mouse:272 binds killed the second, and
removing that one crashed the compositor. This happened on every shell
restart or plugin reload.

- Register the button binds once per Lua state and never remove them;
  their callbacks are gated by MOUSE_FIRST.enabled. A config reload
  starts a fresh Lua state, so nothing accumulates.
- Remove shortcuts with hl.unbind(<keys>), which does not go through
  the buggy handle code.
- Forget handles left by earlier builds without touching them, so the
  fix can be applied over a running session.

Verified in a nested Hyprland: old module, new module on top, stop,
reinstall twice, shortcuts on/on/off and stop all survive, with exactly
two button binds left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check QML syntax

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- One bar icon per app with a window count. Clicking launches the app,
  toggles its only window, or cycles through several (most recently
  used first).
- Right-click menu: the app's windows, New window, Pin/Unpin (written to
  omadock's dock.json, so both stay in sync) and Close / Close all.
- The icon index is cached on disk and only rebuilt, at low priority,
  when an app's icon is missing, instead of on every shell start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add Español; the default language follows $LANG (pt, es, else en).
- tests/i18n.test.mjs fails if any language misses a key.
- README and CHANGELOG cover the new behaviour, options and shortcuts.
- CI runs on pushes to main and on pull requests only, instead of twice
  per push to a PR branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@DjaboDev
DjaboDev merged commit f2d7157 into main Sep 28, 2026
1 check passed
@DjaboDev
DjaboDev deleted the refactor/v0.2 branch September 28, 2026 00:33
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.

1 participant