marketplace/mouse

Motion Layers

Adjust two mouse response curves with keyboard controls and a cancellable wheel preview.

@rebindv0.1.0free

More install options
$rebind install @rebind/motion-layers

Gallery

Motion Layers preview

Readme

Adjust two mouse response curves while using the mouse. Compare A and B, hold a key to blend between them, or preview changes with the wheel before accepting them.

Setup

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. Stop other movement-transform scripts, including Mouse Accelerator, before running this package. Its bypass disables only Motion Layers; another script can still change movement.

Open the package settings to choose control keys and edit Curve A or Curve B. A starts at identity gain and B at half gain. Key changes take effect after restarting the script. Controls accept distinct F1 to F12 keys, letters A to Z, CapsLock, ScrollLock, NumLock, Pause or Menu. The tuning keys below are reserved.

Controls

DefaultAction
Hold F6Preview adjustments to the selected curve.
F6 + 1, 2, 3 or 4Select base gain, knee, transition width or maximum gain. The notification shows the value at selection time. Press the parameter key again to read an adjusted value.
Vertical wheel while F6 is heldAdjust the selected parameter. Vertical wheel events do not reach the application during the preview.
Release F6Apply changed fields through the normal settings persistence path.
Escape during previewCancel. Releasing F6 afterward does not apply anything.
F7Switch the selected A/B curve.
Hold F8Blend toward the other curve over the configured duration.
Release F8Blend back from the current blend position.
F9Toggle Motion Layers bypass immediately.

Entering tuning cancels blending. F7 and new F8 presses are ignored until the tuning key is released. F7 is also ignored while F8 is held. Starting another blend requires a fresh press. Bypass, deactivation and stop discard an unfinished preview. A release missed during settings key capture cancels the preview when the script next observes the key as released.

If the selected curve changes in the settings panel during tuning, acceptance cancels rather than replacing those newer values. Changes to unrelated settings remain. Wheel previews stay in memory. Accepted fields use the existing settings store, which does not confirm disk durability or write all changed fields atomically.

Response curve

Each slot has four controls:

ParameterMeaningRange
Base gainOutput/input multiplier below the knee.0.05 to 10
KneeInput magnitude at which acceleration begins.0 to 1000 counts/report
Transition widthInput magnitude interval over which gain rises.0.1 to 1000 counts/report
Maximum gainMultiplier reached above the transition. Must be at least base gain.0.05 to 10

Gain follows a smoothstep transition between base and maximum. Equal gains give constant sensitivity. One gain applies to both axes, preserving direction before integer rounding. Fractional movement carries into the next event. Blend duration ranges from 0 to 2000 ms; zero changes immediately. Timed blends start at the next script tick boundary. Ticks alone never generate movement.

The input is movement magnitude per delivered report, not velocity. DPI and polling-rate changes may require retuning. Relative output counts do not guarantee exact screen-pixel movement because OS and application handling also affect the result. Invalid curve values, blend duration, selected slot or enable state bypass the transform until corrected and produce one notification per invalid episode. Invalid control keys prevent startup and require correction before restarting.

Development status

Source prepared. Offline package and mock-runtime checks cover curve response, control ordering and settings persistence. Native Windows/macOS capture, hardware loss, report-rate comparisons and the rendered settings panel remain unverified. The cover and icon are generated behavior illustrations, not screenshots or execution evidence. Marketplace submission still requires live verification and review.

From the repository root:

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

Use the library release workflow for publication and acquisition checks.

Reviews

No reviews yet. Write a review in the app.

Source

Version 0.1.0 (current)

main.luau

