RecentScreens

Artifact [14499f4ca7]
Login

Artifact 14499f4ca708f5a9a06861fd234727288de3af53f89d2f630a7151890af06005:


local Info = package.loaded.regscript or function(...) return ... end
local nfo = Info {_filename or ...,
  name        = "RecentScreens";
  --переключение между окнами как в MS Windows™
  description = "switch screens in MRU-order";
  version     = "3.11.1"; --http://semver.org/lang/ru/
  author      = "jd";
  url         = "http://forum.farmanager.com/viewtopic.php?f=60&t=7876";
  id          = "789DF383-23EE-4CF2-B274-7F3D387AEB98";
  minfarversion = {3,0,0,5547,0}; --DN_LISTCHANGE
  options     = {
    macroKey = "Tab",        -- Ctrl-Tab / CtrlShift-Tab
    --macroKey = "`",        -- Ctrl-~ / CtrlShift~
    --macroKey = "CapsLock", -- Ctrl-CapsLock / CtrlShift-CapsLock

    -- default settings:
    listDelay = 200,         -- * delay before RecentScreens list show
                             --   0 means no delay
                             --   <no list> means no list
    listInstant = true,      -- * in list:
                             --   true  - switch screen on every Ctrl+Tab
                             --   false - switch only after Ctrl release

    -- other options
    fixAltDigitMacro = true,
    hideDesktop = true,
    debug = true,
  };
  help        = function(self) far.Message(self.helpstr,self.name.." v"..self.version,nil,"kl") end;
  helpstr     = [[
Параметры, управляющие переключением,
можно изменить с помощью горячих клавиш
на цифровой клавиатуре:

[/] Instant - определяет момент переключения
- true  - сразу, при каждом нажатии Ctrl+Tab
- false - после отпускания Ctrl

[*] Delay - задержка появления списка экранов
Плавно изменить: [+/-]
/* при <no list> список будет доступен по Ctrl+F12 */]];
--todo full help
--CtrlBS
--CtrlBackSlash
--Ctrl=
--CtrlEnter
--Enter/Esc/Break
--CtrlF8

  --disabled    = false;
}

if not nfo or nfo.disabled then return end
local O = nfo.options

local F = far.Flags

------------------------------------------------
--Settings
local _KEY,_NAME = "JD",nfo.name

local S -- current settings

local function defaultSettings()
  return {
    listDelay = O.listDelay,
    listInstant = O.listInstant,
  }
end

local function resetSettings()
  if far.Message("\nDelete saved settings?\n",nfo.name,";OkCancel") then
    mf.mdelete(_KEY,_NAME)
    S = defaultSettings()
  end
end

Event { description="[RecentScreens] Save settings (on exit)";
  id="3B6F9E40-C470-4194-9DD3-4A76206ED8C8";
  group="ExitFAR";
  action=function() mf.msave(_KEY,_NAME,S) end;
}

S = mf.mload(_KEY,_NAME) --initial settings load
if not S or type(S)~="table" or type(S.listDelay)~="number" then
  S = defaultSettings()
end
-------------------------------------------------------------
--- some general definitions
local function aKey() return mf.akey(1,1) end
local function Warning(msg)
  far.Message(msg,"Warning","","w"); far.Text(); win.Sleep(500)
end

local Lock = {Num=0, Caps=1, Scroll=2}
local modeIsOn,keyIsPressed = 1,0xff80
local function LockState(xLock,Action) --set State: 0|1|2=toggle
                                       --get State: nil|-1
  local Lkey = assert(Lock[xLock])
  local State = mf.flock(Lkey,-1)
  local mode, hold = band(State,modeIsOn)==modeIsOn,
                     band(State,keyIsPressed)==keyIsPressed
  if Action==2 or Action~=-1 and (mode==(Action==0)) then
    if hold then mf.flock(Lkey,Action) end --http://forum.farmanager.com/viewtopic.php?p=139236#p139236
    mf.flock(Lkey,Action)
  end
  return mode,hold
end

