-- TabStrip · 窗口标签条 · tabbar.lua · MIT
-- 每块显示器顶部一条 Finder 样式标签栏，跨 App 切换窗口。
-- 依赖：Hammerspoon（需「辅助功能」权限）

local M = {}
local canvas = require("hs.canvas")
local wf = hs.window.filter

---------------------------------------------------------------- 配置
local H_MIN     = 28      -- 标签条最小高度 pt（实际取该屏菜单栏高度）
local H         = H_MIN   -- 默认值，仅用于拖拽幽灵标签
local function barH(screen)
  local mb = screen:frame().y - screen:fullFrame().y
  return math.max(mb, H_MIN)
end
local MAXW      = 260     -- 单个标签最大宽度 pt
local MIN_W, MIN_H = 300, 200   -- 小于此尺寸的窗口不进标签
local FONT      = ".AppleSystemUIFont"
local MOD       = {"ctrl", "cmd"}

---------------------------------------------------------------- 状态
local strict      = {}    -- screenId -> bool（nil = true）
local canvases    = {}    -- screenId -> hs.canvas
local tabsBy      = {}    -- screenId -> { hs.window, ... }
local lastFocus   = {}    -- screenId -> winId
local hover       = nil   -- {sid=, i=}
local iconCache   = {}
local lastTiled   = {}    -- winId -> timestamp
local order       = {}    -- screenId -> { winId, ... } 手动顺序（拖拽后生效）
local orig        = {}    -- winId -> 铺满前的原始 frame
local exempt      = {}    -- winId -> true：用户双击缩回，不再自动铺满
local lastClick   = nil   -- {sid=, i=, t=} 双击检测
local drag        = nil   -- 拖拽中：{sid=, i=, win=, x0=, y0=, moved=, ghost=, tap=}
local redrawTimer, exitTimer

local function isStrict(sid) return strict[sid] ~= false end   -- 默认开启（铺满不重叠），可按屏关闭

---------------------------------------------------------------- 颜色（取自 macOS 26 Finder 标签栏近似值）
local function colors()
  if hs.host.interfaceStyle() == "Dark" then
    return { bar={hex="#1E1E1E",alpha=.80}, active={white=1,alpha=.14}, hov={white=1,alpha=.07},
             label={white=1,alpha=.85}, label2={white=1,alpha=.55}, label3={white=1,alpha=.28},
             sep={white=1,alpha=.10}, blue={hex="#0A84FF"} }
  end
  return { bar={hex="#F6F6F6",alpha=.82}, active={white=1,alpha=.85}, hov={white=1,alpha=.45},
           label={white=0,alpha=.85}, label2={white=0,alpha=.50}, label3={white=0,alpha=.28},
           sep={white=0,alpha=.10}, blue={hex="#007AFF"} }
end

---------------------------------------------------------------- 工具
local function targetFrame(screen)
  local f = screen:frame()
  local H = barH(screen)
  return { x=f.x, y=f.y + H, w=f.w, h=f.h - H }
end

local function sameFrame(a, b)
  return math.abs(a.x-b.x) < 2 and math.abs(a.y-b.y) < 2 and math.abs(a.w-b.w) < 2 and math.abs(a.h-b.h) < 2
end

-- 严格模式：铺满标签条以下；非严格模式：只保证顶边不被标签条压住
local function mouseHeld()
  local b = hs.eventtap.checkMouseButtons()
  return b and b.left
end

local function tile(win)
  if not win then return end
  if mouseHeld() then
    hs.timer.doAfter(0.5, function() tile(win) end)
    return
  end
  local s = win:screen(); if not s then return end
  local t = targetFrame(s)
  local fr = win:frame()
  local id = win:id()
  if isStrict(s:id()) and not exempt[id] then
    if sameFrame(fr, t) then return end
    if not orig[id] then orig[id] = fr end
    lastTiled[id] = hs.timer.secondsSinceEpoch()
    win:setFrame(t, 0)
  else
    if fr.y >= t.y - 1 then return end
    local nf = { x=fr.x, y=t.y, w=fr.w, h=math.min(fr.h, t.y + t.h - t.y) }
    lastTiled[win:id()] = hs.timer.secondsSinceEpoch()
    win:setFrame(nf, 0)
  end
