desk-layouts

@itzchillv1.0.1

Save, restore, and switch window layouts across monitors.

Files

main.luau

--[[
  rebind: min_sdk=3.9.0
  rebind: permissions=none
  rebind: tick_rate=10
  rebind: memory_limit_mb=16
]]

local SLOT_COUNT = 4
local MAX_WINDOWS = 8
local STATE_FILE = "desk-layouts.json"
local DEFAULT_NAMES = { "Work", "Gaming", "Streaming", "Custom" }
local EXCLUDED_PROCESSES = {
  ["rebind"] = true,
  ["rebind-desktop"] = true,
  ["kun"] = true,
  ["textinputhost"] = true,
  ["nvidia overlay"] = true,
  ["nvidia share"] = true,
  ["gamebar"] = true,
  ["xboxgamebar"] = true,
  ["xboxgamebarwidgets"] = true,
}

local cfg = rb.ui.schema({
  menu_key = rb.ui.keybind("F7", {
    label = "Open layouts",
    group = "Hotkeys",
  }),
  save_key = rb.ui.keybind("F8", {
    label = "Save active layout",
    group = "Hotkeys",
  }),
  apply_key = rb.ui.keybind("F9", {
    label = "Apply active layout",
    group = "Hotkeys",
  }),
  layout_1_name = rb.ui.text("Work", {
    label = "Slot 1 name",
    group = "Layouts",
    max_length = 24,
  }),
  layout_2_name = rb.ui.text("Gaming", {
    label = "Slot 2 name",
    group = "Layouts",
    max_length = 24,
  }),
  layout_3_name = rb.ui.text("Streaming", {
    label = "Slot 3 name",
    group = "Layouts",
    max_length = 24,
  }),
  layout_4_name = rb.ui.text("Custom", {
    label = "Slot 4 name",
    group = "Layouts",
    max_length = 24,
  }),
  notifications = rb.ui.toggle(true, {
    label = "Show notifications",
    group = "Behavior",
  }),
})

local state = {
  layouts = {},
  active_slot = 1,
  last_slot = 0,
  menu_open = false,
  menu_surface = nil,
  menu_items = {},
  menu_selectable = {},
  menu_cursor = 1,
  menu_timer = nil,
}

local function trim(value)
  return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end

local function slot_name(slot)
  local name = trim(cfg["layout_" .. slot .. "_name"] or DEFAULT_NAMES[slot])
  if name == "" then
    return DEFAULT_NAMES[slot]
  end
  return name
end

local function notify(message)
  if cfg.notifications == false then
    return
  end
  pcall(rb.overlay.toast, message, {
    title = "Desk Layouts",
    duration = 2800,
  })
end

local function get_displays()
  local ok, displays = pcall(rb.screen.list)
  if ok and type(displays) == "table" and #displays > 0 then
    return displays
  end

  local width, height = 1920, 1080
  local screen_ok, screen_width, screen_height = pcall(rb.system.screen)
  if screen_ok then
    width = tonumber(screen_width) or width
    height = tonumber(screen_height) or height
  end
  return {
    { index = 1, x = 0, y = 0, width = width, height = height, primary = true },
  }
end

local function work_area(display)
  local ok, area = pcall(
    rb.screen.work_area,
    display.x + display.width / 2,
    display.y + display.height / 2
  )
  if ok and type(area) == "table" and area.width and area.height then
    return area
  end
  return display
end

local function display_for_rect(x, y, width, height)
  local displays = get_displays()
  local best = displays[1]
  local best_overlap = -1

  for _, display in ipairs(displays) do
    local overlap_x = math.max(
      0,
      math.min(x + width, display.x + display.width) - math.max(x, display.x)
    )
    local overlap_y = math.max(
      0,
      math.min(y + height, display.y + display.height) - math.max(y, display.y)
    )
    local overlap = overlap_x * overlap_y
    if overlap > best_overlap then
      best = display
      best_overlap = overlap
    end
  end

  return best
end

local function display_by_index(index)
  local displays = get_displays()
  for _, display in ipairs(displays) do
    if display.index == index then
      return display
    end
  end
  for _, display in ipairs(displays) do
    if display.primary then
      return display
    end
  end
  return displays[1]
end

local function norm_process(value)
  local process = tostring(value or ""):lower()
  return (process:gsub("%.exe$", ""))
end

local function is_excluded(window)
  return EXCLUDED_PROCESSES[norm_process(window.process)] == true
end

local function is_minimized(window)
  local ok, window_state = pcall(rb.window.get_state, window.handle)
  return ok and window_state == "minimized"
end