local _mod
-- can be rewrited using win.ExtractKeyEx and DN_INPUT (+DM_SETINPUTNOTIFY)
--                                            EventType==KEY_EVENT / Param2.ControlKeyState
local mod_mask = bor(F.RIGHT_CTRL_PRESSED,F.LEFT_CTRL_PRESSED)
local function ModReleased(set)
  if set then
    _mod = band(mod_mask,Mouse.LastCtrlState) -- state updates only after mf.waitkey / or in dialog event loop
  elseif set==false then
    _mod = false
  else
    return _mod and band(mod_mask,Mouse.LastCtrlState,_mod)~=_mod
  end
end

local actl,          GetWindowCount,       GetWindowInfo,       SetCurrentWindow
    = far.AdvControl,F.ACTL_GETWINDOWCOUNT,F.ACTL_GETWINDOWINFO,F.ACTL_SETCURRENTWINDOW

local En = {
  [F.WTYPE_PANELS]="Panels",[F.WTYPE_VIEWER]="View",[F.WTYPE_EDITOR]="Edit",
  [F.WTYPE_DIALOG]="Dialog",[F.WTYPE_DESKTOP]=not O.hideDesktop and "Desktop",
}
local function Uid(w) --skip unsupported screen types
  return En[w.Type]
     and (w.Type~=F.WTYPE_DIALOG or band(w.Flags,F.WIF_MODAL)==0) --no modal dialogs
     and En[w.Type]..tostring(w.Id)
end

--------------------------------------------------------
local Screens = { --array of windows' uid's in MRU order
  --[1]="Desktop0",
  --[2]="Panels0",
  --[3]="Edit5",
  uids = {
    --["Desktop0"]=true,
    --["Panels0"]=true,
    --["Edit5"]=true,
  },
  --stateFreezeOrder=false,
}

function Screens:exist(uid)
  return self.uids[uid]
end

function Screens:add(uid)
  table.insert(self,uid)
  self.uids[uid] = true
end

function Screens:Remove(uid,nonstrict)
  if self:exist(uid) then
    for i=1,#self do
      if self[i]==uid then
        table.remove(self,i)
        self.uids[uid] = nil
        return i
      end
    end
  elseif nonstrict then --or self.stateFreezeOrder
    return
  end
  if O.debug then Warning("uid do not exist: "..tostring(uid)) end
end

