--- === hs.expose ===
---
--- Keyboard-driven expose replacement/enhancement
---
--- Warning: this module is still somewhat experimental.
--- Should you encounter any issues, please feel free to report them on https://github.com/Hammerspoon/hammerspoon/issues
--- or #hammerspoon on irc.libera.chat
---
--- With this module you can configure a hotkey to show thumbnails for open windows when invoked; each thumbnail will have
--- an associated keyboard "hint" (usually one or two characters) that you can type to quickly switch focus to that
--- window; in conjunction with keyboard modifiers, you can additionally minimize (`alt` by default) or close
--- (`shift` by default) any window without having to focus it first.
---
--- When used in combination with a windowfilter you can include or exclude specific apps, window titles, screens,
--- window roles, etc. Additionally, each expose instance can be customized to include or exclude minimized or hidden windows,
--- windows residing in other Mission Control Spaces, or only windows for the current application. You can further customize
--- hint length, colors, fonts and sizes, whether to show window thumbnails and/or titles, and more.
---
--- To improve responsiveness, this module will update its thumbnail layout in the background (so to speak), so that it
--- can show the expose without delay on invocation. Be aware that on particularly heavy Hammerspoon configurations
--- this could adversely affect overall performance; you can disable this behaviour with
--- `hs.expose.ui.fitWindowsInBackground=false`
---
--- Usage:
--- ```
--- -- set up your instance(s)
--- expose = hs.expose.new(nil,{showThumbnails=false}) -- default windowfilter, no thumbnails
--- expose_app = hs.expose.new(nil,{onlyActiveApplication=true}) -- show windows for the current application
--- expose_space = hs.expose.new(nil,{includeOtherSpaces=false}) -- only windows in the current Mission Control Space
--- expose_browsers = hs.expose.new{'Safari','Google Chrome'} -- specialized expose using a custom windowfilter
--- -- for your dozens of browser windows :)
---
--- -- then bind to a hotkey
--- hs.hotkey.bind('ctrl-cmd','e','Expose',function()expose:toggleShow()end)
--- hs.hotkey.bind('ctrl-cmd-shift','e','App Expose',function()expose_app:toggleShow()end)
--- ```

--TODO /// hs.drawing:setClickCallback(fn) -> drawingObject
--TODO showExtraKeys

--local print=function()end

local min,max,ceil,abs,fmod,floor,random=math.min,math.max,math.ceil,math.abs,math.fmod,math.floor,math.random
local next,type,ipairs,pairs,sformat,supper,ssub,tostring=next,type,ipairs,pairs,string.format,string.upper,string.sub,tostring
local tinsert,tremove,tsort,setmetatable,rawset=table.insert,table.remove,table.sort,setmetatable,rawset

local geom=require'hs.geometry'
local drawing,image=require'hs.drawing',require'hs.image'
local window,screen=require'hs.window',require'hs.screen'
local windowfilter=require'hs.window.filter'
local application,spaces=require'hs.application',require'hs.spaces'
local eventtap=require'hs.eventtap'
local newmodal=require'hs.hotkey'.modal.new
local asciiOnly=require'hs.utf8'.asciiOnly
local function stripUnicode(s)
  return asciiOnly(s):gsub('\\x[0-9A-F][0-9A-F]','')
end
local timer,logger=require'hs.timer',require'hs.logger'
local log=logger.new('expose')

local expose={setLogLevel=log.setLogLevel,getLogLevel=log.getLogLevel} --module
local activeInstances={} -- these are updated in the background
local modals={} -- modal hotkeys for selecting a hint; global state
local activeInstance,fnreactivate -- function to reactivate the current instance (only 1 possible) after a space switch
local modes,tap={} -- modes (minimize, close) for the current instance, and eventtap (globals)
local spacesWatcher,screenWatcher,screensChangedTimer,bgFitTimer -- global watchers
local BG_FIT_INTERVAL=3
local BEHAVIOR=17

local function tlen(t)
  if not t then return 0 end
  local l=0 for _ in pairs(t) do l=l+1 end return l
end

local function isAreaEmpty(rect,w,windows,screenFrame)
  if not rect:inside(screenFrame) then return end
  for _,w2 in pairs(windows) do if w2~=w and w2.frame:intersect(rect).area>0 then return end end
  return true
