Keystroke Overlay

@rebindv0.1.1

A keyboard and mouse on screen that light up as you press them, for streams, recordings, and tutorials.

Files

main.luau

-- Keystroke Overlay: a keyboard and mouse drawn on screen whose keys light up
-- while they are held, each with how long it has been held. Each part of
-- layout.json is a pair of PNGs in caps/, idle and pressed, drawn from
-- assets/library-art/keystroke-overlay/render.swift. The script only observes
-- input, so every key still reaches the app and no key is captured.

local cfg = rb.ui.schema({
  games = rb.ui.text("", {
    label = "Games",
    tooltip = "Show the overlay only while one of these is the focused window:"
      .. " app or process names, or words from the window title, separated"
      .. " by commas. Empty: show it everywhere.",
    placeholder = "empty: everywhere, for example cs2, valorant",
  }),
  anchor = rb.ui.select("bottom-left", {
    "bottom-left",
    "bottom",
    "bottom-right",
    "left",
    "right",
    "top-left",
    "top",
    "top-right",
  }, { label = "Position" }),
  monitor = rb.ui.select("focused window", {
    "focused window",
    "primary",
    "under the mouse",
  }, { label = "Monitor" }),
  offset_x = rb.ui.slider(16, {
    label = "Distance from the side",
    tooltip = "No effect when Position is top, bottom, or centred",
    min = 0,
    max = 1000,
    step = 4,
  }),
  offset_y = rb.ui.slider(16, {
    label = "Distance from the top or bottom",
    tooltip = "No effect when Position is left or right",
    min = 0,
    max = 1000,
    step = 4,
  }),
  scale = rb.ui.slider(100, {
    label = "Size",
    min = 50,
    max = 150,
    step = 10,
    suffix = "%",
  }),
  color = rb.ui.color("#ffffff", {
    label = "Key color",
    tooltip = "Outlines, labels, and pressed keys",
  }),
  background = rb.ui.slider(100, {
    label = "Background opacity",
    tooltip = "The black panel behind the keys; 0% leaves only the keys",
    min = 0,
    max = 100,
    step = 5,
    suffix = "%",
  }),
  mouse = rb.ui.toggle(true, { label = "Show mouse" }),
  timers = rb.ui.toggle(true, {
    label = "Show hold times",
    tooltip = "How long each held key has been down",
  }),
  capturable = rb.ui.toggle(true, {
    label = "Show in screenshots and recordings",
    tooltip = "Off: only you see it, and OBS and screenshots leave it out",
  }),
})

-- the wheel lights this long after a scroll step
local SCROLL_MS = 150
-- hold times count in tenths of a second, so redraw that often while held
local TIMER_MS = 100
local TIMER_SIZE = 11
-- how often the focused window is compared with the games list
local CHECK_MS = 500
local DISPLAYS = {
  ["focused window"] = "active",
  primary = "primary",
  ["under the mouse"] = "mouse",
}

local layout
local parts = {}
-- the part each key name lights
local part_of = {}
local surface = nil
local showing = false
local dirty = true
local drawn_settings = ""
local drawn_at = 0
local checked_at = -CHECK_MS
local scroll_until = 0
local tinted_with = nil

local CRC = {}
for n = 0, 255 do
  local c = n
  for _ = 1, 8 do
    c = if bit32.band(c, 1) ~= 0
      then bit32.bxor(0xEDB88320, bit32.rshift(c, 1))
      else bit32.rshift(c, 1)
  end
  CRC[n] = c
end

-- PNG's CRC-32; rb.hash.crc32 takes only UTF-8 text
local function crc32(s)
  local c = 0xFFFFFFFF
  for i = 1, #s do
    local index = bit32.band(bit32.bxor(c, string.byte(s, i)), 0xFF)
    c = bit32.bxor(CRC[index], bit32.rshift(c, 8))
  end
  return bit32.bxor(c, 0xFFFFFFFF)
end

-- The art is palette PNGs of grays (render.swift). Scaling every palette
-- gray by the colour makes white that colour and keeps black black, and
-- only the PLTE chunk changes, so nothing is decoded.
local function tint(png, r, g, b)
  local at = 9
  while at <= #png do
    local length, kind = string.unpack(">I4c4", png, at)
    if kind == "PLTE" then
      local out = {}
      for i = at + 8, at + 7 + length, 3 do
        local v = string.byte(png, i) / 255
        table.insert(
          out,
          string.char(
            math.floor(r * v + 0.5),
            math.floor(g * v + 0.5),
            math.floor(b * v + 0.5)
          )
        )
      end
      local data = "PLTE" .. table.concat(out)
      return string.sub(png, 1, at + 3)
        .. data
        .. string.pack(">I4", crc32(data))
        .. string.sub(png, at + 12 + length)
    end
    at += 12 + length
  end
  return png
end

-- recolours every part when the key colour changed since the last draw
local function retint()
  if cfg.color == tinted_with then
    return
  end
  tinted_with = cfg.color
  local hex = string.match(cfg.color, "^#(%x%x%x%x%x%x)") or "ffffff"
  local r = tonumber(string.sub(hex, 1, 2), 16)
  local g = tonumber(string.sub(hex, 3, 4), 16)
  local b = tonumber(string.sub(hex, 5, 6), 16)
  for _, part in parts do
    part.up = tint(part.art_up, r, g, b)
    part.down = part.art_down and tint(part.art_down, r, g, b)
  end
end

