break-coach

@itzchillv1.0.0

Timed posture and eye-break reminders with a calm countdown HUD.

Files

Version 1.0.0 (current)

main.luau

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

local COLORS = {
  panel = "#11140FF5",
  text = "#F4F1E8",
  soft = "#D8DDD3",
  muted = "#98A398",
  accent = "#F2C66D",
  line = "#2C3329",
}

local MESSAGES = {
  "Stand up and reset your posture.",
  "Look 20 feet away for 20 seconds.",
  "Drop your shoulders and unclench your jaw.",
  "Walk for a minute before the next block.",
  "Take three slow breaths, then reset.",
}

local cfg = rb.ui.schema({
  interval = rb.ui.slider(45, {
    min = 5,
    max = 120,
    step = 5,
    label = "Remind every (minutes)",
    group = "Timing",
  }),
  break_len = rb.ui.slider(30, {
    min = 10,
    max = 180,
    step = 5,
    label = "Break length (seconds)",
    group = "Timing",
  }),
  toggle_key = rb.ui.keybind("Ctrl+Alt+B", {
    label = "Pause / resume",
    group = "Hotkeys",
  }),
  snooze_key = rb.ui.keybind("Ctrl+Alt+Shift+B", {
    label = "Snooze 5 minutes",
    group = "Hotkeys",
  }),
  sound = rb.ui.toggle(true, {
    label = "Play a chime",
    group = "Behavior",
  }),
  show_hud = rb.ui.toggle(true, {
    label = "Show countdown",
    group = "HUD",
  }),
  hud_anchor = rb.ui.select("top-left", {
    "top-left",
    "top-right",
    "bottom-left",
    "bottom-right",
  }, {
    label = "Countdown position",
    group = "HUD",
  }),
})

local state = {
  paused = false,
  breaking = false,
  next_at = 0,
  break_until = 0,
  remaining = 0,
  break_index = 0,
  break_message = MESSAGES[1],
  hud_surface = nil,
  break_surface = nil,
  hud_signature = nil,
  break_signature = nil,
  timer = nil,
}

local function now()
  return rb.system.time()
