marketplace/mouse

Marking Gestures

Hold a key, move in one of four directions and release to invoke a shortcut.

@rebindv0.1.0free

More install options
$rebind install @rebind/marking-gestures

Gallery

Marking Gestures preview

Readme

Hold a key, move in one of four directions and release to invoke a shortcut. Small strokes and ambiguous diagonals cancel without sending a command.

Setup and controls

This package targets Windows and macOS hardware mode with Rebind Link. Forward the mouse and control keyboard into hardware capture. See platform support for setup requirements. Use one physical-movement transform at a time; this package does not chain with Motion Layers or Mouse Accelerator.

Default keyAction
Hold F6Start a gesture and suppress its mouse movement.
Release F6Evaluate the stroke and emit at most one shortcut.
Escape during a gestureCancel. The later F6 release emits nothing.
1 / 2 / 3 during a gestureSelect Editing / Browsing / Media for this gesture.

Configure distinct control keys in settings, then restart the script. Supported controls are letters, digits, F1 to F12, Space, Escape, CapsLock or Menu. The Initial bank setting selects the bank for each new gesture; bank keys change only the current gesture.

Command banks

DirectionEditingBrowsingMedia
LeftUndoBackPrevious track
RightRedoForwardNext track
UpCopyPrevious tabVolume up
DownPasteNext tabVolume down

Editing uses Ctrl on Windows and Cmd on macOS. Redo uses Ctrl+Y on Windows and Cmd+Shift+Z on macOS. Browser history uses Alt+Left/Right on Windows and Cmd+[/] on macOS. Tab switching uses Ctrl+Shift+Tab and Ctrl+Tab. Media commands emit the corresponding media keys. The receiving application must support these shortcuts; the script does not detect application capabilities.

Stroke recognition

The script sums relative movement over the stroke. The dominant axis must reach Minimum stroke and exceed the other axis by Required axis dominance. Defaults are 40 counts and 1.5 times. Their configurable ranges are 5 to 1000 counts and 1.1 to 3 times. Both values are captured at gesture start.

Returning to the starting displacement cancels a stroke. This is net-direction recognition, not drawn-shape recognition. Counts are captured input units, not screen pixels, so mouse DPI affects the required hand travel.

Ordinary motion passes through when no gesture is active. Cancellation resumes ordinary motion; it never replays the suppressed stroke. Scroll and mouse buttons are not remapped. A held mouse button or Ctrl, Alt, Shift or Cmd/Win modifier prevents starting a gesture. Observing one during a gesture cancels it, and release checks them again before sending a shortcut.

The gesture also cancels on script deactivation, stop, invalid settings, excessive accumulated motion, or a detected change in foreground process/title. Foreground information is sampled by the relay, not captured atomically with input. A release missed by the binding cancels rather than commits when a tick next observes the layer key as released.

Development status

Source prepared. Package check and lint pass. Mock-runtime tests cover direction selection, release-to-commit, command banks, repeated presses, cancellation and settings validation. Cover and icon exports and the local listing preview have been visually inspected. The art illustrates behavior; it is not a screenshot or execution evidence.

Native Windows/macOS capture, hardware loss and the rendered settings panel remain unverified. Listing-preview inspection does not verify them.

rebind check library/marking-gestures
rebind lint library/marking-gestures
stylua --check library/marking-gestures
cargo test -p gp-lua-sdk --features audio --test integration

See the library workflow for deliberate runtime testing and publication.

Reviews

No reviews yet. Write a review in the app.

Source

Version 0.1.0 (current)

main.luau

local cfg = UI.Schema({
  layer = UI.Keybind("F6", { label = "Hold for a gesture", tab = "Controls" }),
  cancel = UI.Keybind("Escape", { label = "Cancel gesture", tab = "Controls" }),
  editing = UI.Keybind(
    "1",
    { label = "Select Editing bank", tab = "Controls" }
  ),
  browsing = UI.Keybind(
    "2",
    { label = "Select Browsing bank", tab = "Controls" }
  ),
  media = UI.Keybind("3", { label = "Select Media bank", tab = "Controls" }),
  bank = UI.Select(
    "Editing",
    { "Editing", "Browsing", "Media" },
    { label = "Initial bank", tab = "Gesture" }
  ),
  threshold = UI.Slider(40, {
    min = 5,
    max = 1000,
    step = 5,
    suffix = " counts",
    label = "Minimum stroke",
    tab = "Gesture",
  }),
  dominance = UI.Slider(1.5, {
    min = 1.1,
    max = 3,
    step = 0.1,
    label = "Required axis dominance",
    tab = "Gesture",
  }),
})
local primary = if _REBIND.platform == "macos" then "LWin" else "LCtrl"
local BANKS = {
  Editing = {
    left = primary .. "+Z",
    right = if _REBIND.platform == "macos" then "LWin+LShift+Z" else "LCtrl+Y",
    up = primary .. "+C",
    down = primary .. "+V",
  },
  Browsing = {
    left = if _REBIND.platform == "macos" then "LWin+[" else "LAlt+Left",
    right = if _REBIND.platform == "macos" then "LWin+]" else "LAlt+Right",
    up = "LCtrl+LShift+Tab",
    down = "LCtrl+Tab",
  },
  Media = {
    left = "MediaPrev",
    right = "MediaNext",
    up = "VolumeUp",
    down = "VolumeDown",
  },
}
local keys, used = {}, {}
local allowed = { Escape = true, Space = true, CapsLock = true, Menu = true }
for n = 1, 12 do
  allowed["F" .. n] = true