local PARAMETERS = {
  { "base", "Base gain", 0.05, 10, 0.05, "x" },
  { "knee", "Knee", 0, 1000, 1, " counts/report" },
  { "width", "Transition width", 0.1, 1000, 1, " counts/report" },
  { "maximum", "Maximum gain", 0.05, 10, 0.05, "x" },
}
local schema = {
  enabled = UI.Toggle(
    true,
    { label = "Apply Motion Layers", tab = "Controls" }
  ),
  selected = UI.Select(
    "A",
    { "A", "B" },
    { label = "Selected curve", tab = "Controls" }
  ),
  blendMs = UI.Slider(150, {
    min = 0,
    max = 2000,
    step = 10,
    suffix = " ms",
    label = "Blend duration",
    tab = "Controls",
  }),
  tuneKey = UI.Keybind("F6", { label = "Hold to tune", tab = "Controls" }),
  switchKey = UI.Keybind("F7", { label = "Switch A/B", tab = "Controls" }),
  blendKey = UI.Keybind(
    "F8",
    { label = "Hold to blend to other curve", tab = "Controls" }
  ),
  bypassKey = UI.Keybind(
    "F9",
    { label = "Bypass Motion Layers", tab = "Controls" }
  ),
}
for _, slot in { "A", "B" } do
  for i, p in PARAMETERS do
    local defaults =
      { if slot == "A" then 1 else 0.5, 5, 20, if slot == "A" then 1 else 0.5 }
    schema[slot .. p[1]] = UI.Slider(defaults[i], {
      min = p[3],
      max = p[4],
      step = p[5],
      suffix = p[6],
      label = p[2],
      tab = "Curve " .. slot,
    })
  end
end
local cfg = UI.Schema(schema)
local keys = { cfg.tuneKey, cfg.switchKey, cfg.blendKey, cfg.bypassKey }
local allowed = {}
for n = 1, 12 do
  allowed["F" .. n] = true
end
for n = 65, 90 do
  allowed[string.char(n)] = true
end
for _, name in { "CapsLock", "ScrollLock", "NumLock", "Pause", "Menu" } do
  allowed[name] = true
end
local used = {}
for _, key in keys do
  if not allowed[key] or used[key] then
    error(
      "Choose four distinct control keys: F1 to F12, A to Z, CapsLock, ScrollLock, NumLock, Pause or Menu."
    )
  end
  used[key] = true
end

local held = {}
local draft, snapshot, draftSlot = nil, nil, nil
local parameter = 1
local weight, target = 0, 0
local freshTick = false
local blending = false
local remainderX, remainderY = 0, 0
local invalidNotified = false
local lastEnabled, lastSelected = cfg.enabled, cfg.selected

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

local function readCurve(slot)
  local curve = {}
  for i, p in PARAMETERS do
    curve[i] = cfg[slot .. p[1]]
  end
  return curve
end

local function validCurve(curve)
  for i, p in PARAMETERS do
    if not finite(curve[i], p[3], p[4]) then
      return false
    end
  end
  return curve[4] >= curve[1]
end

local function selectedWeight()
  return if cfg.selected == "B" then 1 else 0
end

local function reset()
  draft, snapshot, draftSlot = nil, nil, nil
  blending = false
  weight, target = selectedWeight(), selectedWeight()
  freshTick = false
  remainderX, remainderY = 0, 0
end

local function ready()
  if cfg.enabled ~= lastEnabled or cfg.selected ~= lastSelected then
    reset()
    lastEnabled, lastSelected = cfg.enabled, cfg.selected
  end
  local problem = if type(cfg.enabled) ~= "boolean"
    then "Set Apply Motion Layers to on or off."
    elseif
      cfg.selected ~= "A" and cfg.selected ~= "B"
    then "Select curve A or B."
    elseif
      not finite(cfg.blendMs, 0, 2000)
    then "Set blend duration between 0 and 2000 ms."
    elseif
      not validCurve(readCurve("A"))
    then "Check Curve A ranges and keep maximum gain at least base gain."
    elseif
      not validCurve(readCurve("B"))
    then "Check Curve B ranges and keep maximum gain at least base gain."
    else nil
  if problem then
    reset()
    if not invalidNotified then
      UI.Notify("Motion Layers is bypassed. " .. problem, "warning")
      invalidNotified = true
    end
    return false
  end
  invalidNotified = false
  return cfg.enabled
end

local function gain(curve, magnitude)
  local t = math.max(0, math.min(1, (magnitude - curve[2]) / curve[3]))
  return curve[1] + (curve[4] - curve[1]) * t * t * (3 - 2 * t)
end

local function retarget(value)
  target = value
  if cfg.blendMs == 0 then
    weight = target
  end
  -- The next dt includes time before this input callback. Do not charge it.
  freshTick = true
end

local function describe()
  if draft then
    local p = PARAMETERS[parameter]
    UI.Notify(
      string.format(
        "Curve %s: %s = %.2f%s",
        draftSlot,
        p[2],
        draft[parameter],
        p[6]
      ),
      "info"
    )
  end
end

