Input Display

@itzchillv1.0.1

A clean keys-and-buttons HUD for recording, teaching, and testing.

Files

Older release. Installs get 1.0.1.

main.luau

--[[
  rebind: min_sdk=3.11.0
  rebind: permissions=none
  rebind: tick_rate=30
  rebind: memory_limit_mb=16
]]

local COLORS = {
  panel = "#0E110FF5",
  key = "#181D18",
  key_active = "#C7F36B18",
  border = "#2A332A",
  border_active = "#C7F36B70",
  text = "#F4F1E8",
  soft = "#D8DDD3",
  muted = "#98A398",
  accent = "#C7F36B",
}

local LABELS = {
  Mouse1 = "LMB",
  Mouse2 = "RMB",
  Mouse3 = "MMB",
  Mouse4 = "M4",
  Mouse5 = "M5",
  Space = "SPACE",
  Enter = "ENTER",
  Escape = "ESC",
  Backspace = "BACK",
  Tab = "TAB",
  LShift = "LSHIFT",
  RShift = "RSHIFT",
  LCtrl = "LCTRL",
  RCtrl = "RCTRL",
  LAlt = "LALT",
  RAlt = "RALT",
  LWin = "LWIN",
  RWin = "RWIN",
  PadA = "A",
  PadB = "B",
  PadX = "X",
  PadY = "Y",
  PadLB = "LB",
  PadRB = "RB",
  PadLT = "LT",
  PadRT = "RT",
  PadView = "VIEW",
  PadMenu = "MENU",
  PadLS = "LS",
  PadRS = "RS",
  PadUp = "UP",
  PadDown = "DOWN",
  PadLeft = "LEFT",
  PadRight = "RIGHT",
}

local PRIORITY = {
  Mouse1 = 1,
  Mouse2 = 2,
  Mouse3 = 3,
  Mouse4 = 4,
  Mouse5 = 5,
  W = 10,
  A = 11,
  S = 12,
  D = 13,
  Space = 14,
  LShift = 15,
  RShift = 16,
  LCtrl = 17,
  RCtrl = 18,
  LAlt = 19,
  RAlt = 20,
  Tab = 21,
  Enter = 22,
  Escape = 23,
}

local cfg = rb.ui.schema({
  toggle_key = rb.ui.keybind("Ctrl+Alt+I", {
    label = "Show / hide display",
    group = "Hotkeys",
  }),
  show_on_start = rb.ui.toggle(false, {
    label = "Show on start",
    group = "Behavior",
  }),
  show_mouse = rb.ui.toggle(true, {
    label = "Show mouse buttons",
    group = "Behavior",
  }),
  show_controller = rb.ui.toggle(true, {
    label = "Show controller buttons",
    group = "Behavior",
  }),
  max_keys = rb.ui.slider(8, {
    min = 4,
    max = 16,
    step = 1,
    label = "Maximum keys shown",
    group = "Display",
  }),
  anchor = rb.ui.select("bottom-left", {
    "top-left",
    "top-right",
    "bottom-left",
    "bottom-right",
  }, {
    label = "Position",
    group = "Display",
  }),
})

local state = {
  visible = cfg.show_on_start ~= false,
  surface = nil,
  signature = nil,
}