-- whether the focused window is one of the listed games; true with none listed
local function in_game()
  local words = {}
  for _, word in string.split(cfg.games, ",") do
    word = string.lower(string.match(word, "^%s*(.-)%s*$"))
    if word ~= "" then
      table.insert(words, word)
    end
  end
  if #words == 0 then
    return true
  end
  -- no readable foreground window (a lock screen, say) counts as no game
  local ok, window = pcall(rb.system.window)
  if not ok then
    return false
  end
  local process = string.lower(window.process or "")
  local title = string.lower(window.title or "")
  for _, word in words do
    if
      string.find(process, word, 1, true)
      or string.find(title, word, 1, true)
    then
      return true
    end
  end
  return false
end

-- when the part's first held key went down, or nil when none is held
local function since(part)
  local first = nil
  for _, at in part.held do
    first = if first then math.min(first, at) else at
  end
  return first
end

local function lit(part, now)
  if part.id == "mouse-wheel" and now < scroll_until then
    return true
  end
  return next(part.held) ~= nil
end

local function clock(ms)
  if ms < 10000 then
    return string.format("%.1fs", ms / 1000)
  elseif ms < 60000 then
    return string.format("%ds", ms // 1000)
  end
  local s = ms // 1000
  return string.format("%d:%02d", s // 60, s % 60)
end

-- a keycap's time sits under its label in black, centred on the leaned face;
-- layout places it beside parts too narrow to hold it, in the key colour
local function timer_op(part, text, k)
  -- text is at least 6 units, which the 50% size would undercut
  local size = math.max(6, TIMER_SIZE * k)
  local w = rb.overlay.measure(text, size, true)
  local x, y, color
  if part.timer then
    x, y, color = part.timer.x, part.timer.y, cfg.color
  else
    local face = part.w - layout.slant
    x = part.x + face / 2 + layout.slant * 0.2
    y = part.y + part.h - 17
    color = "#000000"
  end
  return {
    kind = "text",
    x = x * k - w / 2,
    y = y * k,
    size = size,
    bold = true,
    text = text,
    color = color,
  }
end

local function shown(part)
  return cfg.mouse or string.sub(part.id, 1, 6) ~= "mouse-"
end

local function draw(now)
  retint()
  local k = cfg.scale / 100
  local pad = layout.pad
  -- as wide as the parts shown, with the left margin repeated on the right
  local right = 0
  for _, part in parts do
    if shown(part) then
      right = math.max(right, part.x + part.w)
    end
  end
  local width = (right + layout.margin) * k
  local alpha = math.floor(cfg.background / 100 * 255 + 0.5)
  local ops = {
    {
      kind = "rect",
      w = width,
      h = layout.height * k,
      fill = string.format("#000000%02x", alpha),
      radius = 6 * k,
    },
  }
  local timers = {}
  for _, part in parts do
    if not shown(part) then
      continue
    end
    table.insert(ops, {
      kind = "image",
      x = (part.x - pad) * k,
      y = (part.y - pad) * k,
      w = (part.w + 2 * pad) * k,
      h = (part.h + 2 * pad) * k,
      png = if lit(part, now) then part.down else part.up,
    })
    local at = since(part)
    if cfg.timers and at then
      table.insert(timers, timer_op(part, clock(now - at), k))
    end
  end
  -- after every image, so a time beside a part is never drawn over
  table.move(timers, 1, #timers, #ops + 1, ops)
  local fields = {
    anchor = cfg.anchor,
    display = DISPLAYS[cfg.monitor] or "active",
    offset_x = cfg.offset_x,
    offset_y = cfg.offset_y,
    width = width,
    height = layout.height * k,
    capturable = cfg.capturable,
    ops = ops,
  }
  if surface then
    surface:set(fields)
  else
    surface = rb.overlay.surface(fields)
  end
end

rb.on("start", function()
  layout = rb.file.read_json("layout.json")
  for _, part in layout.parts do
    part.art_up = rb.file.read_bytes("caps/" .. part.id .. ".png")
    if #part.keys > 0 then
      part.art_down = rb.file.read_bytes("caps/" .. part.id .. "-down.png")
    end
    part.held = {}
    for _, key in part.keys do
      part_of[key] = part
    end
    table.insert(parts, part)
  end
end)

rb.on("input_down", function(key)
  local part = part_of[key]
  if part and not part.held[key] then
    part.held[key] = rb.system.time()
    dirty = true
  end
end)

rb.on("input_up", function(key)
  local part = part_of[key]
  if part and part.held[key] then
    part.held[key] = nil
    dirty = true
  end
end)

rb.on("input_scroll", function()
  scroll_until = rb.system.time() + SCROLL_MS
  dirty = true
end)

local function any_held()
  for _, part in parts do
    if next(part.held) ~= nil then
      return true
    end
  end
  return false
end

-- one redraw per tick at most, however many keys changed; settings edits,
-- the wheel going dark after a scroll, and running hold times redraw here too.
-- Outside the listed games the surface is removed; keys are still tracked so
-- hold times stay right when it comes back.
rb.on("tick", function()
  local now = rb.system.time()
  if now - checked_at >= CHECK_MS then
    checked_at = now
    local wanted = in_game()
    if wanted ~= showing then
      showing = wanted
      if not showing and surface then
        surface:remove()
        surface = nil
      end
      dirty = true
    end
  end
  if not showing then
    return
  end
  local settings = table.concat({
    cfg.anchor,
    cfg.monitor,
    cfg.offset_x,
    cfg.offset_y,
    cfg.scale,
    cfg.color,
    cfg.background,
    tostring(cfg.mouse),
    tostring(cfg.timers),
    tostring(cfg.capturable),
  }, "/")
  if scroll_until > 0 and now >= scroll_until then
    scroll_until = 0
    dirty = true
  end
  if cfg.timers and now - drawn_at >= TIMER_MS and any_held() then
    dirty = true
  end
  if dirty or settings ~= drawn_settings then
    dirty, drawn_settings, drawn_at = false, settings, now
    draw(now)
  end
end)

63 files without text previews