end
for n = 65, 90 do
  allowed[string.char(n)] = true
end
for n = 0, 9 do
  allowed[tostring(n)] = true
end
for _, name in { "layer", "cancel", "editing", "browsing", "media" } do
  local key = cfg[name]
  if not allowed[key] or used[key] then
    error(
      "Choose distinct control keys: letters, digits, F1 to F12, Space, Escape, CapsLock or Menu."
    )
  end
  keys[name], used[key] = key, true
end

local held = {}
local stroke = nil
local invalidNotified = false

local function modified()
  for _, key in
    { "LCtrl", "RCtrl", "LAlt", "RAlt", "LShift", "RShift", "LWin", "RWin" }
  do
    if Input.IsDown(key) then
      return true
    end
  end
  for n = 1, 5 do
    if Input.IsDown("Mouse" .. n) then
      return true
    end
  end
  return false
end

local function finite(value, low, high)
  return type(value) == "number"
    and value == value
    and value >= low
    and value <= high
end

local function valid()
  local ok = BANKS[cfg.bank] ~= nil
    and finite(cfg.threshold, 5, 1000)
    and finite(cfg.dominance, 1.1, 3)
  if not ok then
    stroke = nil
    if not invalidNotified then
      UI.Notify(
        "Marking Gestures is inactive. Select a bank, set minimum stroke between 5 and 1000 counts and axis dominance between 1.1 and 3.",
        "warning"
      )
      invalidNotified = true
    end
  else
    invalidNotified = false
  end
  return ok
end

local function sameWindow()
  local win = System.Window()
  return stroke and win.process == stroke.process and win.title == stroke.title
end

local function finish()
  if not stroke then
    return
  end
  local current = stroke
  local allowedToCommit = valid() and sameWindow() and not modified()
  stroke = nil
  if not allowedToCommit then
    return
  end
  local x, y = math.abs(current.x), math.abs(current.y)
  local direction
  if x >= current.threshold and x >= y * current.dominance then
    direction = if current.x < 0 then "left" else "right"
  elseif y >= current.threshold and y >= x * current.dominance then
    direction = if current.y < 0 then "up" else "down"
  end
  if direction then
    HID.Combo(BANKS[current.bank][direction])
  end
end

for _, name in { "layer", "cancel", "editing", "browsing", "media" } do
  local key = keys[name]
  Bind(key, {
    when = function()
      return held[key]
        or (
          if name == "layer" then valid() and not modified() else stroke ~= nil
        )
    end,
    action = function()
      if held[key] then
        return nil
      end
      held[key] = true
      if name == "layer" then
        local win = System.Window()
        stroke = {
          x = 0,
          y = 0,
          bank = cfg.bank,
          threshold = cfg.threshold,
          dominance = cfg.dominance,
          process = win.process,
          title = win.title,
        }
      elseif name == "cancel" then
        stroke = nil
      else
        stroke.bank = if name == "editing"
          then "Editing"
          elseif name == "browsing" then "Browsing"
          else "Media"
        UI.Notify(stroke.bank .. " gesture bank.", "info")
      end
      return nil
    end,
    release = function()
      held[key] = nil
      if name == "layer" then
        finish()
      end
    end,
  })
end

function OnMove(dx, dy)
  if not stroke then
    return true
  end
  if not valid() or modified() or not sameWindow() then
    stroke = nil
    return false
  end
  if not finite(dx, -1000000, 1000000) or not finite(dy, -1000000, 1000000) then
    stroke = nil
    return false
  end
  stroke.x += dx
  stroke.y += dy
  if math.abs(stroke.x) > 1000000 or math.abs(stroke.y) > 1000000 then
    stroke = nil
  end
  return false
end

function OnTick()
  for key in held do
    if not Input.IsDown(key) then
      held[key] = nil
      if key == keys.layer then
        stroke = nil
      end
    end
  end
  if stroke and (not valid() or modified() or not sameWindow()) then
    stroke = nil
  end
end

function OnBlur()
  stroke = nil
end
function OnFocus()
  stroke = nil
end
function OnStop()
  stroke = nil
end
function OnError(message)
  stroke = nil
  Log.Error(message)
end