local function add_rect(ops, x, y, width, height, fill, radius, border, border_width)
  local op = {
    kind = "rect",
    x = x,
    y = y,
    w = width,
    h = height,
    fill = fill,
    radius = radius or 0,
  }
  if border then
    op.border = border
    op.border_width = border_width or 1
  end
  ops[#ops + 1] = op
end

local function add_text(ops, x, y, size, value, color, bold)
  ops[#ops + 1] = {
    kind = "text",
    x = x,
    y = y,
    size = size,
    text = value,
    color = color,
    bold = bold == true,
  }
end

local function text_width(value, size, bold)
  local ok, width = pcall(rb.overlay.measure, value, size, bold)
  if ok and width then
    return width
  end
  return #value * size * 0.62
end

local function key_label(key)
  local label = LABELS[key]
  if label then
    return label
  end
  if #key > 8 then
    return key:sub(1, 8)
  end
  return key
end

local function is_controller_key(key)
  return key:sub(1, 3) == "Pad" or key:sub(1, 3) == "Joy"
end

local function visible_keys()
  local keys = {}
  local held = rb.input.keys() or {}
  for _, key in ipairs(held) do
    local show = true
    if not cfg.show_mouse and key:sub(1, 5) == "Mouse" then
      show = false
    end
    if not cfg.show_controller and is_controller_key(key) then
      show = false
    end
    if show then
      keys[#keys + 1] = key
    end
  end

  table.sort(keys, function(a, b)
    local pa = PRIORITY[a] or 50
    local pb = PRIORITY[b] or 50
    if pa == pb then
      return a < b
    end
    return pa < pb
  end)
  return keys
end

local function remove_surface()
  if state.surface then
    pcall(function()
      state.surface:remove()
    end)
    state.surface = nil
  end
end

local function draw(keys)
  if not state.visible then
    remove_surface()
    return
  end

  local limit = math.max(4, math.min(16, math.floor(tonumber(cfg.max_keys) or 8)))
  local shown = {}
  for index = 1, math.min(#keys, limit) do
    shown[#shown + 1] = keys[index]
  end
  local extra = math.max(0, #keys - limit)
  local rows = math.max(1, math.ceil(#shown / 4))
  local height = 62 + rows * 38 + (extra > 0 and 16 or 0)
  local signature = table.concat(shown, ",")
    .. "|" .. tostring(extra)
    .. "|" .. tostring(cfg.anchor)
    .. "|" .. tostring(limit)
  if signature == state.signature then
    return
  end
  state.signature = signature

  local ops = {}
  add_rect(ops, 0, 0, 360, height, COLORS.panel, 14, COLORS.border, 1)
  add_rect(ops, 16, 16, 3, 24, COLORS.accent, 1)
  add_text(ops, 28, 12, 11, "INPUT", COLORS.text, true)
  add_text(ops, 28, 30, 9, "LIVE", COLORS.muted)
  add_text(ops, 300, 12, 9, "Ctrl+Alt+I", COLORS.muted)

  if #shown == 0 then
    add_rect(ops, 16, 48, 328, 30, COLORS.key, 8, COLORS.border, 1)
    add_text(ops, 24, 56, 10, "Press any key", COLORS.muted)
  else
    for index, key in ipairs(shown) do
      local column = (index - 1) % 4
      local row = (index - 1) // 4
      local x = 16 + column * 84
      local y = 48 + row * 38
      local label = key_label(key)
      local label_width = text_width(label, 10, true)
      add_rect(ops, x, y, 76, 30, COLORS.key_active, 7, COLORS.border_active, 1)
      add_text(
        ops,
        x + (76 - label_width) / 2,
        y + 9,
        10,
        label,
        COLORS.text,
        true
      )
    end
    if extra > 0 then
      add_text(ops, 16, 54 + rows * 38, 9, "+" .. tostring(extra) .. " more", COLORS.muted)
    end
  end

  if state.surface then
    local ok = pcall(function()
      state.surface:set({
        width = 360,
        height = height,
        ops = ops,
      })
    end)
    if not ok then
      remove_surface()
    end
  end

  if not state.surface then
    local ok, surface = pcall(rb.overlay.surface, {
      anchor = cfg.anchor,
      width = 360,
      height = height,
      ops = ops,
    })
    if ok then
      state.surface = surface
    else
      rb.log.warn("Input Display: could not create surface: " .. tostring(surface))
    end
  end
end

local function toggle()
  state.visible = not state.visible
  state.signature = nil
  if state.visible then
    draw(visible_keys())
  else
    remove_surface()
  end
end

local function tick()
  if not state.visible then
    return
  end
  draw(visible_keys())
end

rb.bind(cfg.toggle_key, toggle)

function OnStart()
  state.visible = cfg.show_on_start ~= false
  state.signature = nil
  draw(visible_keys())
end

function OnTick()
  tick()
end

function OnStop()
  remove_surface()
end