end

-- 双击标签 / ⌃⌘↩：在「铺满」与「原尺寸」之间切换
local function toggleFill(win)
  if not win then return end
  local s = win:screen(); if not s then return end
  local t = targetFrame(s)
  local fr = win:frame()
  local id = win:id()
  if sameFrame(fr, t) then
    exempt[id] = true
    local o = orig[id]
    if not o or sameFrame(o, t) then
      o = { x = t.x + t.w * 0.14, y = t.y + t.h * 0.10, w = t.w * 0.72, h = t.h * 0.80 }
    end
    lastTiled[id] = hs.timer.secondsSinceEpoch()
    win:setFrame(o, 0)
  else
    exempt[id] = nil
    orig[id] = fr
    lastTiled[id] = hs.timer.secondsSinceEpoch()
    win:setFrame(t, 0)
  end
  win:focus()
end

local function iconFor(win)
  local app = win:application(); if not app then return nil end
  local bid = app:bundleID(); if not bid then return nil end
  if iconCache[bid] == nil then iconCache[bid] = hs.image.imageFromAppBundle(bid) or false end
  return iconCache[bid] or nil
end

local function labelFor(win)
  local app = win:application()
  local an = app and app:name() or ""
  local t = win:title() or ""
  if t == "" then return an end
  if an ~= "" and not t:find(an, 1, true) then return an .. " — " .. t end
  return t
end

local function textWidth(str, size, font)
  local ok, sz = pcall(hs.drawing.getTextDrawingSize, str, {font=font, size=size})
  if ok and sz then return sz.w end
  return #str * size * 0.6
end

local function isFullscreenSpace(screen)
  local ok, res = pcall(function()
    local sp = hs.spaces.activeSpaceOnScreen(screen)
    return sp and hs.spaces.spaceType(sp) == "fullscreen"
  end)
  return ok and res or false
end

---------------------------------------------------------------- 窗口过滤
local filter = wf.new():setDefaultFilter({
  visible = true, currentSpace = true, fullscreen = false, allowRoles = {"AXStandardWindow"},
}):rejectApp("Hammerspoon")