function Screens:Delta(d) return self[#self-d] end

function Screens:Focus(uid) -- move to top
  if O.debug then
    --win.OutputDebugString(table.concat(Screens,"|").."=>"..uid)
  end
  if self[#self]~=uid then
    self:Remove(uid,"if any")
    self:add(uid)
    return true
  end
end

function Screens.it_windows() --iterate over all screens / ret: i,uid,w
  return function(n,i)
    while(i<n) do
      i = i+1
      local w = actl(GetWindowInfo,i)
      local uid = Uid(w)
      if uid then return i,uid,w end
    end
  end,actl(GetWindowCount),0
end

function Screens:Goto(_uid) -- switch screen
  for _,uid,w in self:it_windows() do
    if uid==_uid then
      if band(w.Flags,F.WIF_CURRENT)==0 then
        actl(SetCurrentWindow,w.Pos)
      end
      return true
    end
  end
  --
  if O.debug then Warning("cannot find window "..tostring(_uid)) end
  self:Remove(_uid)
end

--[[
function:Screens:Commit()
  --for i=1,#self do self:Goto(self[i]) end
  local windows = {}; for _,uid,w in self:it_windows() do windows[uid] = w end -- helper table (windows by uid)
  local pos,n = {},#self; for i=1,n do pos[i] = windows[Screens[i] ].Pos end
  for i=1,n do
    local Pos = pos[i]
    actl(SetCurrentWindow,Pos)
    for j=i+1,n do
      if pos[j]>Pos then pos[j] = pos[j]-1
    end
  end
end
--]]

-- limitations: https://forum.farmanager.com/viewtopic.php?p=123663#p123663
--              CtrlTab usually is served by Conemu itself
--              so we are unable to properly handle Panels/Desktop screens
--              thus MRU order will be incorrect
-- nevertheless, someone still wants it in conemu:
-- https://forum.farmanager.com/viewtopic.php?p=124349#p124349
-- so let them
-- but it's still better to disable some functions (MRUswithOnClose).. --todo

--[[
local function Conemu(param)
  return Plugin.SyncCall("4B675D80-1D4A-4EA9-8436-FDC23F2FC14B",param)
end

--if win.GetEnv"ConEmuDir"
--if Conemu("IsConEmu")
--and O.macroKey=="Tab"

--!!check attach/detach
if akey():sub(-3)=="Tab" and Conemu("GetOption TabSelf")~="0" then
  --
end
--]]

for _,uid in Screens:it_windows() do Screens:add(uid) end -- init Screens

---------------------------------------------------------------
-- Helper event-handlers
Event{ description="[RecentScreens] editor handler"; group="EditorEvent";
  id="39C42CCD-E4E1-4165-AAAF-FD81D9C54E37";
  action=function(id,Event)
    if Event==F.EE_CLOSE then
      local uid = "Edit"..id
      Screens:Remove(uid)
    elseif Event==F.EE_GOTFOCUS and not Screens.stateFreezeOrder then
      local uid = "Edit"..id
      Screens:Focus(uid)
    end
  end;
}
Event{ description="[RecentScreens] viewer handler"; group="ViewerEvent";
  id="267160C4-7C26-4883-A126-D4EB081C254D";
  action=function(id,Event)
    if Event==F.VE_CLOSE then
      local uid = "View"..id
      Screens:Remove(uid,"skip QView,Info")
    elseif Event==F.VE_GOTFOCUS and not Screens.stateFreezeOrder then
      local uid = "View"..id
      Screens:Focus(uid)
    end
  end;
}
Event{ description="[RecentScreens] dialog handler"; group="DialogEvent";
  id="2FE435A8-52B6-47F6-8823-9F4D064B1DBA";
  action=function(Event,param)
    if Event~=F.DE_DLGPROCEND then return end
    local Msg,hDlg,Param1 = param.Msg,param.hDlg,param.Param1
    if Msg==F.DN_CLOSE then
      local uid = "Dialog"..tostring(hDlg)
      Screens:Remove(uid,"skip modals")
    elseif Msg==F.DN_GOTFOCUS and Param1==-1 --nonmodals only
           and not Screens.stateFreezeOrder then
      local uid = "Dialog"..tostring(hDlg)
      Screens:Focus(uid)
    end
  end;
}

-----------------------------------------------------------
-- RecentScreens menu logic helpers (see showRecentScreens)
local RSid = "92B73618-3C29-401C-BE62-B0406513CB82" -- RecentScreens menu id
local function isRecentScreensMenu() return Menu.Id==RSid end; --DM_GETDIALOGINFO
local STEP = 50 --ms
local NOLIST = -STEP
local function needSwitch() return S.listInstant or S.listDelay==NOLIST end

--!!2do reimplement menu via dialog
Event{ description="[RecentScreens] internal handler"; group="DialogEvent";
  id="15FF5001-632B-4FB8-8A62-A0F629791369";
  condition=function(Event) return Event==F.DE_DLGPROCINIT end;
  action=function(_,Param)
    local Msg,hDlg,Param2 = Param.Msg,Param.hDlg,Param.Param2
    if Msg==F.DN_INPUT then -- close on mod release
      if Param2.KeyDown==false and isRecentScreensMenu() and ModReleased() then
        --hDlg:send(F.DM_KEY,nil,"Enter")
        hDlg:send(F.DM_CLOSE)
      end
    elseif Msg==F.DN_LISTCHANGE then -- auto-switching
      if needSwitch() and isRecentScreensMenu() then
        mf.postmacro(Keys,"CtrlEnter")   -- send BreakKey /close, switch window, and reopen/
      end
    end
  end;
}

------------------------------------------------
-- RecentScreens-menu macros
Macro { description="[RecentScreens] Prev/Next";
  area="Menu"; key="/^[LR]Ctrl(Shift)?(CapsLock|Tab|`)$/";
  id="650C5D50-2750-4814-A12A-E7E749F4E322";
  condition=isRecentScreensMenu;
  action=function()
    Keys(aKey():find"Shift" and "Up" or "Down")
  end;
}

local NavKeys = "Up|Down|PgUp|PgDn|Num9|Num3|Home|End|Num7|Num1"
Macro { description="[RecentScreens] Prev/Next (additional)";
  area="Menu"; key="/^[LR]Ctrl("..NavKeys..")|"..NavKeys.."$/";
  id="F7ABABFA-A70D-4A26-A725-632040747F0A";
  condition=isRecentScreensMenu;
  action=function()
    Keys(aKey():match"^R?Ctrl(.+)$" or "AKey")
  end;
}

Macro { description="[RecentScreens] Scroll long titles";
  area="Menu"; key="CtrlRight CtrlLeft";
  id="BCD83367-88D6-4CAF-BF33-C2FF176D09AB";
  condition=isRecentScreensMenu;
  action=function() Keys("Alt"..aKey():match"Ctrl(.+)") end;
}

Macro { description="[RecentScreens] Goto [0-9A-Z]";
  area="Menu"; key="/^[RL]Ctrl\\w$/";
  id="A1428D9E-C5A0-46A8-8A1B-F5B0A2407FE1";
  condition=isRecentScreensMenu;
  action=function() Keys(aKey():sub(-1)) end;
}

-------------------------------------------
-- RecentScreens menu itself
local function getPanelInfo(name,typename)
  local p = panel.GetPanelInfo(nil,1)
  local ptype = p.PanelType
  if ptype==F.PTYPE_FILEPANEL then
    --https://forum.farmanager.com/viewtopic.php?f=8&t=11290
    --unfortunately there is no strict order in plugins different plugins' info
    --so to construct panel title we have to use heavy smart-ass euristics here(
    name = name:match".*\\"
    if band(p.Flags,F.PFLAGS_PLUGIN)~=0 then
      --??2do: name from plugin panel
      --??todo APanel.UNCPath
      --??Object.Title
      typename = "[plugin]"
      local prefix = panel.GetPanelPrefix(nil,1) or ""
      if prefix:len()>0 then
        prefix = prefix:match"[^:]*"..":"
        typename = prefix
      end
      local host = panel.GetPanelHostFile(nil,1) or ""
      if host:len()>0 then
        --??2do: truncate long host path, add plugin path
        name = host
      else
        local format = panel.GetPanelFormat(nil,1) or ""
        if format:len()>0 then
          name = format.." - "..name
        end
      end
    end
  else
    typename = ptype==F.PTYPE_INFOPANEL and "Info"
            or ptype==F.PTYPE_QVIEWPANEL and "QView"
            or ptype==F.PTYPE_TREEPANEL and "Tree"
    --todo QView
    name = APanel.Path --https://bugs.farmanager.com/view.php?id=3639
  end
  return name,typename,band(p.Flags,F.PFLAGS_VISIBLE)==0 and "-"
                    or band(p.Flags,F.PFLAGS_PANELLEFT)~=0 and "<" or ">"
end

local function prepItem(w,i,modal)
  local name,typename = w.Name,w.TypeName
  local current = band(w.Flags,F.WIF_CURRENT)~=0
  local mark,checked,key
  -- =≡■
  if w.Type==F.WTYPE_PANELS then
    checked,key = "≡","BackSlash"
    name,typename,mark = getPanelInfo(name,typename)
  elseif w.Type==F.WTYPE_DESKTOP then
    checked = ""
  elseif w.Type==F.WTYPE_EDITOR then
    mark = band(w.Flags,F.WIF_MODIFIED)~=0 and "*"
    local title = editor.GetTitle(w.Id)               --Показывать заголовки редакторов в меню Screens вместо путей
    if title~=name then name = title.." - "..name end --https://bugs.farmanager.com/view.php?id=3316
  end
  --local hk = w.Pos-1 --"Pos" used to be same as Screens #hotkey
                       -- not anymore http://forum.farmanager.com/viewtopic.php?f=54&t=8883&p=125016#p125016
  local hk = i
  hk = hk<=9 and hk or hk<=35 and string.char(("A"):byte()+hk-10)
  hk = hk and "&"..hk.."." or ""
  return {
    text = ("%2s %-8s %1s %s"):format(hk,typename,mark or "",name),
    checked=band(w.Flags,F.WIF_MODAL)~=0 and "!" or checked,
    disable=modal and not current,
    selected=current,
    AccelKey=key, --https://bugs.farmanager.com/view.php?id=3471
  }
end

local BreakKeys = {
  {BreakKey="CtrlEnter"},  --no action, need for menu close/reopen and window auto-switching
  {BreakKey="CtrlDivide",  action=function() S.listInstant = not S.listInstant; end},
  {BreakKey="CtrlAdd",     action=function() S.listDelay = S.listDelay<0 and 0 or S.listDelay+STEP; end},
  {BreakKey="CtrlSubtract",action=function() S.listDelay = S.listDelay<0 and NOLIST or S.listDelay-STEP; end},
  {BreakKey="CtrlMultiply",action=function() --cycle through listDelay values
     S.listDelay = S.listDelay==NOLIST and 0
                or S.listDelay==0      and O.listDelay --default
                or S.listDelay>0       and NOLIST
   end},
  {BreakKey="F1",          action=function() nfo:help() end},
}

local CommonKeys = { --!!to doc
  CtrlF8 = resetSettings;
  CtrlF1 = function(self) nfo:help(); self.br = ModReleased() end;
  CtrlBS = function() return Screens:Delta(0) end;
  CtrlO  = not O.hideDesktop
       and function() return "Desktop0" end or nil;
  CtrlBackSlash = panel.CheckPanelsExist()
       and function() return "Panels0" end  or nil;
}
for k,v in pairs(CommonKeys) do table.insert(BreakKeys,{BreakKey=k,action=v}) end

local Props = {Title="RecentScreens",Id=win.Uuid(RSid),Flags=F.FMENU_WRAPMODE}
local Bottom = ("[F1] Help  [/] Instant: %-5s  [*/+/-] Delay: %9s")

local function _shift(key)
  return key:find("Down$") and -1
      or key:find("^R?CtrlShift") and -1
      or key:find("Up$") and 1
      or key:find("^R?Ctrl") and 1
      or 0
end
local function _align(pos)
  return pos>#Screens and 1 or pos<1 and #Screens or pos
end

local function RS_menu(key,is_modal) --switch far screens (shows list)
  --??idea: show all windows even if nouid
  local windows = {} -- helper table (windows by uid)
  for _,uid,w in Screens:it_windows() do windows[uid] = w end
  local Items = {}
  for i=#Screens,1,-1 do
    table.insert(Items,prepItem(windows[Screens[i]],i,is_modal))
  end
  local pos = not is_modal and key and _align(1+_shift(key))
  local uid
  repeat
    if needSwitch() and not is_modal and pos then
      Screens:Goto(Screens:Delta(pos-1))
    end
    Props.Bottom = Bottom:format(
      tostring(S.listInstant),
      S.listDelay==NOLIST and "<no list>" or S.listDelay.." ms"
    )
    Props.SelectIndex = pos
    local item
    item,pos = far.Menu(Props,Items,BreakKeys)
    if item and item.action then --if BreakKey
      uid = item:action()
    end
  until not item      -- menu cancelled (Esc, CtrlBreak ets) / pos==nil
         or item.text -- item selected (Enter pressed etc)   / item==Items[pos]
         or item.br   -- BrK: currently active item will be selected
         or uid and not is_modal -- BrK
  if not is_modal then
    return uid
        or Screens:Delta((pos or 1)-1) -- no pos if dialog canceled (Ctrl-Break, Esc, ...)
                                       -- !!pos==0 if all items disabled
  end
end

---------------------------
-- switch without menu
local time
local function timeout(new)
  if S.listDelay==NOLIST then return end --no-menu mode does not need timeout
  local uptime = Far.UpTime
  if not new then return uptime-time>S.listDelay end
  time = uptime + S.listDelay
end

local function RS_nomenu(key) --switch far screens (no list)
  timeout"new"
  local Pos,uid = #Screens
  mmode(1,0) --enable output
  repeat -- NB: new loop every 50 ms
    if key=="" and timeout() or key=="CtrlF12" then
      return
    elseif key~="" then
      if CommonKeys[key] then
        uid = CommonKeys[key]{}
      else
        Pos = _align(Pos-_shift(key))
        Screens:Goto(Screens[Pos])
      end
      timeout"new"
    end
    key = uid or mf.waitkey(50)
  until   uid or ModReleased()
  return  uid or Screens[Pos]
end

----------------------------------------
-- main
local function need_manual_tracking(w)
  return w.Type~=F.WTYPE_DIALOG and w.Type~=F.WTYPE_EDITOR and w.Type~=F.WTYPE_VIEWER
end
local function RecentScreens(mode)
  local nomenu,key
  if mode then
    key = aKey()
    nomenu = mode=="switch w/o menu"
  end
  local w = assert(actl(GetWindowInfo))
  local uid = Uid(w)
  if uid and need_manual_tracking(w) then
    Screens:Focus(uid)
  end
  local is_modal = band(w.Flags,F.WIF_MODAL)~=0 or not uid
  ModReleased(key and "set current" or false)
  Screens.stateFreezeOrder = true
  local target_uid
  if not is_modal and nomenu and S.listInstant and S.listDelay~=0 and #Screens~=1 then
    target_uid = RS_nomenu(key,is_modal)
    key = false
  end
  if not target_uid then
    target_uid = RS_menu(key,is_modal)
  end
  if target_uid then
    Screens:Focus(target_uid)
    Screens:Goto(target_uid)
  end
  Screens.stateFreezeOrder = false
end
nfo.config      = function() RecentScreens() end;

---------------------------------------------
-- Main macro
local allScreens = "Shell Info QView Tree Editor Viewer Dialog "
if not O.hideDesktop then allScreens = allScreens.."Desktop " end

local function has_uid()
  return Uid(assert(actl(GetWindowInfo)))
end
if O.macroKey then
Macro { description="[RecentScreens] switch";
  area=allScreens; key="/^LCtrl(Shift)?("..O.macroKey..")$/";
  id="792800D3-DD8A-4F5D-A4A8-1004E6AFBE7E";
  condition=has_uid;
  action=function()
    RecentScreens"switch w/o menu"
    if O.macroKey=="CapsLock" then LockState("Caps",0) end
  end;
}
end

NoMacro { description="[RecentScreens] switch & show list";--https://forum.farmanager.com/viewtopic.php?p=121183#p121183
  area=allScreens; key="/^LCtrl(Shift)?CapsLock$/";
  id="3DC8C55E-376D-4ED5-8C42-FF3E30F0AD68";
  condition=has_uid;
  action=function()
    RecentScreens"switch"
    LockState("Caps",0)
  end;
}

Macro { description="[RecentScreens] show list";
  area="Common"; key="CtrlShiftF12";
  id="40CA4C44-03C3-426A-8D25-0F882E7A50E5";
  condition=function() return not isRecentScreensMenu() end;
  action=function()
    RecentScreens()
  end;
}

MenuItem{ --!!test
  menu= "Plugins Disks Config"; area="Common";
  description=nfo.description;
  text=function() return not isRecentScreensMenu() and nfo.name end;
  guid="5019E6EC-1CD0-4C1F-91D7-DB505DC60ED6";
  action=function() RecentScreens() end;
}

if O.fixAltDigitMacro then
Macro { description="[RecentScreens] fix MRU for Addons/Macros/AltScreens.lua";
  area=allScreens.."Desktop "; key="/[LR]Alt[0-9]/";
  id="67124A52-B6F8-448D-BF9D-846423785181";
  condition=function()
    local uid = Uid(assert(actl(GetWindowInfo)))
    if uid then Screens:Focus(uid) end
  end;
  action=function()end;
}
end
--todo check Screens:Commit
--todo: сделать лайт-версию (после 4138 известен z-order, что позволяет не хранить порядок)