end
local function sortedWindows(t,comp)
  local r={} for _,w in pairs(t) do r[#r+1]=w end
  tsort(r,comp) return r
end

local function fitWindows(self,screen,maxIterations)
  if not screen.dirty then return end
  local screenFrame=screen.frame
  local windows=screen.windows
  local nwindows=tlen(windows)
  if nwindows==0 then screen.dirty=nil return end
  local haveThumbs,isStrip=screen.thumbnails,screen.isStrip
  local optimalRatio=min(1,screenFrame.area/screen.totalOriginalArea)
  local accRatio=0

  local minWidth,minHeight=self.ui.minWidth,self.ui.minHeight
  local longSide=max(screenFrame.w,screenFrame.h)
  local maxDisplace=longSide/20
  local VEC00=geom.new(0,0)
  local edge=(isStrip and not haveThumbs) and screen.edge or VEC00

  if not haveThumbs then -- "fast" mode
    for _,w in pairs(windows) do
      if w.dirty then w.frame:setw(minWidth):seth(minHeight):setcenter(w.originalFrame.center):fit(screenFrame) w.ratio=1 end
      w.weight=1/nwindows
  end
  accRatio=1
  maxDisplace=max(minWidth,minHeight)*0.5
  else
    local isVertical=screen.pos=='left' or screen.pos=='right'
    local s=(longSide*0.7/nwindows)/(isVertical and minHeight or minWidth)
    if isStrip and s<1 then
      minWidth,minHeight=minWidth*s,minHeight*s
      local t=sortedWindows(windows,isVertical and
        function(w1,w2) return w1.frame.y<w2.frame.y end or
        function(w1,w2) return w1.frame.x<w2.frame.x end)
      local inc=longSide/nwindows
      for i,w in ipairs(t) do
        --        if w.dirty then w.frame=geom.new(inc*(i-1)+screenFrame.x,inc*(i-1)+screenFrame.y,minWidth,minHeight) end
        if w.dirty then w.frame:setx(inc*(i-1)+screenFrame.x):sety(inc*(i-1)+screenFrame.y):fit(screenFrame) end
        w.ratio=w.frame.area/w.originalFrame.area
        w.weight=w.originalFrame.area/screen.totalOriginalArea
        accRatio=accRatio+w.ratio*w.weight
      end
      maxDisplace=max(minWidth,minHeight)*0.5
    else
      for _,w in pairs(windows) do
        if w.dirty then w.frame=geom.copy(w.originalFrame):scale(min(1,optimalRatio*2)) w.ratio=min(1,optimalRatio*2)
        else w.ratio=w.frame.area/w.originalFrame.area end
        w.weight=w.originalFrame.area/screen.totalOriginalArea
        accRatio=accRatio+w.ratio*w.weight
      end
    end
  end
  local avgRatio=accRatio
  if nwindows==1 then maxIterations=1 end
  local didwork,iterations = true,0
  local TESTFRAMES={{S=1,s=1,weight=3},{S=1.08,s=1.02,weight=1},{S=1.4,s=1.1,weight=0.3},{S=2.5,s=1.5,weight=0.02}}
  local MAXTEST=haveThumbs and (isStrip and 3 or #TESTFRAMES) or 3
  while didwork and iterations<maxIterations do
    didwork,accRatio,iterations=false,0,iterations+1
    local totalOverlaps=0
    for _,w in pairs(windows) do
      local wframe,wratio=w.frame,w.ratio
      accRatio=accRatio+wratio*w.weight
      for i=MAXTEST,1,-1 do local test=TESTFRAMES[i]
        local ovs,tarea,weight={},0,test.weight
        for _,testframe in ipairs{geom.copy(wframe):scale(test.S,test.s),geom.copy(wframe):scale(test.s,test.S)} do
          for _,w2 in pairs(windows) do if w~=w2 then
            local intersection=testframe:intersect(w2.frame)
            local area=intersection.area
            if area>0 then
              tarea=tarea+area
              ovs[#ovs+1]=intersection
            end
          end end
        end
        if tarea>0 then
          local ac=geom.copy(VEC00)
          for _,ov in ipairs(ovs) do ac=ac+ov.center*(ov.area/tarea) end
          ac=(wframe.center-ac) * (tarea/wframe.area*weight*(isStrip and 3 or 3))
          if ac.length>maxDisplace then ac.length=maxDisplace
            --          else
            --            ac:move(random(-10,10)/20,random(-10,10)/20)
            --             end
          elseif ac:floor()==VEC00 then ac:move(random(-10,10)/20,random(-10,10)/20) end
          wframe:move(ac):fit(screenFrame)
          --          if i<=2 then didwork=true end
          if i==1 then
            totalOverlaps=totalOverlaps+1
            if haveThumbs then
              if wratio*1.25>avgRatio then --shrink
                wframe:scale(0.965)
                didwork=true
              else
                for _,w2 in pairs(windows) do w2.frame:scale(0.98) w2.ratio=w2.ratio*0.98 end
                accRatio=accRatio*0.98
              end
            end
          end
        elseif i==2 then
          if haveThumbs and wratio<avgRatio*1.25 and wratio<optimalRatio then -- grow
            wframe:scale(1.04)
            if not didwork and wframe.w<screenFrame.w and wframe.h<screenFrame.h then didwork=true end
          end
          break
        end
      end
      wframe:move(edge):fit(screenFrame)
      w.frame=wframe w.ratio=wframe.area/w.originalFrame.area
    end
    didwork=didwork or totalOverlaps>0
    local halting=iterations==maxIterations
    if not didwork or halting then
      local totalArea,totalRatio=0,0
      for _,win in pairs(windows) do
        totalArea=totalArea+win.frame.area
        totalRatio=totalRatio+win.ratio
        win.frames[screen]=geom.copy(win.frame)
        win.dirty=nil
      end
      self.log.vf('%s: %s (%d iter), coverage %.2f%%, ratio %.2f%%/%.2f%%, %d overlaps',screen.name,
        didwork and 'halted' or 'optimal',iterations,totalArea/(screenFrame.area)*100,totalRatio/nwindows*100,optimalRatio*100,totalOverlaps)
      if not didwork then screen.dirty=nil end
    else avgRatio=accRatio end
  end
end

local uiGlobal = {
  textColor={0.9,0.9,0.9,1},
  fontName='Lucida Grande',
  textSize=40,

  highlightColor={0.6,0.3,0.0,1},

  backgroundColor={0.03,0.03,0.03,1},
  closeModeModifier = 'shift',
  closeModeBackgroundColor={0.7,0.1,0.1,1},
  minimizeModeModifier = 'alt',
  minimizeModeBackgroundColor={0.1,0.2,0.3,1},
  onlyActiveApplication=false,
  includeNonVisible=true,
  nonVisibleStripPosition='bottom',
  nonVisibleStripBackgroundColor={0.03,0.1,0.15,1},
  nonVisibleStripWidth=0.1,
  includeOtherSpaces=true,
  otherSpacesStripBackgroundColor={0.1,0.1,0.1,1},
  otherSpacesStripWidth=0.2,
  otherSpacesStripPosition='top',

  showTitles=true,
  showThumbnails=true,
  thumbnailAlpha=0,
  highlightThumbnailAlpha=1,
  highlightThumbnailStrokeWidth=8,

  maxHintLetters = 2,

  fitWindowsMaxIterations=30,
  fitWindowsInBackground=false,
  fitWindowsInBackgroundMaxIterations=3,
  fitWindowsInBackgroundMaxRepeats=10,

  showExtraKeys=true,
}

local function getColor(t) if type(t)~='table' or t.red or not t[1] then return t else return {red=t[1] or 0,green=t[2] or 0,blue=t[3] or 0,alpha=t[4] or 1} end end

--- hs.expose.ui
--- Variable
--- Allows customization of the expose behaviour and user interface
---
--- This table contains variables that you can change to customize the behaviour of the expose and the look of the UI.
--- To have multiple expose instances with different behaviour/looks, use the `uiPrefs` parameter for the constructor;
--- the passed keys and values will override those in this table for that particular instance.
---
--- The default values are shown in the right hand side of the assignments below.
---
--- To represent color values, you can use:
---  * a table {red=redN, green=greenN, blue=blueN, alpha=alphaN}
---  * a table {redN,greenN,blueN[,alphaN]} - if omitted alphaN defaults to 1.0
--- where redN, greenN etc. are the desired value for the color component between 0.0 and 1.0
---
---  * `hs.expose.ui.textColor = {0.9,0.9,0.9}`
---  * `hs.expose.ui.fontName = 'Lucida Grande'`
---  * `hs.expose.ui.textSize = 40` - in screen points
---  * `hs.expose.ui.highlightColor = {0.8,0.5,0,0.1}` - highlight color for candidate windows
---  * `hs.expose.ui.backgroundColor = {0.30,0.03,0.03,1}`
---  * `hs.expose.ui.closeModeModifier = 'shift'` - "close mode" engaged while pressed (or 'cmd','ctrl','alt')
---  * `hs.expose.ui.closeModeBackgroundColor = {0.7,0.1,0.1,1}` - background color while "close mode" is engaged
---  * `hs.expose.ui.minimizeModeModifier = 'alt'` - "minimize mode" engaged while pressed
---  * `hs.expose.ui.minimizeModeBackgroundColor = {0.1,0.2,0.3,1}` - background color while "minimize mode" is engaged
---  * `hs.expose.ui.onlyActiveApplication = false` -- only show windows of the active application
---  * `hs.expose.ui.includeNonVisible = true` - include minimized and hidden windows
---  * `hs.expose.ui.nonVisibleStripBackgroundColor = {0.03,0.1,0.15,1}` - contains hints for non-visible windows
---  * `hs.expose.ui.nonVisibleStripPosition = 'bottom'` - set it to your Dock position ('bottom', 'left' or 'right')
---  * `hs.expose.ui.nonVisibleStripWidth = 0.1` - 0..0.5, width of the strip relative to the screen
---  * `hs.expose.ui.includeOtherSpaces = true` - include windows in other Mission Control Spaces
---  * `hs.expose.ui.otherSpacesStripBackgroundColor = {0.1,0.1,0.1,1}`
---  * `hs.expose.ui.otherSpacesStripPosition = 'top'`
---  * `hs.expose.ui.otherSpacesStripWidth = 0.2`
---  * `hs.expose.ui.showTitles = true` - show window titles
---  * `hs.expose.ui.showThumbnails = true` - show window thumbnails
---  * `hs.expose.ui.thumbnailAlpha = 0` - 0..1, opacity for thumbnails
---  * `hs.expose.ui.highlightThumbnailAlpha = 1` - 0..1, opacity for thumbnails of candidate windows
---  * `hs.expose.ui.highlightThumbnailStrokeWidth = 8` - thumbnail frame thickness for candidate windows
---  * `hs.expose.ui.maxHintLetters = 2` - if necessary, hints longer than this will be disambiguated with digits
---  * `hs.expose.ui.fitWindowsMaxIterations = 30` -- lower is faster, but higher chance of overlapping thumbnails
---  * `hs.expose.ui.fitWindowsInBackground = false` -- improves responsiveness, but can affect the rest of the config

-- TODO * `hs.expose.ui.fitWindowsMaxIterations = 3`
-- TODO * `hs.expose.ui.showExtraKeys = true` -- show non-hint keybindings at the top of the screen

expose.ui=setmetatable({},{
  __newindex=function(t,k,v) uiGlobal[k]=getColor(v) end,
  __index=function(t,k)return getColor(uiGlobal[k])end,
})



local function getHints(self,windows)
  local function hasSubHints(t)
    for k,v in pairs(t) do if type(k)=='string' and #k==1 then return true end end
  end
  local hints={apps={}}
  local reservedHint=1
  for _,screen in pairs(self.screens) do
    for id,w in pairs(screen.windows) do
      if not windows or windows[id] then
        local appname=stripUnicode(w.appname or '')
        while #appname<self.ui.maxHintLetters do
          appname=appname..tostring(reservedHint) reservedHint=reservedHint+1
        end
        w.appname=appname
        hints[#hints+1]=w
        hints.apps[appname]=(hints.apps[appname] or 0)+1
        w.hint=''
      end
    end
  end
  local function normalize(t,n) --change in place
    local _
    while #t>0 and tlen(t.apps)>0 do
      if n>self.ui.maxHintLetters or (tlen(t.apps)==1 and n>1 and not hasSubHints(t))  then
        -- last app remaining for this hint; give it digits
        local app=next(t.apps)
        t.apps={}
        if #t>1 then
          --fix so that accumulation is possible
          local total=#t
          for i,w in ipairs(t) do
            t[i]=nil
            local c=tostring(total<10 and i-(t.m1 and 1 or 0) or floor(i/10))
            t[c]=t[c] or {}
            tinsert(t[c],w)
            if #t[c]>1 then t[c].apps={app=#t[c]} t[c].m1=c~='0' end
            w.hint=w.hint..c
          end
        end
      else
        -- find the app with least #windows and add a hint to it
        local minfound,minapp=9999
        for appname,nwindows in pairs(t.apps) do
          if nwindows<minfound then minfound=nwindows minapp=appname end
        end
        t.apps[minapp]=nil
        local c=supper(ssub(minapp,n,n))
        --TODO what if not long enough
        t[c]=t[c] or {apps={}}
        t[c].apps[minapp]=minfound
        local i=1
        while i<=#t do
          if t[i].appname==minapp then
            local w=tremove(t,i)
            tinsert(t[c],w)
            w.hint=w.hint..c
          else i=i+1 end
        end
      end
    end
    for c,subt in pairs(t) do
      if type(c)=='string' and #c==1 then
        normalize(subt,n+1)
      end
    end
  end

  normalize(hints,1)
  return hints
end

local function updateHighlights(ui,hints,subtree,entering,show)
  for c,t in pairs(hints) do
    if t==subtree then
      updateHighlights(ui,t,nil,entering,true)
    elseif type(c)=='string' and #c==1 then
      local w=t[1]
      if w then
        if ui.showThumbnails then
          if show then w.thumb:setAlpha(ui.highlightThumbnailAlpha) w.highlight:show()
          else w.thumb:setAlpha(ui.thumbnailAlpha) w.highlight:hide() end
        end
        if ui.showTitles then
          if show then w.titlerect:show() w.titletext:show()
          else w.titletext:hide() w.titlerect:hide() end
        end
        if show then
          w.hintrect:show()
          w.curhint=ssub('        ',1,#modals+(entering and 0 or -1))..ssub(w.hint,#modals+(entering and 1 or 0))
          w.hinttext:setText(w.curhint):show()
          w.icon:show()
        else
          w.hinttext:hide()
          w.hintrect:hide()
          w.icon:hide()
        end
        w.visible=show
      else updateHighlights(ui,t,subtree,entering,show) end
    end
  end
end

local function setMode(self,k,mode)
  if modes[k]==mode then return end
  modes[k]=mode
  for s,screen in pairs(self.screens) do
    if modes[k] then
      screen.bg:setFillColor(k=='close' and self.ui.closeModeBackgroundColor or self.ui.minimizeModeBackgroundColor)
    elseif s=='inv' then
      screen.bg:setFillColor(self.ui.nonVisibleStripBackgroundColor)
    elseif type(s)=='string' then
      screen.bg:setFillColor(self.ui.otherSpacesStripBackgroundColor)
    else
      screen.bg:setFillColor(self.ui.backgroundColor)
    end
  end
end

local enter--,setThumb

local function exit(self)
  self.log.vf('exit modal for hint #%d',#modals)
  tremove(modals).modal:exit()
  if #modals>0 then updateHighlights(self.ui,modals[#modals].hints,nil,false,true) return enter(self) end
  -- exit all
  local showThumbs,showTitles=self.ui.showThumbnails,self.ui.showTitles
  for _,s in pairs(self.screens) do
    for _,w in pairs(s.windows) do
      if showThumbs then w.thumb:hide() w.highlight:hide() end
      if showTitles then w.titletext:hide() w.titlerect:hide() end
      if w.icon then w.icon:hide() w.hinttext:hide() w.hintrect:hide() end
      --      if w.rect then w.rect:delete() end
      if w.textratio then w.textratio:hide() end
    end
    s.bg:hide()
  end
  tap:stop()
  fnreactivate,activeInstance=nil,nil
  --    return exitAll(self)
  --  end
  --  return enter(self)
end
local function exitAll(self,toFocus)
  self.log.d('exiting')
  while #modals>0 do exit(self) end
  if toFocus then
    self.log.i('focusing',toFocus)
    --    if toFocus:application():bundleID()~='com.apple.finder' then
    --    toFocus:focus()
    --    else
    timer.doAfter(0.25,function()toFocus:focus()end) -- el cap bugs out (desktop "floats" on top) if done directly
    --    end
  end
end

enter=function(self,hints)
  if not hints then modals[#modals].modal:enter()
  elseif hints[1] then
    --got a hint
    updateHighlights(self.ui,modals[#modals].hints,nil,false,true)
    local h,w=hints[1],hints[1].window
    local app,appname=w:application(),h.appname
    if modes.close then
      self.log.f('closing window (%s)',appname)
      w:close()
      hints[1]=nil
      -- close app
      if app then
        if #app:allWindows()==0 then
          self.log.f('quitting application %s',appname)
          app:kill()
        end
      end
      --      updateHighlights(self.ui,modals[#modals].hints,nil,false,true)
      return enter(self)
    elseif modes.min then
      self.log.f('toggling window minimized/hidden (%s)',appname)
      if w:isMinimized() then w:unminimize()
      elseif app:isHidden() then app:unhide()
      else w:minimize() end
      --      updateHighlights(self.ui,modals[#modals].hints,nil,false,true)
      return enter(self)
    else
      self.log.f('focusing window (%s)',appname)
      if w:isMinimized() then w:unminimize() end
      --      w:focus()
      return exitAll(self,w)
    end
  else
    if modals[#modals] then self.log.vf('exit modal %d',#modals) modals[#modals].modal:exit() end
    local modal=newmodal()
    modals[#modals+1]={modal=modal,hints=hints}
    modal:bind({},'escape',function()return exitAll(self)end)
    modal:bind({},'delete',function()return exit(self)end)
    for c,t in pairs(hints) do
      if type(c)=='string' and #c==1 then
        modal:bind({},c,function()updateHighlights(self.ui,hints,t,true) enter(self,t) end)
        modal:bind({self.ui.closeModeModifier},c,function()updateHighlights(self.ui,hints,t,true) enter(self,t) end)
        modal:bind({self.ui.minimizeModeModifier},c,function()updateHighlights(self.ui,hints,t,true) enter(self,t) end)
      end
    end
    self.log.vf('enter modal for hint #%d',#modals)
    modal:enter()
  end
end

local function spaceChanged()
  if not activeInstance then return end
  local temp=fnreactivate
  exitAll(activeInstance)
  return temp()
end

local function setThumbnail(w,screenFrame,thumbnails,titles,ui,bg)
  local wframe=w.frame
  if thumbnails then
    w.thumb:setFrame(wframe):orderAbove(bg)
    w.highlight:setFrame(wframe):orderAbove(w.thumb)
  end
  --  local hwidth=#w.hint*ui.hintLetterWidth
  local hintWidth=drawing.getTextDrawingSize(w.hint or '',ui.hintTextStyle).w
  local hintHeight=ui.hintHeight
  local padding=hintHeight*0.1
  local br=geom.copy(wframe):seth(hintHeight):setw(hintWidth+hintHeight+padding*4):setcenter(wframe.center):fit(screenFrame)
  local tr=geom.copy(br):setw(hintWidth+padding*2):move(hintHeight+padding*2,0)
  local ir=geom.copy(br):setw(hintHeight):move(padding,0)
  w.hintrect:setFrame(br):orderAbove(w.highlight or bg)
  w.hinttext:setFrame(tr):orderAbove(w.hintrect):setText(w.curhint or w.hint or ' ')
  w.icon:setFrame(ir):orderAbove(w.hintrect)

  if titles then
    local titleWidth=min(wframe.w,w.titleWidth)
    local tr=geom.copy(wframe):seth(ui.titleHeight):setw(titleWidth+8)
      :setcenter(wframe.center):move(0,ui.hintHeight):fit(screenFrame)
    w.titlerect:setFrame(tr):orderAbove(w.highlight or bg)
    w.titletext:setFrame(tr):orderAbove(w.titlerect)
  end
end


local UNAVAILABLE=image.imageFromName'NSStopProgressTemplate'

local function showExpose(self,windows,animate,alt_algo)
  -- animate is waaay to slow: don't bother
  -- alt_algo sometimes performs better in terms of coverage, but (in the last half-broken implementation) always reaches maxIterations
  -- alt_algo TL;DR: much slower, don't bother
  if not self.running then self.log.i('instance not running, cannot show expose') return end
  self.log.d('activated')
  local hints=getHints(self,windows)
  local ui=self.ui
  for sid,s in pairs(self.screens) do
    if animate and ui.showThumbnails then
      s.bg:show():orderBelow()
      for _,w in pairs(s.windows) do
        w.thumb = drawing.image(w.originalFrame,window.snapshotForID(w.id)):show() --FIXME
      end end
    fitWindows(self,s,ui.fitWindowsMaxIterations,animate and 0 or nil,alt_algo)
    local bg,screenFrame,thumbnails,titles=s.bg:show(),s.frame,s.thumbnails,ui.showTitles
    for id,w in pairs(s.windows) do
      if not windows or windows[id] then
        setThumbnail(w,screenFrame,thumbnails,titles,ui,bg)
        --      if showThumbs then w.thumb:show() w.highlight:show() end
        --      if showTitles then w.titlerect:show() w.titletext:show() end
        --      w.hintrect:show() w.hinttext:show() w.icon:show()
        if w.textratio then w.textratio:show() end
      end
    end
  end
  tap=eventtap.new({eventtap.event.types.flagsChanged},function(e)
    local function hasOnly(t,mod)
      local n=next(t)
      if n~=mod then return end
      if not next(t,n) then return true end
    end
    setMode(self,'close',hasOnly(e:getFlags(),self.ui.closeModeModifier))
    setMode(self,'min',hasOnly(e:getFlags(),self.ui.minimizeModeModifier))
  end)
  tap:start()
  enter(self,hints)
end

--- hs.expose:toggleShow([activeApplication])
--- Method
--- Toggles the expose - see `hs.expose:show()` and `hs.expose:hide()`
---
--- Parameters:
---  * activeApplication - (optional) if true, only show windows of the active application (within the scope of the instance windowfilter); otherwise show all windows allowed by the instance windowfilter
---
--- Returns:
---  * None
---
--- Notes:
---  * passing `true` for `activeApplication` will simply hide hints/thumbnails for applications other than the active one, without recalculating the hints layout; conversely, setting `onlyActiveApplication=true` for an expose instance's `ui` will calculate an optimal layout for the current active application's windows
---  * Completing a hint will exit the expose and focus the selected window.
---  * Pressing esc will exit the expose and with no action taken.
---  * If shift is being held when a hint is completed (the background will be red), the selected window will be closed. If it's the last window of an application, the application will be closed.
---  * If alt is being held when a hint is completed (the background will be blue), the selected  window will be minimized (if visible) or unminimized/unhidden (if minimized or hidden).
---
--- Returns:
---  * None
function expose:toggleShow(...)
  if activeInstance then return self:hide() else return self:show(...) end
end
--- hs.expose:hide()
--- Method
--- Hides the expose, if visible, and exits the modal mode
---
--- Parameters:
---  * None
---
--- Returns:
---  * None
---
--- Notes:
---  * Call this function if you need to make sure the modal is exited without waiting for the user to press `esc`
function expose:hide()
  if activeInstance then return exitAll(activeInstance) end
end
--- hs.expose:show([activeApplication])
--- Method
--- Shows an expose-like screen with modal keyboard hints for switching to, closing or minimizing/unminimizing windows.
---
--- Parameters:
---  * activeApplication - (optional) if true, only show windows of the active application (within the
---   scope of the instance windowfilter); otherwise show all windows allowed by the instance windowfilter
---
--- Returns:
---  * None
---
--- Notes:
---  * passing `true` for `activeApplication` will simply hide hints/thumbnails for applications other
---    than the active one, without recalculating the hints layout; conversely, setting `onlyActiveApplication=true`
---    for an expose instance's `ui` will calculate an optimal layout for the current active application's windows
---  * Completing a hint will exit the expose and focus the selected window.
---  * Pressing esc will exit the expose and with no action taken.
---  * If shift is being held when a hint is completed (the background will be red), the selected
---    window will be closed. If it's the last window of an application, the application will be closed.
---  * If alt is being held when a hint is completed (the background will be blue), the selected
---    window will be minimized (if visible) or unminimized/unhidden (if minimized or hidden).

local function getApplicationWindows()
  local a=application.frontmostApplication()
  if not a then log.w('cannot get active application') return end
  local r={}
  for _,w in ipairs(a:allWindows()) do r[w:id()]=w end
  return r
end

function expose:show(currentApp,...)
  if activeInstance then return end
  activeInstance=self
  fnreactivate=function()return self:show(currentApp)end
  return showExpose(self,currentApp and getApplicationWindows() or nil,...)
end

local bgRepeats=0
local function bgFitWindows()
  local rep
  for self in pairs(activeInstances) do
    local DEBUG,DEBUG_TIME=self.ui.DEBUG
    if DEBUG then DEBUG_TIME=timer.secondsSinceEpoch() end
    local iters=self.ui.fitWindowsInBackgroundMaxIterations --3--math.random(9)
    if self.dirty then
      for _,screen in pairs(self.screens) do
        if screen.dirty then fitWindows(self,screen,iters) rep=rep or screen.dirty end
        if activeInstance==self or DEBUG then
          for _,w in pairs(screen.windows) do
            if w.visible then setThumbnail(w,screen.frame,screen.thumbnails,self.ui.showTitles,self.ui,screen.bg) end
          end
        end
      end
    end
    if DEBUG then print(math.floor((timer.secondsSinceEpoch()-DEBUG_TIME)/iters*1000)..'ms per iteration - '..iters..' total') end
  end
  bgRepeats=bgRepeats-1
  if rep and bgRepeats>0 then bgFitTimer:start() end
end

function expose.STOP()
  bgFitTimer:stop()
  for i in pairs(activeInstances) do
    for _,s in pairs(i.screens) do
      for _,w in pairs(s.windows) do
        if w.thumb then w.thumb:hide() w.highlight:hide() end
        if w.titletext then w.titletext:hide() w.titlerect:hide() end
        if w.icon then w.icon:hide() w.hinttext:hide() w.hintrect:hide() end
        --      if w.rect then w.rect:delete() end
        if w.textratio then w.textratio:hide() end

      end
    end
  end
end
local function startBgFitWindows(ui)
  if activeInstance and not ui.fitWindowsInBackground then bgRepeats=2 return bgFitTimer:start(0.05) end
  bgRepeats=ui.fitWindowsInBackgroundMaxRepeats
  if bgRepeats>0 and ui.fitWindowsInBackground then
    bgFitTimer:start()
  end
end

local function windowRejected(self,win,appname,screen)
  local id=win:id()
  local w=self.windows[id]
  if not w then return end
  if screen.windows[id] then
    self.log.vf('window %s (%d) <- %s',appname,id,screen.name)
    screen.totalOriginalArea=screen.totalOriginalArea-w.originalFrame.area
    screen.windows[id]=nil
    screen.dirty=true
    return startBgFitWindows(self.ui)
  end
end

local function windowDestroyed(self,win,appname,screen)
  local id=win:id()
  local w=self.windows[id]
  if not w then return end
  windowRejected(self,win,appname,screen)
  if w.thumb then w.thumb:delete() w.highlight:delete() end
  if w.titletext then w.titletext:delete() w.titlerect:delete() end
  w.hintrect:delete() w.hinttext:delete() w.icon:delete()
  self.windows[id]=nil
  self.dirty=true
end

local function getTitle(self,w)
  local title=w.window:title() or ' '
  w.titleWidth=drawing.getTextDrawingSize(title,self.ui.titleTextStyle).w
  w.titletext:setText(title)
end
local function windowAllowed(self,win,appname,screen)
  --  print('addwindow '..appname..' to '..screen.name)
  local id=win:id()
  local w=self.windows[id]
  if w then
    local prevScreen=w.screen
    w.screen=screen -- set new screen
    windowRejected(self,win,appname,prevScreen) --remove from previous screen
    local cached=w.frames[screen]
    self.log.vf('window %s (%d) -> %s%s',appname,id,screen.name,cached and ' [CACHED]' or '')
    w.frame=geom.copy(cached or w.originalFrame)
    w.dirty=not cached
    screen.windows[id]=w
    screen.totalOriginalArea=screen.totalOriginalArea+w.originalFrame.area
    screen.dirty=screen.dirty or not cached or true
    return startBgFitWindows(self.ui)
  end

  self.log.df('window %s (%d) created',appname,id)
  local ui=self.ui
  local f=win:frame()
  --  if not screen.thumbnails then f.aspect=1 local side=ui.minWidth f.area=side*side end
  local w={window=win,appname=appname,originalFrame=geom.copy(f),frame=f,ratio=1,frames={},id=id,screen=screen}
  if ui.showThumbnails then
    w.thumb=drawing.image(f,window.snapshotForID(id) or UNAVAILABLE):setAlpha(ui.highlightThumbnailAlpha)
      :setBehavior(BEHAVIOR)
    w.highlight=drawing.rectangle(f):setFill(false)
      :setStrokeWidth(ui.highlightThumbnailStrokeWidth):setStrokeColor(ui.highlightColor):setBehavior(BEHAVIOR)
    --      :orderAbove(w.thumb)
  end
  if ui.showTitles then
    w.titlerect=drawing.rectangle(f):setFill(true):setFillColor(ui.highlightColor)
      :setStroke(false):setRoundedRectRadii(ui.textSize/8,ui.textSize/8):setBehavior(BEHAVIOR)
    --      :orderAbove(w.thumb)
    w.titletext=drawing.text(f,' '):setTextStyle(ui.titleTextStyle):setBehavior(BEHAVIOR)--:orderAbove(w.titlerect)
    getTitle(self,w)
  end
  w.hintrect=drawing.rectangle(f):setFill(true):setFillColor(ui.highlightColor)
    :setStroke(true):setStrokeWidth(min(ui.textSize/10,ui.highlightThumbnailStrokeWidth)):setStrokeColor(ui.highlightColor)
    :setRoundedRectRadii(ui.textSize/4,ui.textSize/4):setBehavior(BEHAVIOR)
  --    :orderAbove(w.thumb)
  w.hinttext=drawing.text(f,' '):setTextStyle(ui.hintTextStyle):setBehavior(BEHAVIOR)--:orderAbove(w.hintrect)
  local bid=win:application():bundleID()
  local icon=bid and image.imageFromAppBundle(bid) or UNAVAILABLE
  w.icon=drawing.image(f,icon):setBehavior(BEHAVIOR)--:orderAbove(w.hintrect)
  w.textratio=drawing.text(f,''):setTextColor{red=1,alpha=1,blue=0,green=0}
  w.dirty=true
  screen.totalOriginalArea=screen.totalOriginalArea+f.area
  screen.windows[id]=w
  self.windows[id]=w
  screen.dirty=true
  self.dirty=true
  return startBgFitWindows(ui)
end
local function getSnapshot(w,id)
  if w.thumb then w.thumb:setImage(window.snapshotForID(id) or UNAVAILABLE) end
end
local function windowUnfocused(self,win,appname,screen)
  local id=win:id()
  if screen.windows then
    local w=screen.windows[id]
    if w then return getSnapshot(w,id) end
  end
end
local function windowMoved(self,win,appname,screen)
  local id=win:id() local w=screen.windows[id]
  if not w then return end
  local frame=win:frame()
  w.frame=frame w.originalFrame=frame w.frames={}--[screen]=nil
  screen.dirty=true w.dirty=true
  getSnapshot(w,id)
  return startBgFitWindows(self.ui)
end


local function titleChanged(self,win,appname,screen)
  if not self.ui.showTitles then return end
  local id=win:id() local w=screen.windows[id]
  if w then return getTitle(self,w) end
end

local function resume(self)
  if not activeInstances[self] then self.log.i('instance stopped, ignoring resume') return self end
  -- subscribe
  for _,s in pairs(self.screens) do
    s.callbacks={
      [windowfilter.windowAllowed]=function(win,a)
        self.log.vf('%s: window %s allowed',s.name,a)
        return windowAllowed(self,win,a,s)
      end,
      [windowfilter.windowRejected]=function(win,a)
        self.log.vf('%s: window %s rejected',s.name,a)
        return windowRejected(self,win,a,s)
      end,
      [windowfilter.windowDestroyed]=function(win,a)
        self.log.vf('%s: window %s destroyed',s.name,a)
        return windowDestroyed(self,win,a,s)
      end,
      [windowfilter.windowMoved]=function(win,a)
        self.log.vf('%s: window %s moved',s.name,a)
        return windowMoved(self,win,a,s)
      end,
      [windowfilter.windowUnfocused]=function(win,a)
        return windowUnfocused(self,win,a,s)
      end,
      [windowfilter.windowTitleChanged]=function(win,a)
        return titleChanged(self,win,a,s)
      end,
    }
    s.wf:subscribe(s.callbacks)
    for _,w in ipairs(s.wf:getWindows()) do
      windowAllowed(self,w,w:application():name(),s)
    end
  end
  self.running=true
  self.log.i'instance resumed'
  return self
end

local function pause(self)
  if not activeInstances[self] then self.log.i('instance stopped, ignoring pause') return self end
  -- unsubscribe
  if activeInstance==self then exitAll(self) end
  for _,s in pairs(self.screens) do
    s.wf:unsubscribe(s.callbacks)
  end
  self.running=nil
  self.log.i'instance paused'
  return self
end

local function deleteScreens(self)
  for id,s in pairs(self.screens) do
    s.wf:delete() -- remove previous wfilters
    s.bg:delete()
  end
  self.screens={}
  for id,w in pairs(self.windows) do
    if w.thumb then w.thumb:delete() w.highlight:delete() end
    if w.titletext then w.titletext:delete() w.titlerect:delete() end
    w.hintrect:delete() w.hinttext:delete() w.icon:delete()
  end
  self.windows={}
end

local function makeScreens(self)
  self.log.i'populating screens'
  local wfLogLevel=windowfilter.getLogLevel()
  deleteScreens(self)
  windowfilter.setLogLevel('warning')
  -- gather screens
  local activeApplication=self.ui.onlyActiveApplication and true or nil
  local hsscreens=screen.allScreens()
  local screens={}
  for _,scr in ipairs(hsscreens) do -- populate current screens
    local sid,sname,sframe=scr:id(),scr:name(),scr:frame()
    if sid and sname then
      local wf=windowfilter.copy(self.wf,'wf-'..self.__name..'-'..sid):setDefaultFilter{}
        :setOverrideFilter{visible=true,currentSpace=true,allowScreens=sid,activeApplication=activeApplication}:keepActive()
      screens[sid]={name=sname,wf=wf,windows={},frame=sframe,totalOriginalArea=0,thumbnails=self.ui.showThumbnails,edge=geom.new(0,0),
        bg=drawing.rectangle(sframe):setFill(true):setFillColor(self.ui.backgroundColor):setBehavior(BEHAVIOR)}
      self.log.df('screen %s',scr:name())
    end
  end
  if not next(screens) then self.log.w'no valid screens found' windowfilter.setLogLevel(wfLogLevel) return end
  if self.ui.includeNonVisible then
    do -- hidden windows strip
      local msid=hsscreens[1]:id()
      local f=screens[msid].frame
      local pos=self.ui.nonVisibleStripPosition
      local width=self.ui.nonVisibleStripWidth
      local swidth=f[(pos=='left' or pos=='right') and 'w' or 'h']
      if width<1 then width=swidth*width end
      local thumbnails=self.ui.showThumbnails and width/swidth>=0.1
      local invf,edge=geom.copy(f),geom.new(0,0)
      --    local dock = execute'defaults read com.apple.dock "orientation"':sub(1,-2)
      -- calling execute takes 100ms every time, make this a ui preference instead
      if pos=='left' then f.w=f.w-width f.x=f.x+width invf.w=width edge:move(-200,0)
      elseif pos=='right' then f.w=f.w-width invf.x=f.x+f.w invf.w=width edge:move(200,0)
      else pos='bottom' f.h=f.h-width invf.y=f.y+f.h invf.h=width edge:move(0,200) end --bottom
      local wf=windowfilter.copy(self.wf,'wf-'..self.__name..'-invisible'):setDefaultFilter{}
        :setOverrideFilter{visible=false,activeApplication=activeApplication}:keepActive()
      screens.inv={name='invisibleWindows',isStrip=true,wf=wf,windows={},totalOriginalArea=0,frame=invf,thumbnails=thumbnails,edge=edge,pos=pos,
        bg=drawing.rectangle(invf):setFill(true):setFillColor(self.ui.nonVisibleStripBackgroundColor):setBehavior(BEHAVIOR)}
      screens[msid].bg:setFrame(f)
      self.log.d'invisible windows'
    end
  end
  if self.ui.includeOtherSpaces then
    local oscreens={}
    for sid,screen in pairs(screens) do -- other spaces strip
      if not screen.isStrip then
        local f=screen.frame
        local othf,edge=geom.copy(f),geom.new(0,0)
        local pos=self.ui.otherSpacesStripPosition
        local width=self.ui.otherSpacesStripWidth
        local fwidth=f[(pos=='left' or pos=='right') and 'w' or 'h']
        if width<1 then width=fwidth*width end
        local thumbnails=self.ui.showThumbnails and width/fwidth>=0.1
        if pos=='left' then f.w=f.w-width f.x=f.x+width othf.w=width edge:move(-200,0)
        elseif pos=='right' then f.w=f.w-width othf.x=f.x+f.w othf.w=width edge:move(200,0)
        elseif pos=='bottom' then f.h=f.h-width othf.y=f.y+f.h othf.h=width edge:move(0,200)
        else pos='top' f.h=f.h-width othf.y=f.y othf.h=width f.y=f.y+width edge:move(0,-200) end -- top
        local wf=windowfilter.copy(self.wf,'wf-'..self.__name..'-o'..sid):setDefaultFilter{}
          :setOverrideFilter{visible=true,currentSpace=false,allowScreens=sid,activeApplication=activeApplication}:keepActive()
        local name='other/'..screen.name
        oscreens['o'..sid]={name=name,isStrip=true,wf=wf,windows={},totalOriginalArea=0,frame=othf,thumbnails=thumbnails,edge=edge,pos=pos,
          bg=drawing.rectangle(othf):setFill(true):setFillColor(self.ui.otherSpacesStripBackgroundColor):setBehavior(BEHAVIOR)}
        screen.bg:setFrame(f)
        self.log.df('screen %s',name)
      end
    end
    for sid,scr in pairs(oscreens) do screens[sid]=scr end
  end
  for _,screen in pairs(screens) do
    screen.frame:move(10,10):setw(screen.frame.w-20):seth(screen.frame.h-20) -- margin
  end
  self.screens=screens
  windowfilter.setLogLevel(wfLogLevel)
end
local function processScreensChanged()
  for self in pairs(activeInstances) do makeScreens(self) end
  for self in pairs(activeInstances) do resume(self) end
end

expose.screensChangedDelay=10
local function screensChanged()
  log.d('screens changed, pausing active instances')
  for self in pairs(activeInstances) do pause(self) end
  screensChangedTimer:start()
end


local function start(self)
  if activeInstances[self] then self.log.i('instance already started, ignoring') return self end
  activeInstances[self]=true
  if not screenWatcher then
    log.i('starting global watchers')
    screenWatcher=screen.watcher.new(screensChanged):start()
    screensChangedTimer=timer.delayed.new(expose.screensChangedDelay,processScreensChanged)
    spacesWatcher=spaces.watcher.new(spaceChanged):start()
    bgFitTimer=timer.delayed.new(BG_FIT_INTERVAL,bgFitWindows)
  end
  self.log.i'instance started'
  makeScreens(self)
  return resume(self)
end

local function stop(self)
  if not activeInstances[self] then self.log.i('instance already stopped, ignoring') return self end
  pause(self)
  deleteScreens(self)
  activeInstances[self]=nil
  self.log.i'instance stopped'
  if not next(activeInstances) then
    if screenWatcher then
      log.i('stopping global watchers')
      screenWatcher:stop() screenWatcher=nil
      screensChangedTimer:stop() screensChangedTimer=nil
      spacesWatcher:stop() spacesWatcher=nil
      bgFitTimer:stop() bgFitTimer=nil
    end
  end
  return self
end

function expose.stop(self)
  if self then return stop(self) end
  for i in pairs(activeInstances) do
    stop(i)
  end
end
-- return onScreenWindows, invisibleWindows, otherSpacesWindows

local inUiPrefs -- avoid recursion
local function setUiPrefs(self)
  inUiPrefs=true
  local ui=self.ui
  ui.hintTextStyle={font=ui.fontName,size=ui.textSize,color=ui.textColor}
  ui.titleTextStyle={font=ui.fontName,size=max(10,ui.textSize/2),color=ui.textColor,lineBreak='truncateTail'}
  ui.hintHeight=drawing.getTextDrawingSize('O',ui.hintTextStyle).h
  ui.titleHeight=drawing.getTextDrawingSize('O',ui.titleTextStyle).h
  local hintWidth=drawing.getTextDrawingSize(ssub('MMMMMMM',1,ui.maxHintLetters+1),ui.hintTextStyle).w
  ui.minWidth=hintWidth+ui.hintHeight*1.4--+padding*4
  ui.minHeight=ui.hintHeight*2
  --  ui.noThumbsFrameSide=ui.minWidth-- ui.textSize*4
  inUiPrefs=nil
end

--- hs.expose.new([windowfilter[, uiPrefs][, logname, [loglevel]]]) -> hs.expose object
--- Constructor
--- Creates a new hs.expose instance; it can use a windowfilter to determine which windows to show
---
--- Parameters:
---  * windowfilter - (optional) if omitted or nil, use the default windowfilter; otherwise it must be a windowfilter
---    instance or constructor table
---  * uiPrefs - (optional) a table to override UI preferences for this instance; its keys and values
---    must follow the conventions described in `hs.expose.ui`; this parameter allows you to have multiple
---    expose instances with different behaviour (for example, with and without thumbnails and/or titles)
---    using different hotkeys
---  * logname - (optional) name of the `hs.logger` instance for the new expose; if omitted, the class logger will be used
---  * loglevel - (optional) log level for the `hs.logger` instance for the new expose
---
--- Returns:
---  * the new instance
---
--- Notes:
---   * by default expose will show invisible windows and (unlike the OSX expose) windows from other spaces; use
---     `hs.expose.ui` or the `uiPrefs` parameter to change these behaviours.
function expose.new(wf,uiPrefs,logname,loglevel)
  if type(uiPrefs)=='string' then loglevel=logname logname=uiPrefs uiPrefs={} end
  if uiPrefs==nil then uiPrefs={} end
  if type(uiPrefs)~='table' then error('uiPrefs must be a table',2) end
  local self = setmetatable({screens={},windows={},__name=logname or 'expose'},{__index=expose,__gc=stop})
  self.log=logname and logger.new(logname,loglevel) or log
  self.setLogLevel=self.log.setLogLevel self.getLogLevel=self.log.getLogLevel
  if wf==nil then self.log.i('new expose instance, using default windowfilter') wf=windowfilter.default
  else self.log.i('new expose instance using windowfilter instance') wf=windowfilter.new(wf) end
  --uiPrefs
  self.ui=setmetatable({},{
    __newindex=function(t,k,v)rawset(self.ui,k,getColor(v))if not inUiPrefs then return setUiPrefs(self)end end,
    __index=function(t,k)return getColor(uiGlobal[k]) end,
  })
  for k,v in pairs(uiPrefs) do rawset(self.ui,k,getColor(v)) end setUiPrefs(self)
  --  local wfLogLevel=windowfilter.getLogLevel()
  --  windowfilter.setLogLevel('warning')
  --  self.wf=windowfilter.copy(wf):setDefaultFilter{} -- all windows; include fullscreen and invisible even for default wf
  --  windowfilter.setLogLevel(wfLogLevel)
  self.wf=wf
  return start(self)
end

return expose