local function collect()
  tabsBy = {}
  for _, w in ipairs(filter:getWindows(wf.sortByCreated)) do
    local fr = w:frame()
    if fr and fr.w >= MIN_W and fr.h >= MIN_H then
      local s = w:screen()
      if s then
        local sid = s:id()
        tabsBy[sid] = tabsBy[sid] or {}
        table.insert(tabsBy[sid], w)
      end
    end
  end
  -- 应用手动顺序：已排过的在前（按 order），新窗口按创建时间追加
  for sid, list in pairs(tabsBy) do
    local ord = order[sid]
    if ord and #ord > 0 then
      local pos = {}
      for i, id in ipairs(ord) do pos[id] = i end
      local idx = {}
      for i, w in ipairs(list) do idx[w:id()] = i end
      table.sort(list, function(a, b)
        local pa, pb = pos[a:id()], pos[b:id()]
        if pa and pb then return pa < pb end
        if pa then return true end
        if pb then return false end
        return idx[a:id()] < idx[b:id()]
      end)
      local fresh = {}
      for _, w in ipairs(list) do fresh[#fresh+1] = w:id() end
      order[sid] = fresh
    end
  end
end

local function selectedIndex(sid)
  local list = tabsBy[sid] or {}
  local fw = hs.window.focusedWindow()
  local want = (fw and fw:screen() and fw:screen():id() == sid) and fw:id() or lastFocus[sid]
  for i, w in ipairs(list) do if w:id() == want then return i end end
  return #list > 0 and 1 or nil
end

---------------------------------------------------------------- 绘制
local onMouse  -- forward

local function drawScreen(screen)
  local sid = screen:id()
  local f = screen:frame()
  local H = barH(screen)
  local rect = { x=f.x, y=f.y, w=f.w, h=H }
  local c = canvases[sid]
  if not c then
    c = canvas.new(rect)
    c:level(canvas.windowLevels.floating)
    c:behaviorAsLabels({"canJoinAllSpaces", "stationary"})
    c:canvasMouseEvents(true, true, true, false)
    c:clickActivating(false)
    c:mouseCallback(function(_, ev, id) onMouse(sid, ev, id) end)
    canvases[sid] = c
  else
    c:frame(rect)
  end

  if isFullscreenSpace(screen) then c:hide(); return end

  local col = colors()
  local list = tabsBy[sid] or {}
  local sel = selectedIndex(sid)
  local n = #list
  local tabW = n > 0 and math.min(MAXW, math.floor(f.w / n)) or MAXW
  local els = {
    { type="rectangle", action="fill", fillColor=col.bar, frame={x=0,y=0,w=f.w,h=H} },
    { type="rectangle", action="fill", fillColor=col.sep, frame={x=0,y=H-1,w=f.w,h=1} },
  }
  for i, w in ipairs(list) do
    local x0 = (i-1) * tabW
    local isSel = (i == sel)
    local isHov = hover and hover.sid == sid and hover.i == i
    local fill = isSel and col.active or (isHov and col.hov or {alpha=0})
    els[#els+1] = { type="rectangle", action="fill", fillColor=fill, id="tab:"..i,
                    frame={x=x0,y=0,w=tabW,h=H-1},
                    trackMouseDown=true, trackMouseUp=true, trackMouseEnterExit=true }
    els[#els+1] = { type="rectangle", action="fill", fillColor=col.sep, frame={x=x0+tabW-1,y=0,w=1,h=H-1} }

    -- 内容：图标 + 标题，整体居中；左右各留 26pt 给关闭/序号
    local pad = 26
    local avail = tabW - pad*2
    local label = labelFor(w)
    local icon = iconFor(w)
    local iconW = icon and 22 or 0
    local tw = math.min(textWidth(label, 13, FONT) + 2, avail - iconW)
    local cw = iconW + tw
    local sx = x0 + pad + math.max(0, (avail - cw) / 2)
    if icon then
      els[#els+1] = { type="image", image=icon, imageScaling="scaleProportionally", frame={x=sx,y=(H-16)/2,w=16,h=16} }
    end
    els[#els+1] = { type="text", text=label, textFont=FONT, textSize=13, textAlignment="left",
                    textColor=isSel and col.label or col.label2, textLineBreak="truncateTail",
                    frame={x=sx+iconW, y=(H-18)/2, w=tw, h=H} }

    if isHov then
      els[#els+1] = { type="rectangle", action="fill", fillColor={alpha=0}, id="close:"..i,
                      frame={x=x0+6,y=(H-16)/2,w=16,h=16}, roundedRectRadii={xRadius=4,yRadius=4},
                      trackMouseDown=true, trackMouseUp=true, trackMouseEnterExit=true }
      els[#els+1] = { type="text", text="✕", textFont=FONT, textSize=11, textAlignment="center",
                      textColor=col.label2, frame={x=x0+6,y=(H-16)/2,w=16,h=16} }
    end
    if i <= 9 then
      els[#els+1] = { type="text", text=tostring(i), textFont="Menlo", textSize=10, textAlignment="right",
                      textColor=col.label3, frame={x=x0+tabW-24,y=(H-14)/2+1,w=16,h=14} }
    end
  end
  c:replaceElements(els)
  c:show()
end

local function redrawNow()
  collect()
  local alive = {}
  for _, s in ipairs(hs.screen.allScreens()) do
    alive[s:id()] = true
    drawScreen(s)
  end
  for sid, c in pairs(canvases) do
    if not alive[sid] then c:delete(); canvases[sid] = nil end
  end
end

local function redraw()
  if redrawTimer then redrawTimer:stop() end
  redrawTimer = hs.timer.doAfter(0.05, redrawNow)
end

---------------------------------------------------------------- 拖拽标签
local function tabWidthOf(screen)
  local n = #(tabsBy[screen:id()] or {})
  return n > 0 and math.min(MAXW, math.floor(screen:frame().w / n)) or MAXW
end

local function endDrag(cancelled)
  if not drag then return end
  local d = drag; drag = nil
  if d.tap then d.tap:stop() end
  if d.ghost then d.ghost:delete() end
  if cancelled then redraw(); return end

  if not d.moved then d.win:focus(); redraw(); return end

  local pos = hs.mouse.absolutePosition()
  local target = hs.mouse.getCurrentScreen()
  if not target then redraw(); return end
  local tsid = target:id()
  local tf = target:frame()
  local H = barH(target)
  local onStrip = pos.y >= tf.y and pos.y <= tf.y + H * 2   -- 松手在标签条附近才算
  if not onStrip then redraw(); return end

  local id = d.win:id()
  -- 从原屏顺序移除
  local src = order[d.sid] or {}
  for i = #src, 1, -1 do if src[i] == id then table.remove(src, i) end end
  order[d.sid] = src
  -- 目标位置
  local list = tabsBy[tsid] or {}
  local tw = tabWidthOf(target)
  local dropIdx = math.floor((pos.x - tf.x) / tw) + 1
  local ids = {}
  for _, w in ipairs(list) do if w:id() ~= id then ids[#ids+1] = w:id() end end
  dropIdx = math.max(1, math.min(dropIdx, #ids + 1))
  table.insert(ids, dropIdx, id)
  order[tsid] = ids

  if tsid ~= d.sid then
    d.win:moveToScreen(target, false, true, 0)
    lastFocus[tsid] = id
    hs.timer.doAfter(0.15, function() tile(d.win); d.win:focus(); redraw() end)
  else
    d.win:focus()
    redraw()
  end
end

local function beginDrag(sid, i)
  local w = (tabsBy[sid] or {})[i]
  if not w then return end
  local p = hs.mouse.absolutePosition()
  drag = { sid=sid, i=i, win=w, x0=p.x, y0=p.y, moved=false }
  local et = hs.eventtap.event.types
  drag.tap = hs.eventtap.new({ et.leftMouseDragged, et.leftMouseUp }, function(e)
    if not drag then return false end
    local t = e:getType()
    local q = hs.mouse.absolutePosition()
    if t == et.leftMouseDragged then
      if not drag.moved and (math.abs(q.x - drag.x0) > 4 or math.abs(q.y - drag.y0) > 4) then
        drag.moved = true
        local col = colors()
        local label = labelFor(drag.win)
        local gw = math.min(MAXW, textWidth(label, 13, FONT) + 48)
        drag.ghost = canvas.new({ x=q.x - gw/2, y=q.y - H/2, w=gw, h=H })
        drag.ghost:level(canvas.windowLevels.dragging)
        drag.ghost:behaviorAsLabels({"canJoinAllSpaces", "stationary"})
        drag.ghost:appendElements(
          { type="rectangle", action="fill", fillColor=col.active, roundedRectRadii={xRadius=6,yRadius=6}, frame={x=0,y=0,w=gw,h=H} },
          { type="rectangle", action="stroke", strokeColor=col.sep, strokeWidth=1, roundedRectRadii={xRadius=6,yRadius=6}, frame={x=0,y=0,w=gw,h=H} },
          { type="text", text=label, textFont=FONT, textSize=13, textAlignment="center", textColor=col.label, textLineBreak="truncateTail", frame={x=8,y=5,w=gw-16,h=H-5} }
        )
        drag.ghost:alpha(0.92)
        drag.ghost:show()
      end
      if drag.ghost then
        local f = drag.ghost:frame()
        drag.ghost:topLeft({ x=q.x - f.w/2, y=q.y - H/2 })
      end
      return false
    elseif t == et.leftMouseUp then
      local d = drag
      if d and not d.moved then
        -- 普通点击：区分单击 / 双击
        local now = hs.timer.secondsSinceEpoch()
        local dbl = lastClick and lastClick.sid == d.sid and lastClick.i == d.i and (now - lastClick.t) < 0.4
        lastClick = dbl and nil or { sid=d.sid, i=d.i, t=now }
        drag = nil; if d.tap then d.tap:stop() end
        if dbl then toggleFill(d.win) else d.win:focus() end
        redraw()
      else
        endDrag(false)
      end
      return false
    end
    return false
  end)
  drag.tap:start()
end

---------------------------------------------------------------- 鼠标
onMouse = function(sid, ev, id)
  local kind, idx = id:match("^(%a+):(%d+)$")
  idx = tonumber(idx)
  if not kind then return end
  if ev == "mouseEnter" then
    if exitTimer then exitTimer:stop(); exitTimer = nil end
    if not (hover and hover.sid == sid and hover.i == idx) then
      hover = { sid=sid, i=idx }; redraw()
    end
  elseif ev == "mouseExit" then
    if exitTimer then exitTimer:stop() end
    exitTimer = hs.timer.doAfter(0.08, function() hover = nil; redraw() end)
  elseif ev == "mouseDown" then
    if kind == "tab" then beginDrag(sid, idx) end
  elseif ev == "mouseUp" then
    local w = (tabsBy[sid] or {})[idx]
    if not w then return end
    if kind == "close" then
      endDrag(true); w:close(); redraw()
    end
  end
end

hs.timer.doEvery(1, function()
  if drag and not mouseHeld() then endDrag(true) end
end)

---------------------------------------------------------------- 窗口事件
local moveTimers = {}
local function onWindow(win, _, ev)
  if not win then redraw(); return end
  local ok, sid = pcall(function() return win:screen() and win:screen():id() end)
  if not ok then redraw(); return end

  if ev == wf.windowDestroyed then
    local id = win:id(); if id then orig[id] = nil; exempt[id] = nil end
  elseif ev == wf.windowFocused then
    if sid then lastFocus[sid] = win:id() end
    tile(win)
  elseif ev == wf.windowCreated or ev == wf.windowVisible then
    tile(win)
  elseif ev == wf.windowMoved then
    local id = win:id()
    if moveTimers[id] then moveTimers[id]:stop() end
    moveTimers[id] = hs.timer.doAfter(0.4, function()
      moveTimers[id] = nil
      local now = hs.timer.secondsSinceEpoch()
      if (now - (lastTiled[id] or 0)) > 1.0 then tile(win) end
      redraw()
    end)
  end
  redraw()
end

filter:subscribe({
  wf.windowCreated, wf.windowDestroyed, wf.windowMoved, wf.windowTitleChanged,
  wf.windowFocused, wf.windowUnfocused, wf.windowVisible, wf.windowNotVisible,
}, onWindow)

---------------------------------------------------------------- 快捷键
local function curScreen() return hs.mouse.getCurrentScreen() or hs.screen.mainScreen() end
local function focusIndex(s, i)
  local w = (tabsBy[s:id()] or {})[i]
  if w then w:focus() end
end
local function cycle(delta)
  local s = curScreen(); local sid = s:id()
  local list = tabsBy[sid] or {}
  if #list == 0 then return end
  local cur = selectedIndex(sid) or 1
  focusIndex(s, ((cur - 1 + delta) % #list) + 1)
end
local function tileScreen(s)
  for _, w in ipairs(tabsBy[s:id()] or {}) do tile(w) end
end

for i = 1, 9 do
  hs.hotkey.bind(MOD, tostring(i), function() focusIndex(curScreen(), i) end)
end
hs.hotkey.bind(MOD, "[", function() cycle(-1) end)
hs.hotkey.bind(MOD, "]", function() cycle(1) end)
hs.hotkey.bind(MOD, "w", function()
  local w = hs.window.focusedWindow(); if w then w:close() end
end)
hs.hotkey.bind(MOD, "return", function() toggleFill(hs.window.focusedWindow()) end)
hs.hotkey.bind({"ctrl","cmd","shift"}, "t", function()
  local s = curScreen(); local sid = s:id()
  strict[sid] = not isStrict(sid)
  hs.alert.show((strict[sid] and "严格 Tab 模式：开" or "严格 Tab 模式：关") .. "  · " .. s:name(), 1.2)
  if strict[sid] then collect(); tileScreen(s) end
  redraw()
end)
local function moveScreen(dir)
  local w = hs.window.focusedWindow(); if not w then return end
  local s = w:screen(); if not s then return end
  local t = dir > 0 and s:next() or s:previous()
  if not t or t:id() == s:id() then return end
  w:moveToScreen(t, false, true, 0)
  lastFocus[t:id()] = w:id()
  hs.timer.doAfter(0.1, function() tile(w); redraw() end)
end
hs.hotkey.bind({"ctrl","cmd","shift"}, "[", function() moveScreen(-1) end)
hs.hotkey.bind({"ctrl","cmd","shift"}, "]", function() moveScreen(1) end)

---------------------------------------------------------------- 菜单栏
local bar = hs.menubar.new()
if bar then
  bar:setTitle("⧉")
  bar:setTooltip("窗口标签条")
  bar:setMenu(function()
    local m = {}
    for _, s in ipairs(hs.screen.allScreens()) do
      local sid = s:id()
      m[#m+1] = { title = "严格 Tab · " .. s:name(), checked = isStrict(sid),
        fn = function()
          strict[sid] = not isStrict(sid)
          if strict[sid] then collect(); tileScreen(s) end
          redraw()
        end }
    end
    m[#m+1] = { title = "-" }
    m[#m+1] = { title = "全部重新铺满", fn = function() exempt = {}; collect(); for _, s in ipairs(hs.screen.allScreens()) do tileScreen(s) end; redraw() end }
    m[#m+1] = { title = "重新载入配置", fn = function() hs.reload() end }
    m[#m+1] = { title = "-" }
    m[#m+1] = { title = "退出标签条（窗口保持原位）", fn = function() for _, c in pairs(canvases) do c:hide() end; hs.alert.show("标签条已退出，菜单栏 ⧉ → 重新载入 可恢复", 2) end }
    return m
  end)
end

---------------------------------------------------------------- 系统事件
hs.screen.watcher.newWithActiveScreen(function() hs.timer.doAfter(0.5, function() collect(); for _, s in ipairs(hs.screen.allScreens()) do tileScreen(s) end; redraw() end) end):start()
pcall(function() hs.spaces.watcher.new(function() redraw() end):start() end)
hs.distributednotifications.new(function() redraw() end, "AppleInterfaceThemeChangedNotification"):start()

---------------------------------------------------------------- 启动
M.version = "0.6"

function M.start()
  -- 未授权辅助功能：弹系统授权框，然后每 2 秒检查，授权后自动重载
  if not hs.accessibilityState() then
    hs.accessibilityState(true)
    hs.alert.show("TabStrip 需要「辅助功能」权限\n请在系统设置里打开 Hammerspoon，之后会自动启动", 6)
    local poll
    poll = hs.timer.doEvery(2, function()
      if hs.accessibilityState() then poll:stop(); hs.reload() end
    end)
    return
  end
  collect()
  for _, s in ipairs(hs.screen.allScreens()) do tileScreen(s) end
  redrawNow()
  hs.alert.show("TabStrip 已启动", 1.2)
end

M.redraw = redrawNow
M.tabs = function() return tabsBy end
return M
