From 03fd32f9fa27d9f54a0adb5dd51df149f74489cc Mon Sep 17 00:00:00 2001 From: Tristan Hume Date: Tue, 26 May 2015 20:50:32 -0400 Subject: [PATCH] Add tabs module --- extensions/tabs/LICENSE | 21 +++++ extensions/tabs/init.lua | 196 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 extensions/tabs/LICENSE create mode 100644 extensions/tabs/init.lua diff --git a/extensions/tabs/LICENSE b/extensions/tabs/LICENSE new file mode 100644 index 000000000..c7660743f --- /dev/null +++ b/extensions/tabs/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Tristan Hume + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/extensions/tabs/init.lua b/extensions/tabs/init.lua new file mode 100644 index 000000000..f158f608c --- /dev/null +++ b/extensions/tabs/init.lua @@ -0,0 +1,196 @@ +--- === hs.tabs === +--- +--- Tab the windows of any application. Draws tabs in the title bar. +local tabs = {} + +local drawing = require "hs.drawing" +local uielement = require "hs.uielement" +local watcher = uielement.watcher +local fnutils = require "hs.fnutils" +local timer = require "hs.timer" +local application = require "hs.application" +local appwatcher = application.watcher +local appfinder = require "hs.appfinder" + +tabs.leftPad = 10 +tabs.topPad = 2 +tabs.tabPad = 2 +tabs.tabWidth = 80 +tabs.tabHeight = 17 +tabs.tabRound = 4 +tabs.textLeftPad = 2 +tabs.textTopPad = 2 +tabs.textSize = 10 +tabs.fillColor = {red = 1.0, green = 1.0, blue = 1.0, alpha = 0.5} +tabs.selectedColor = {red = .9, green = .9, blue = .9, alpha = 0.5} +tabs.strokeColor = {red = 0.0, green = 0.0, blue = 0.0, alpha = 0.7} +tabs.textColor = {red = 0.0, green = 0.0, blue = 0.0, alpha = 0.6} +tabs.maxTitle = 11 + +local function realWindow(win) + -- AXScrollArea is weird role of special finder desktop window + return (win:isStandard() and win:role() ~= "AXScrollArea") +end + +--- hs.tabs.tabWindows(app) +--- Function +--- Gets a list of the tabs of a window. Use to write switching functions. +--- +--- Parameters: +--- * An application object +--- +--- Returns: +--- * Array of the tabbed windows of an app in the same order as they would be tabbed +function tabs.tabWindows(app) + local tabWins = fnutils.filter(app:allWindows(),realWindow) + table.sort(tabWins, function(a,b) return a:title() < b:title() end) + return tabWins +end + +local drawTable = {} +local function trashTabs(pid) + local tab = drawTable[pid] + if not tab then return end + for i,obj in ipairs(tab) do + obj:delete() + end +end + +local function drawTabs(app) + local pid = app:pid() + trashTabs(pid) + drawTable[pid] = {} + local proto = app:focusedWindow() + if not proto or not app:isFrontmost() then return end + local geom = app:focusedWindow():frame() + + local tabWins = tabs.tabWindows(app) + local pt = {x = geom.x+geom.w-tabs.leftPad, y = geom.y+tabs.topPad} + local objs = drawTable[pid] + -- iterate in reverse order because we draw right to left + local numTabs = #tabWins + for i=0,(numTabs-1) do + local win = tabWins[numTabs-i] + pt.x = pt.x - tabs.tabWidth - tabs.tabPad + local r = drawing.rectangle({x=pt.x,y=pt.y,w=tabs.tabWidth,h=tabs.tabHeight}) + r:setFill(true) + if win == proto then + r:setFillColor(tabs.selectedColor) + else + r:setFillColor(tabs.fillColor) + end + r:setStrokeColor(tabs.strokeColor) + r:setRoundedRectRadii(tabs.tabRound,tabs.tabRound) + r:bringToFront() + r:show() + table.insert(objs,r) + local tabText = win:title():sub(1,tabs.maxTitle) + local t = drawing.text({x=pt.x+tabs.textLeftPad,y=pt.y+tabs.textTopPad, + w=tabs.tabWidth,h=tabs.tabHeight},tabText) + t:setTextSize(tabs.textSize) + t:setTextColor(tabs.textColor) + t:show() + table.insert(objs,t) + end +end + +local function reshuffle(app) + local proto = app:focusedWindow() + if not proto then return end + local geom = app:focusedWindow():frame() + for i,win in ipairs(app:allWindows()) do + if win:isStandard() then + win:setFrame(geom) + end + end + drawTabs(app) +end + +local function manageWindow(win, app) + if not win:isStandard() then return end + -- only trigger on focused window movements otherwise the reshuffling triggers itself + local newWatch = win:newWatcher(function(el,ev,wat,ud) if el == app:focusedWindow() then reshuffle(app) end end) + newWatch:start({watcher.windowMoved, watcher.windowResized, watcher.elementDestroyed}) + local redrawWatch = win:newWatcher(function (el,ev,wat,ud) drawTabs(app) end) + redrawWatch:start({watcher.elementDestroyed, watcher.titleChanged}) + + -- resize this window to match possible others + local notThis = fnutils.filter(app:allWindows(), function(x) return (x ~= win and realWindow(x)) end) + local protoWindow = notThis[1] + if protoWindow then + print("Prototyping to '" .. protoWindow:title() .. "'") + win:setFrame(protoWindow:frame()) + end +end + +local function watchApp(app) + -- print("Enabling tabs for " .. app:title()) + for i,win in ipairs(app:allWindows()) do + manageWindow(win,app) + end + local winWatch = app:newWatcher(function(el,ev,wat,appl) manageWindow(el,appl) end,app) + winWatch:start({watcher.windowCreated}) + local redrawWatch = app:newWatcher(function (el,ev,wat,ud) drawTabs(app) end) + redrawWatch:start({watcher.applicationActivated, watcher.applicationDeactivated, + watcher.applicationHidden, watcher.focusedWindowChanged}) + + reshuffle(app) +end + +local appWatcherStarted = false +local appWatches = {} + +--- hs.tabs.enableForApp(app) +--- Function +--- Starts tabbing the windows of an app. All windows will be herded into one stack and kept together. +--- +--- Parameters: +--- * An application object +--- +--- Returns: +--- * None +function tabs.enableForApp(appName) + appWatches[appName] = true + + -- might already be running + local runningApp = appfinder.appFromName(appName) + if runningApp then + watchApp(runningApp) + end + + -- set up a watcher to catch any watched app launching or terminating + if appWatcherStarted then return end + appWatcherStarted = true + local watch = appwatcher.new(function(name,event,app) + -- print("Event from " .. name) + if event == appwatcher.launched and appWatches[name] then + watchApp(app) + elseif event == appwatcher.terminated then + trashTabs(app:pid()) + end + end) + watch:start() +end + +--- hs.tabs.focusTab(app,num) +--- Function +--- Focuses a tab of an app. Numbers greater than the number of tabs focus the last tab. +--- +--- Parameters: +--- * A tabbed application object +--- * A number >=1 of the tab +--- +--- Returns: +--- * None +function tabs.focusTab(app,num) + if not app or not appWatches[app:title()] then return end + local tabs = tabs.tabWindows(app) + local bounded = num + print(hs.inspect(tabs)) + if num > #tabs then + bounded = #tabs + end + tabs[bounded]:focus() +end + +return tabs