diff --git a/README.md b/README.md new file mode 100644 index 0000000..be1b7e8 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# lua-utils +A collection of utilities I've built as-needed for progamming in Lua and working with [Hammerspoon](hammerspoon.org). + +Utilities compatible with vanilla Lua 5.3 are at the top level, while those intended for working with and/or extending Hammerspoon core libraries are nested within the `hs` directory. diff --git a/hs/application.lua b/hs/application.lua new file mode 100644 index 0000000..83d9065 --- /dev/null +++ b/hs/application.lua @@ -0,0 +1,59 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or require('hs.application') +assert(type(module) == 'table', 'must provide a table to extend') + +require('hs.applescript') +require('hs.task') + +function module.tell(application, message) + if message == nil then return nil end + if application == nil then return nil end + + local function _tellMessage(app) + if app == nil then + -- print("Something's wrong: application could not be found!") + return nil + end + + -- print("App is running! Commanding it to do things.") + + local ok, _ = hs.applescript('tell application "'..app:name()..'" to '..message) + if ok then + return app + else + return nil + end + end + + if type(application) == 'userdata' then return _tellMessage(application) end + if type(application) == 'string' then + -- print('Converting app string') + + local app = hs.application.get(application) + if app == nil then + -- print("App not running: attempting to start it in the background") + + local function _tellOnOpen() + local function sleep(n) + local t0 = os.clock() + while os.clock() - t0 <= n do end + end + app = hs.application.get(application) + sleep(1) + -- print('Opened! Commanding application to do things') + _tellMessage(app) + end + + -- Launch the application in the background and wait a reasonable amount of time + -- for it to become responsive before telling it anything. + hs.task.new('/usr/bin/open', _tellOnOpen, function() end, {'-ga', application}):start():waitUntilExit() + + return app + else + return _tellMessage(app) + end + end +end + +----------- +return module diff --git a/hs/canvas.lua b/hs/canvas.lua new file mode 100644 index 0000000..2b6b052 --- /dev/null +++ b/hs/canvas.lua @@ -0,0 +1,61 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or require('hs.canvas') +assert(type(module) == 'table', 'must provide a table to extend') + +require('hs.timer') +requrie('hs.fnutils') + +---- Rolling my own 'show and hide with fade-out': need to be able to cancel the animation. +module.flashable = {} +function module.flashable.new(canvasObj, options) + options = options or {} + local _fade -- Forward declaration for closure + local fader = hs.timer.delayed.new(options.fadeSpeed or 0.07, function() _fade() end) + local hider = hs.timer.delayed.new(options.showDuration or 1, hs.fnutils.partial(fader.start, fader)) + + -- A sentinel representing the exit condition for our background 'fader' + local cancelFade = false + + local function _abortFade() + -- Set the exit condition for any ongoing fade + cancelFade = true + -- Cancel any ongoing fade timer + fader:stop() + -- Reset canvas to maximum visibility + canvasObj:alpha(1.0) + end + + _fade = function() + local exit = cancelFade or (canvasObj:alpha() == 0) + if exit then + canvasObj:hide() + _abortFade() + else + local lowerAlpha = canvasObj:alpha() - 0.1 + canvasObj:alpha(lowerAlpha) + + -- Go around again + fader:start() + end + end + + local function _hideCanvas() + cancelFade = false + hider:start() + end + + return { + canvas = canvasObj, + flash = function() + -- Show the canvas if it's not already visible, then hide it according to configuration. + _abortFade() + canvasObj:show() + _hideCanvas() + end + } +end + +setmetatable(module.flashable, {__call = function(_, ...) return module.flashable.new(...) end}) + +----------- +return module diff --git a/hs/chooser.lua b/hs/chooser.lua new file mode 100644 index 0000000..261b70d --- /dev/null +++ b/hs/chooser.lua @@ -0,0 +1,21 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or require('hs.chooser') +assert(type(module) == 'table', 'must provide a table to extend') + +-- Takes a list of strings and creates a choice table formatted such that +-- it is acceptable by hs.chooser:choices +function module.generateChoiceTable(list) + if list == nil or #list == 0 then + return {} + end + + local choiceTable = {} + for _,item in ipairs(list) do + table.insert(choiceTable, { text = item..'' }) + end + + return choiceTable +end + +----------- +return module diff --git a/hs/fs.lua b/hs/fs.lua new file mode 100644 index 0000000..e3fc4cf --- /dev/null +++ b/hs/fs.lua @@ -0,0 +1,50 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or require('hs.fs') +assert(type(module) == 'table', 'must provide a table to extend') + +require '../string' + +-- Returns a list of directories in the given path. +function module.dirs(path) + local _,directoryContents = hs.fs.dir(path) + local directories = {} + repeat + local filename = directoryContents:next() + if ( + filename and + filename:match("^%.") == nil and -- Exclude dotfiles + hs.fs.attributes(path..filename, 'mode') == 'directory' + ) then + + table.insert(directories, filename) + end + until filename == nil + directoryContents:close() + + return directories +end + +-- Load each file in a given directory, with any arguments given. +function module.loadAllFiles(rootDir, ...) + -- Make sure our root ends with a directory marker. + rootDir = rootDir:endsWith('/') and rootDir or rootDir..'/' + + local loadedScripts = {} + local _,scripts = hs.fs.dir(rootDir) + repeat + local filename = scripts:next() + if filename and filename ~= '.' and filename ~= '..' then + print('\t\tloading script: '..filename) + -- Load the script, passing the given arguments as parameters to the Lua chunk. + -- Using `assert(loadfile(...))` instead of `require` to be compatible with Spoons. + local script = assert(loadfile(rootDir..filename))(...) + local basename = filename:match("^(.+)%.") -- Matches everything up to the first '.' + loadedScripts[basename] = script + end + until filename == nil + + return loadedScripts +end + +----------- +return module diff --git a/hs/location.lua b/hs/location.lua new file mode 100644 index 0000000..1d1909a --- /dev/null +++ b/hs/location.lua @@ -0,0 +1,24 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or require('hs.location') +assert(type(module) == 'table', 'must provide a table to extend') +if not module.geocoder then module.geocoder = {} end + +-- Looks up a region table for the given location query. +function module.geocoder.lookupRegion(query, callback) + local function callWithRegion(state, result) + if state then + callback(state, result[1].region) + else + callback(state, result) + end + end + + if type(query) == 'string' then + return hs.location.geocoder.lookupAddress(query, callWithRegion) + else + return hs.location.geocoder.lookupLocation(query, callWithRegion) + end +end + +----------- +return module diff --git a/hs/spotify.lua b/hs/spotify.lua new file mode 100644 index 0000000..97cb638 --- /dev/null +++ b/hs/spotify.lua @@ -0,0 +1,11 @@ +local module = ... or {} +assert(type(module) == 'table', 'must provide a table to extend') + +local tell = require('./application').tell + +module.tell = function(...) + tell('Spotify', ...) +end + +----------- +return module diff --git a/math.lua b/math.lua new file mode 100644 index 0000000..9af8aa5 --- /dev/null +++ b/math.lua @@ -0,0 +1,20 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or math +assert(type(module) == 'table', 'must provide a table to extend') + +-- Round the given number to the given number of decimal places. +function module.round(num, numPlaces) + local mult = 10^(numPlaces or 0) + return math.floor(num * mult + 0.5) / mult +end + +-- Degree/radian conversions +function module.toRadians(degrees) + return degrees * math.pi / 180; +end +function module.toDegrees(radians) + return radians * 180 / math.pi; +end + +----------- +return module diff --git a/string.lua b/string.lua new file mode 100644 index 0000000..96123cf --- /dev/null +++ b/string.lua @@ -0,0 +1,24 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or string +assert(type(module) == 'table', 'must provide a table to extend') + +--- Recipes for common string manipulations in Lua --- + +-- Returns true if the given string starts with another, otherwise returns false. +function module.startsWith(str, query) + return string.sub(str, 1, string.len(query)) == query +end + +-- Returns true if the given string ends with another, otherwise returns false. +function module.endsWith(str, query) + return query == '' or string.sub(str, -string.len(query)) == query +end + +-- Removes initial and trailing whitespace +-- @see http://lua-users.org/wiki/StringTrim, 'trim6' implementation +function module.trim(str) + return str:match'^()%s*$' and '' or str:match'^%s*(.*%S)' +end + +----------- +return module diff --git a/table.lua b/table.lua new file mode 100644 index 0000000..57dd6fc --- /dev/null +++ b/table.lua @@ -0,0 +1,159 @@ +-- Provide the ability to encapsulate the extensions into a different table. +local module = ... or table +assert(type(module) == 'table', 'must provide a table to extend') + +-- Handy table formatting function. Supports arbitrary depth. +function module.format(t, depth) + if type(t) ~= 'table' then return tostring(t) end + depth = depth or 1 + + local function tabs(n) + local tabs = '' + for i=0,n do + tabs = tabs..'\t' + end + return tabs + end + + local function trimTrailingNewline(s) + return s:gsub('\n$', '') + end + + local function walk(t, curIndentLvl, depth) + if next(t) == nil then return '' end -- empty table check + + local tstring = '\n' + for k,v in pairs(t) do + local indent = tabs(curIndentLvl) + local kstring = trimTrailingNewline(tostring(k)) + local vstring = nil + if type(v) == 'table' and depth > 1 then + vstring = string.format(' = table{%s%s}', walk(v, curIndentLvl + 1, depth - 1), indent) + else + vstring = string.format(' = %s', trimTrailingNewline(tostring(v))) + end + + tstring = string.format("%s%s%s%s\n", tstring, indent, kstring, vstring) + end + + return tstring + end + + return '{'..walk(t, 0, depth)..'}' +end + +-- Returns a list of the keys of the given table. +function module.keys(t) + if type(t) ~= 'table' then return nil end + + local keys = {} + for k,_ in pairs(t) do + table.insert(keys, k) + end + return keys +end + +-- Returns a list of the values of the given table. +function module.values(t) + if type(t) ~= 'table' then return nil end + + local values = {} + for _,v in pairs(t) do + table.insert(values, v) + end + return values +end + +-- Executes, across a table, a function that transforms each key-value pair into a new key-value pair, and +-- concatenates all the resulting tables together. +function module.map(t, fn) + if type(t) ~= 'table' then return nil end + if type(fn) ~= 'function' then return nil end + + local results = {} + for k,v in pairs(t) do + local k,v = fn(k,v) + results[k] = v + end + return results +end + + +----------------- +-- +-- Functions of varying complexity levels to achieve +-- a table copy in Lua. +-- + + +-- 1. The Problem. +-- +-- Here's an example to see why deep copies are useful. +-- Let's say function f receives a table parameter t, +-- and it wants to locally modify that table without +-- affecting the caller. This code fails: +-- +-- function f(t) +-- t.a = 3 +-- end +-- +-- local my_t = {a = 5} +-- f(my_t) +-- print(my_t.a) --> 3 +-- +-- This behavior can be hard to work with because, in +-- general, side effects such as input modifications +-- make it more difficult to reason about program +-- behavior. + + +-- 2. The easy solution. + +function module.copy(obj) + if type(obj) ~= 'table' then return obj end + + -- Preserve metatables. + local res = setmetatable({}, getmetatable(obj)) + + for k, v in pairs(obj) do res[module.copy(k)] = module.copy(v) end + return res +end + +-- 3. Supporting recursive structures. +-- +-- The issue here is that the following code will +-- get stuck in an infinite loop: +-- +-- local my_t = {} +-- my_t.a = my_t +-- local t_copy = table.copy(my_t, true) +-- +-- This happens when trying to make a copy of my_t.a, +-- which involves making a copy of my_t.a.a, which +-- involves making a copy of my_t.a.a.a, etc. The +-- recursive table my_t is perfectly legal, and it's +-- possible to make a deep_copy function that can +-- handle this by tracking which tables it has already +-- started to copy. + +function module.deepCopy(obj, seen) + -- Handle non-tables and previously-seen tables. + if type(obj) ~= 'table' then return obj end + if seen and seen[obj] then return seen[obj] end + + -- New table: mark it as seen and copy recursively. + local s = seen or {} + local res = setmetatable({}, getmetatable(obj)) + s[obj] = res + for k, v in pairs(obj) do res[module.deepCopy(k, s)] = module.deepCopy(v, s) end + return res +end + +-- Simple utility for merging two tables. +function module.merge(t1, t2) + for k,v in pairs(t2) do t1[k] = v end + return t1 +end + +----------- +return module