local function capture_windows()
  local ok, windows = pcall(rb.window.list)
  if not ok or type(windows) ~= "table" then
    return {}, 0
  end

  local captured = {}
  for _, window in ipairs(windows) do
    local width = tonumber(window.width) or 0
    local height = tonumber(window.height) or 0
    local title = trim(window.title)

    if window.handle
      and title ~= ""
      and title ~= "Program Manager"
      and width >= 80
      and height >= 80
      and not is_excluded(window)
      and not is_minimized(window) then
      local display = display_for_rect(
        tonumber(window.x) or 0,
        tonumber(window.y) or 0,
        width,
        height
      )
      captured[#captured + 1] = {
        process = tostring(window.process or ""),
        title = title:sub(1, 160),
        display = display.index,
        x = (tonumber(window.x) or 0) - display.x,
        y = (tonumber(window.y) or 0) - display.y,
        w = width,
        h = height,
      }
    end
  end

  table.sort(captured, function(a, b)
    if a.y == b.y then
      return a.x < b.x
    end
    return a.y < b.y
  end)

  local total = #captured
  while #captured > MAX_WINDOWS do
    captured[#captured] = nil
  end
  return captured, total
end

local function save_state()
  local payload = {
    version = 1,
    active_slot = state.active_slot,
    last_slot = state.last_slot,
    layouts = state.layouts,
  }
  local ok = pcall(rb.file.write_json, STATE_FILE, payload)
  if not ok then
    rb.log.warn("Desk Layouts: could not save " .. STATE_FILE)
  end
end

local function load_state()
  if not rb.file.exists(STATE_FILE) then
    return
  end

  local ok, data = pcall(rb.file.read_json, STATE_FILE)
  if not ok or type(data) ~= "table" then
    return
  end

  state.active_slot = math.max(
    1,
    math.min(SLOT_COUNT, math.floor(tonumber(data.active_slot) or 1))
  )
  state.last_slot = math.max(
    0,
    math.min(SLOT_COUNT, math.floor(tonumber(data.last_slot) or 0))
  )

  if type(data.layouts) ~= "table" then
    return
  end

  for slot = 1, SLOT_COUNT do
    local layout = data.layouts[slot] or data.layouts[tostring(slot)]
    if type(layout) == "table" and type(layout.windows) == "table" then
      local windows = {}
      for _, window in ipairs(layout.windows) do
        if type(window) == "table"
          and type(window.process) == "string"
          and type(window.title) == "string"
          and not EXCLUDED_PROCESSES[norm_process(window.process)] then
          windows[#windows + 1] = {
            process = window.process,
            title = window.title,
            display = tonumber(window.display) or 1,
            x = tonumber(window.x) or 0,
            y = tonumber(window.y) or 0,
            w = tonumber(window.w) or 800,
            h = tonumber(window.h) or 600,
          }
        end
      end
      if #windows > 0 then
        state.layouts[slot] = {
          windows = windows,
          captured_at = tonumber(layout.captured_at) or 0,
        }
      end
    end
  end

  if state.last_slot > SLOT_COUNT then
    state.last_slot = 0
  end
end

local function find_window(slot)
  local ok, windows = pcall(rb.window.list)
  if not ok or type(windows) ~= "table" then
    return nil
  end

  local best = nil
  local best_score = 0
  local wanted_process = norm_process(slot.process)
  local wanted_title = trim(slot.title):lower()

  for _, window in ipairs(windows) do
    if not is_excluded(window) then
      local score = 0
      if norm_process(window.process) == wanted_process then
        score = score + 4
      end

      local title = trim(window.title):lower()
      if wanted_title ~= "" and title == wanted_title then
        score = score + 8
      elseif wanted_title ~= ""
        and (title:find(wanted_title, 1, true) or wanted_title:find(title, 1, true)) then
        score = score + 3
      end

      if score > best_score then
        best = window
        best_score = score
      end
    end
  end

  if best_score > 0 then
    return best
  end
  return nil
end

local function clamp(value, minimum, maximum)
  return math.max(minimum, math.min(value, maximum))
end

local function apply_layout(slot)
  local layout = state.layouts[slot]
  if not layout or #layout.windows == 0 then
    notify(slot_name(slot) .. " is empty. Save a layout first.")
    return
  end

  local moved = 0
  local missing = 0
  local missing_names = {}

  for _, saved in ipairs(layout.windows) do
    local window = find_window(saved)
    if window then
      local display = display_by_index(saved.display)
      local area = work_area(display)
      local width = clamp(math.floor(saved.w), 120, area.width)
      local height = clamp(math.floor(saved.h), 80, area.height)
      local x = clamp(
        math.floor(display.x + saved.x),
        area.x,
        area.x + area.width - width
      )
      local y = clamp(
        math.floor(display.y + saved.y),
        area.y,
        area.y + area.height - height
      )

      local ok = pcall(rb.window.move, window.handle, {
        x = x,
        y = y,
        width = width,
        height = height,
      })
      if ok then
        moved = moved + 1
      else
        missing = missing + 1
        if #missing_names < 3 then
          missing_names[#missing_names + 1] = trim(window.title)
        end
      end
    else
      missing = missing + 1
      if #missing_names < 3 then
        missing_names[#missing_names + 1] = trim(saved.title)
      end
    end
  end

  state.active_slot = slot
  state.last_slot = slot
  save_state()

  local message = string.format(
    "%s · moved %d/%d windows",
    slot_name(slot),
    moved,
    #layout.windows
  )
  if missing > 0 then
    message = message .. " · " .. missing .. " missing"
    if #missing_names > 0 then
      message = message .. " (" .. table.concat(missing_names, ", ") .. ")"
    end
  end
  notify(message)
end

local function capture_layout(slot)
  local windows, total = capture_windows()
  if #windows == 0 then
    notify("No visible windows found to capture.")
    return
  end

  state.layouts[slot] = {
    windows = windows,
    captured_at = rb.system.time(),
  }
  state.active_slot = slot
  state.last_slot = slot
  save_state()

  local message = string.format(
    "Saved %d window%s to %s",
    #windows,
    #windows == 1 and "" or "s",
    slot_name(slot)
  )
  if total > MAX_WINDOWS then
    message = message .. " (capped at " .. MAX_WINDOWS .. ")"
  end
  notify(message)
end

local function layout_hint(slot)
  local layout = state.layouts[slot]
  if not layout or #layout.windows == 0 then
    return "empty"
  end
  return string.format(
    "%d window%s",
    #layout.windows,
    #layout.windows == 1 and "" or "s"
  )
end

local MENU_WIDTH = 620
local COLORS = {
  accent = "#C7F36B",
  text = "#F4F1E8",
  soft = "#D8DDD3",
  muted = "#98A398",
}

local close_menu

local function menu_height(items)
  local height = 92
  for _, item in ipairs(items) do
    height = height + (item.kind == "header" and 30 or 40)
  end
  return height + 52
end

local function menu_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 menu_text(ops, x, y, size, text, color, bold)
  ops[#ops + 1] = {
    kind = "text",
    x = x,
    y = y,
    size = size,
    text = text,
    color = color,
    bold = bold == true,
  }
end

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

local function draw_menu()
  if not state.menu_open then
    return
  end

  local items = state.menu_items
  local width = MENU_WIDTH
  local height = menu_height(items)
  local ops = {}

  menu_rect(ops, 0, 0, width, height, "#0E110FF5", 18, "#2B332C", 1)
  menu_rect(ops, 24, 24, 3, 18, COLORS.accent, 1)
  menu_text(ops, 38, 20, 20, "DESK LAYOUTS", COLORS.text, true)
  menu_text(ops, 38, 47, 11, "Saved window arrangements", COLORS.muted)

  local menu_hint = "F7  MENU"
  menu_text(
    ops,
    width - 24 - text_width(menu_hint, 10, false),
    22,
    10,
    menu_hint,
    COLORS.muted
  )
  local active = "ACTIVE  " .. string.upper(slot_name(state.active_slot))
  menu_text(
    ops,
    width - 24 - text_width(active, 10, true),
    43,
    10,
    active,
    COLORS.accent,
    true
  )

  local y = 84
  for _, item in ipairs(items) do
    if item.kind == "header" then
      menu_text(ops, 24, y + 10, 10, item.label, COLORS.muted, true)
      y = y + 30
    else
      local selected = item.number == state.menu_cursor
      local fill = selected and "#C7F36B14" or "#171C18"
      local border = selected and "#C7F36B55" or "#242C25"

      menu_rect(ops, 16, y, width - 32, 36, fill, 9, border, 1)
      if selected then
        menu_rect(ops, 16, y, 3, 36, COLORS.accent, 1)
      end
      menu_text(
        ops,
        32,
        y + 10,
        13,
        item.label,
        selected and COLORS.text or COLORS.soft,
        selected
      )

      local hint = string.format("%d   %s", item.number, item.hint or "")
      menu_text(
        ops,
        width - 32 - text_width(hint, 10, false),
        y + 11,
        10,
        hint,
        selected and COLORS.accent or COLORS.muted,
        false
      )
      y = y + 40
    end
  end

  local footer_y = y + 12
  menu_rect(ops, 16, footer_y, width - 32, 1, "#283029", 0)
  menu_text(
    ops,
    24,
    footer_y + 12,
    10,
    "UP/DOWN  navigate      ENTER  select      ESC  close",
    COLORS.muted
  )

  if not state.menu_surface then
    local ok, surface = pcall(rb.overlay.surface, {
      anchor = "center",
      width = width,
      height = height,
      ops = ops,
    })
    if not ok then
      rb.log.warn("Desk Layouts menu: " .. tostring(surface))
      state.menu_open = false
      return
    end
    state.menu_surface = surface
  else
    local updated = pcall(function()
      state.menu_surface:set({
        width = width,
        height = height,
        ops = ops,
      })
    end)
    if not updated then
      close_menu()
    end
  end
end

close_menu = function()
  state.menu_open = false
  if state.menu_timer then
    pcall(function()
      state.menu_timer:stop()
    end)
    state.menu_timer = nil
  end
  if state.menu_surface then
    pcall(function()
      state.menu_surface:remove()
    end)
    state.menu_surface = nil
  end
end

local function show_help()
  notify(
    "F7  open menu\n"
      .. "F8  save active layout\n"
      .. "F9  apply active layout\n\n"
      .. "Only running windows move.\n"
      .. "Up to 8 windows per layout.\n"
      .. "Positions follow each monitor."
  )
end

local function select_item(item)
  if not item then
    return
  end
  close_menu()
  if item.action then
    item.action()
  end
end

local function move_cursor(delta)
  if not state.menu_open then
    return
  end
  local count = #state.menu_selectable
  if count == 0 then
    return
  end
  state.menu_cursor = ((state.menu_cursor - 1 + delta) % count) + 1
  draw_menu()
end

local function select_number(number)
  if not state.menu_open then
    return
  end
  select_item(state.menu_selectable[number])
end

local function open_menu()
  if state.menu_open then
    close_menu()
    return
  end

  local items = {
    { kind = "header", label = "APPLY A LAYOUT" },
  }
  for slot = 1, SLOT_COUNT do
    local index = slot
    items[#items + 1] = {
      label = slot_name(index),
      hint = layout_hint(index),
      action = function()
        apply_layout(index)
      end,
    }
  end

  items[#items + 1] = { kind = "header", label = "SAVE CURRENT WINDOWS" }
  for slot = 1, SLOT_COUNT do
    local index = slot
    items[#items + 1] = {
      label = "Save to " .. slot_name(index),
      hint = "replace",
      action = function()
        capture_layout(index)
      end,
    }
  end

  items[#items + 1] = { kind = "header", label = "GENERAL" }
  items[#items + 1] = {
    label = "Apply last layout",
    hint = state.last_slot > 0 and slot_name(state.last_slot) or "none",
    action = function()
      if state.last_slot > 0 then
        apply_layout(state.last_slot)
      else
        notify("No layout has been applied yet.")
      end
    end,
  }
  items[#items + 1] = {
    label = "Help / hotkeys",
    hint = "keys and limits",
    action = show_help,
  }

  state.menu_items = items
  state.menu_selectable = {}
  for _, item in ipairs(items) do
    if item.kind ~= "header" then
      state.menu_selectable[#state.menu_selectable + 1] = item
      item.number = #state.menu_selectable
    end
  end
  state.menu_cursor = 1
  state.menu_open = true
  draw_menu()

  if state.menu_open then
    local ok, timer = pcall(rb.timer.after, 30000, function()
      close_menu()
    end)
    if ok then
      state.menu_timer = timer
    end
  end
end

local function save_active()
  if state.menu_open then
    close_menu()
  end
  capture_layout(state.active_slot)
end

local function apply_active()
  if state.menu_open then
    close_menu()
  end
  apply_layout(state.active_slot)
end

local function menu_is_open()
  return state.menu_open
end

rb.bind(cfg.menu_key, open_menu)
rb.bind(cfg.save_key, save_active)
rb.bind(cfg.apply_key, apply_active)

rb.bind("Up", function()
  move_cursor(-1)
end, { when = menu_is_open })
rb.bind("Down", function()
  move_cursor(1)
end, { when = menu_is_open })
rb.bind("Enter", function()
  select_item(state.menu_selectable[state.menu_cursor])
end, { when = menu_is_open })
rb.bind("Escape", close_menu, { when = menu_is_open })

for number = 1, 9 do
  local value = number
  rb.bind(tostring(value), function()
    select_number(value)
  end, { when = menu_is_open })
end

function OnStart()
  load_state()
  notify("F7 layouts  ·  F8 save  ·  F9 apply")
end

function OnStop()
  save_state()
end