end

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

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 format_time(milliseconds)
  local seconds = math.max(0, math.ceil(milliseconds / 1000))
  return string.format("%02d:%02d", seconds // 60, seconds % 60)
end

local function notify(message)
  pcall(rb.overlay.toast, message, {
    title = "Break Coach",
    duration = 2600,
  })
end

local function remove_surface(key)
  local surface = state[key]
  if surface then
    pcall(function()
      surface:remove()
    end)
    state[key] = nil
  end
end

local function set_surface(key, width, height, anchor, ops)
  local surface = state[key]
  if surface then
    local ok = pcall(function()
      surface:set({
        width = width,
        height = height,
        ops = ops,
      })
    end)
    if ok then
      return
    end
    remove_surface(key)
  end

  local ok, created = pcall(rb.overlay.surface, {
    anchor = anchor,
    width = width,
    height = height,
    ops = ops,
  })
  if ok then
    state[key] = created
  else
    rb.log.warn("Break Coach: could not create surface: " .. tostring(created))
  end
end

local function update_hud()
  if cfg.show_hud == false then
    remove_surface("hud_surface")
    state.hud_signature = nil
    return
  end

  local remaining
  if state.paused then
    remaining = state.remaining
  elseif state.breaking then
    remaining = math.max(0, state.break_until - now())
  else
    remaining = math.max(0, state.next_at - now())
  end

  local label
  if state.paused then
    label = "PAUSED"
  elseif state.breaking then
    label = "BREAK"
  else
    label = "NEXT RESET"
  end

  local value = format_time(remaining)
  local signature = label .. "|" .. value .. "|" .. tostring(cfg.hud_anchor)
  if signature == state.hud_signature then
    return
  end
  state.hud_signature = signature

  local ops = {}
  add_rect(ops, 0, 0, 280, 78, COLORS.panel, 14, COLORS.line, 1)
  add_rect(ops, 16, 16, 3, 46, COLORS.accent, 1)
  add_text(ops, 28, 12, 10, label, COLORS.muted, true)
  add_text(ops, 28, 28, 26, value, COLORS.text, true)
  add_text(ops, 28, 60, 9, "Ctrl+Alt+B  pause", COLORS.muted)

  set_surface("hud_surface", 280, 78, cfg.hud_anchor, ops)
end

local function update_break()
  if not state.breaking then
    remove_surface("break_surface")
    state.break_signature = nil
    return
  end

  local remaining = math.max(0, state.break_until - now())
  local countdown = tostring(math.max(0, math.ceil(remaining / 1000))) .. "s"
  local signature = state.break_message .. "|" .. countdown
  if signature == state.break_signature then
    return
  end
  state.break_signature = signature

  local ops = {}
  add_rect(ops, 0, 0, 600, 230, "#11140FF7", 18, COLORS.line, 1)
  add_rect(ops, 24, 24, 3, 32, COLORS.accent, 1)
  add_text(ops, 40, 22, 24, "TIME TO RESET", COLORS.text, true)
  add_text(ops, 40, 58, 13, state.break_message, COLORS.soft)
  add_text(ops, 40, 92, 12, "Stand up. Look 20 feet away. Drop your shoulders.", COLORS.muted)
  add_text(ops, 40, 126, 30, countdown, COLORS.accent, true)
  add_text(ops, 40, 172, 10, "Ctrl+Alt+Shift+B  snooze 5 min", COLORS.muted)

  set_surface("break_surface", 600, 230, "center", ops)
end

local function chime()
  if cfg.sound == false then
    return
  end
  pcall(rb.audio.beep)
end

local function start_break()
  state.break_index = state.break_index + 1
  if state.break_index > #MESSAGES then
    state.break_index = 1
  end
  state.break_message = MESSAGES[state.break_index]
  state.breaking = true
  state.break_until = now() + clamp(tonumber(cfg.break_len) or 30, 10, 180) * 1000
  state.break_signature = nil
  update_break()
  update_hud()
  chime()
end

local function finish_break()
  state.breaking = false
  state.next_at = now() + clamp(tonumber(cfg.interval) or 45, 5, 120) * 60000
  state.break_signature = nil
  remove_surface("break_surface")
  update_hud()
  notify("Break complete. Next reset is scheduled.")
end

local function toggle_pause()
  if state.breaking then
    finish_break()
    return
  end

  if state.paused then
    state.paused = false
    state.next_at = now() + math.max(1000, state.remaining)
    notify("Reminder resumed.")
  else
    state.paused = true
    state.remaining = math.max(0, state.next_at - now())
    notify("Reminder paused.")
  end
  update_hud()
end

local function snooze()
  if state.breaking then
    state.breaking = false
    state.break_signature = nil
    remove_surface("break_surface")
  end
  state.next_at = now() + 5 * 60 * 1000
  notify("Snoozed for 5 minutes.")
  update_hud()
end

local function tick()
  if state.paused then
    update_hud()
    return
  end

  if state.breaking then
    if now() >= state.break_until then
      finish_break()
    else
      update_break()
      update_hud()
    end
    return
  end

  if now() >= state.next_at then
    start_break()
  else
    update_hud()
  end
end

rb.bind(cfg.toggle_key, toggle_pause)
rb.bind(cfg.snooze_key, snooze)

function OnStart()
  state.next_at = now() + clamp(tonumber(cfg.interval) or 45, 5, 120) * 60000
  state.hud_signature = nil
  update_hud()
  state.timer = rb.timer.every(1000, tick)
end

function OnStop()
  if state.timer then
    pcall(function()
      state.timer:stop()
    end)
    state.timer = nil
  end
  remove_surface("hud_surface")
  remove_surface("break_surface")
end