Motion Layers

@rebindv0.1.6

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

Files

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" }
  ),
  cancelKey = UI.Keybind(
    "Escape",
    { label = "Discard preview", tab = "Controls" }
  ),
}
for i, p in PARAMETERS do
  schema["pickKey" .. i] =
    UI.Keybind(tostring(i), { label = "Pick " .. p[2], tab = "Controls" })
end
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
-- preview keys may also be digits or Escape, and must differ from the rest
local pickKeys = {}
for i = 1, #PARAMETERS do
  pickKeys[i] = cfg["pickKey" .. i]
end
for n = 0, 9 do
  allowed[tostring(n)] = true
end
allowed.Escape = true
for _, key in
  { pickKeys[1], pickKeys[2], pickKeys[3], pickKeys[4], cfg.cancelKey }
do
  if not allowed[key] or used[key] then
    error(
      "Choose distinct preview keys: digits, F1 to F12, A to Z, Escape, 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(pickKeys[i], function()
    return draft ~= nil
  end, function()
    parameter = i
    describe()
  end)
end
bindControl(cfg.cancelKey, 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