local function accept()
  if not draft or not ready() or not draft then
    return
  end
  local current = readCurve(draftSlot)
  for i = 1, 4 do
    if current[i] ~= snapshot[i] then
      reset()
      UI.Notify(
        "Preview cancelled because this curve changed in settings.",
        "warning"
      )
      return
    end
  end
  if not validCurve(draft) then
    reset()
    return
  end
  for i, p in PARAMETERS do
    if draft[i] ~= snapshot[i] then
      cfg[draftSlot .. p[1]] = draft[i]
    end
  end
  reset()
  UI.Notify("Curve preview applied.", "info")
end

local function bindControl(key, when, down, up)
  Bind(key, {
    when = when,
    action = function()
      if not held[key] then
        held[key] = true
        down()
      end
      return nil
    end,
    release = function()
      held[key] = nil
      if up then
        up()
      end
    end,
  })
end

bindControl(keys[1], function()
  return ready()
end, function()
  reset()
  draftSlot = cfg.selected
  snapshot = readCurve(draftSlot)
  draft = { snapshot[1], snapshot[2], snapshot[3], snapshot[4] }
  describe()
end, accept)

bindControl(keys[2], function()
  return ready()
end, function()
  if draft or held[keys[1]] or held[keys[3]] then
    return
  end
  cfg.selected = if cfg.selected == "A" then "B" else "A"
  ready()
  UI.Notify("Selected curve " .. cfg.selected .. ".", "info")
end)

bindControl(keys[3], function()
  return ready()
end, function()
  if draft or held[keys[1]] then
    return
  end
  blending = true
  retarget(1 - selectedWeight())
end, function()
  if blending then
    blending = false
    if ready() then
      retarget(selectedWeight())
    end
  end
end)

bindControl(keys[4], function()
  return true
end, function()
  cfg.enabled = not (cfg.enabled == true)
  reset()
  lastEnabled = cfg.enabled
  if not cfg.enabled then
    UI.Notify("Motion Layers bypassed.", "info")
  elseif ready() then
    UI.Notify("Motion Layers enabled.", "info")
  end
end)

for i = 1, 4 do
  bindControl(tostring(i), function()
    return draft ~= nil
  end, function()
    parameter = i
    describe()
  end)
end
bindControl("Escape", function()
  return draft ~= nil
end, function()
  reset()
  UI.Notify("Curve preview cancelled.", "info")
end)

function OnScroll(delta)
  if not ready() or not draft then
    return true
  end
  if not finite(delta, -1000000, 1000000) then
    return false
  end
  local p = PARAMETERS[parameter]
  local low = if parameter == 4 then draft[1] else p[3]
  local high = if parameter == 1 then draft[4] else p[4]
  draft[parameter] =
    math.max(low, math.min(high, draft[parameter] + delta * p[5]))
  return false
end

function OnMove(dx, dy)
  if not ready() then
    return true
  end
  local magnitude = math.sqrt(dx * dx + dy * dy)
  local factor
  if draft then
    factor = gain(draft, magnitude)
  else
    factor = (1 - weight) * gain(readCurve("A"), magnitude)
      + weight * gain(readCurve("B"), magnitude)
  end
  local x, y = dx * factor + remainderX, dy * factor + remainderY
  local outX = if x >= 0 then math.floor(x + 0.5) else math.ceil(x - 0.5)
  local outY = if y >= 0 then math.floor(y + 0.5) else math.ceil(y - 0.5)
  remainderX, remainderY = x - outX, y - outY
  if outX ~= 0 or outY ~= 0 then
    HID.Move(outX, outY)
  end
  return false
end

function OnTick(dt)
  if not ready() then
    return
  end
  -- Key capture can consume a release before Bind sees it. Never accept there.
  for key in held do
    if not Input.IsDown(key) then
      held[key] = nil
      if key == keys[1] and draft then
        reset()
      end
      if key == keys[3] and blending then
        blending = false
        retarget(selectedWeight())
      end
    end
  end
  if freshTick then
    freshTick = false
    return
  end
  if not finite(dt, 0, 1000000) then
    return
  end
  if cfg.blendMs == 0 then
    weight = target
    return
  end
  local step = dt / cfg.blendMs
  weight += math.max(-step, math.min(step, target - weight))
end

function OnStart()
  reset()
end
function OnBlur()
  reset()
end
function OnFocus()
  reset()
end
function OnStop()
  reset()
end
function OnError(message)
  reset()
  Log.Error(message)
end