App Keys

@rebindv0.1.6

One chord per app: focus its window if it is running, launch it if it is not.

Files

main.luau

-- App Keys: a chord focuses a running app or launches it.

local SLOTS = 6

local MOD_NAMES = {
  "Ctrl+Alt",
  "Ctrl+Shift",
  "Alt+Shift",
  "Ctrl+Alt+Shift",
  "Cmd/Win+Alt",
  "Cmd/Win+Shift",
}
local MODS = {
  ["Ctrl+Alt"] = { ctrl = true, alt = true },
  ["Ctrl+Shift"] = { ctrl = true, shift = true },
  ["Alt+Shift"] = { alt = true, shift = true },
  ["Ctrl+Alt+Shift"] = { ctrl = true, alt = true, shift = true },
  ["Cmd/Win+Alt"] = { win = true, alt = true },
  ["Cmd/Win+Shift"] = { win = true, shift = true },
}

local IS_MAC = _REBIND.platform == "macos"

local schema = {
  modifier = UI.Select(
    "Ctrl+Alt",
    MOD_NAMES,
    { label = "Modifier held with every slot key", tab = "Settings" }
  ),
}
for n = 1, SLOTS do
  local tab = `Slot {n}`
  schema[`key{n}`] = UI.Keybind(`F{n}`, { label = "Key", tab = tab })
  schema[`app{n}`] = UI.Text("", {
    label = if IS_MAC then "Application name" else "Executable",
    placeholder = if IS_MAC then "Safari" else "notepad.exe",
    tab = tab,
  })
  schema[`args{n}`] = UI.Text("", {
    label = "Arguments",
    placeholder = if IS_MAC then "not used on macOS" else "--new-window",
    tab = tab,
  })
end
local cfg = UI.Schema(schema)

local function chordHeld()
  local want = MODS[cfg.modifier] or {}
  local have = Input.GetModifiers()
  for _, m in { "ctrl", "alt", "shift", "win" } do
    if (want[m] == true) ~= have[m] then
      return false
    end
  end
  return true
end

local function report(n, message)
  Log.Warn(`slot {n}: {message}`)
  UI.Notify(message, "warning")
end

-- Windows: a running app is a visible window whose process name contains
-- the configured executable name (notepad.exe matches "notepad.exe" and
-- "notepad"). Window.List reports the file name only, so a full path is
-- reduced to its file name before matching. Focus it; otherwise launch the
-- executable with its arguments.
local function windowsActivate(n, app, args)
  local needle = app:lower():gsub("^.*[\\/]", ""):gsub("%.exe$", "")
  for _, w in Window.List() do
    if w.process:lower():find(needle, 1, true) then
      local ok, err = pcall(Window.Activate, w.handle)
      if not ok then
        report(n, `could not focus {w.process}: {err}`)
      end
      return
    end
  end
  -- start through the shell so Windows also finds apps registered by name
  -- (App Paths), not only programs on PATH
  local argv = { "/c", "start", "", app }
  for word in args:gmatch("%S+") do
    table.insert(argv, word)
  end
  local ok, err = pcall(System.ExecDetached, "cmd", argv)
  if not ok then
    report(n, `could not launch {app}: {err}`)
  end
end

-- macOS: App.Activate focuses a running app only, so a stopped one is opened
-- with `open -a`, which also brings it forward. Arguments do not apply.
local function macActivate(n, app)
  local ok, err
  if App.IsRunning(app) then
    ok, err = pcall(App.Activate, app)
  else
    ok, err = pcall(System.ExecDetached, "open", { "-a", app })
  end
  if not ok then
    report(n, `could not open {app}: {err}`)
  end
end

for n = 1, SLOTS do
  Bind(cfg[`key{n}`], {
    when = chordHeld,
    action = function()
      local app = cfg[`app{n}`]
      if app == "" then
        report(n, `slot {n} has no application`)
      elseif IS_MAC then
        macActivate(n, app)
      else
        windowsActivate(n, app, cfg[`args{n}`])
      end
      return nil
    end,
  })
end

function OnStart()
  Log.Info(
    `{cfg.modifier} + slot key focuses or launches the slot's app. Change keys, then restart the script.`
  )
end