history: Add a generic history module to rule them all.

* Multiple iterators
 * Works with everything
 * Limits the boilerplate code when using with `connect_signal`
 * Limits the boilerplate code when using with `awful.keygrabber`
 * Supports iterator filter
 * Integrates with Lua `for` loop iterators.
This commit is contained in:
Emmanuel Lepage Vallee 2019-06-05 23:56:11 -04:00
parent 9205cfaf1d
commit be1ed5c90f
2 changed files with 670 additions and 0 deletions

669
lib/gears/history.lua Normal file
View File

@ -0,0 +1,669 @@
---------------------------------------------------------------------------
--- A generic and stateful history tracking class.
--
-- This module hosts an abstract implementation of history tracking. It can be
-- used with any datatype and hide the implementation details.
--
-- Alt+Tab like iterator for clients
-- =================================
--
-- local h = gears.history {
-- unique = true , -- Automagically deduplicate entries
-- count = false, -- Keep track of the number of time an item was pushed
-- }
--
-- -- Listen to event to push/pop/remove objects without boilerplate code.
-- client.connect_signal("focus" , h.push )
-- client.connect_signal("manage" , h.push )
-- client.connect_signal("unmanage", h.remove )
--
-- -- Abstract way to select objects.
-- h:connect_signal("request::select", function(_, c)
-- client.focus = c
-- c:jump_to()
-- end)
--
-- -- Support multiple iterators per history object.
-- local h_i = h:iterate_newest {
-- filter = function(c) return c.screen == awful.screen.focused() end,
-- }
--
-- -- Easy to use for transactions.
-- awful.keygrabber {
-- keybindings = {
-- {{'Mod1' }, 'Tab', h_i.select_next },
-- {{'Mod1', 'Shift'}, 'Tab', h_i.select_previous},
-- },
-- stop_key = 'Mod1', -- This is (left) "Alt".
-- stop_event = 'release',
-- start_callback = h.pause,
-- stop_callback = h.resume,
-- export_keybindings = true,
-- }
--
-- -- Easy to iterate.
-- h.paused = true
--
-- for my_object in h:iterate_oldest() do
-- -- something
-- end
--
-- h.paused = false
--
-- @author Emmanuel Lepage Vallee <elv1313@gmail.com>
-- @copyright 2014-2019 Emmanuel Lepage Vallee
-- @classmod gears.history
---------------------------------------------------------------------------
local gobject = require("gears.object")
local gtable = require("gears.table")
-- rename gears.ordered_set
--TODO
-- Support filter (and make the result iterable, map-reduce chain style)
-- Make sure it can be dumped into awful.widget.common as-is
-- Keep track of the hit count of each entry (for commands)
-- Support serialization using some external gears stuff?
-- Make this the base of awful.widget.common and replace fully Radical
-- Add some way to auto generate roles mapping for the awful.widget.common
-- template
-- Replace awful.completion, somehow
-- Replace the prompt history with this
local module = {}
--- The history updating mode.
--
-- Possible values are:
--
-- * *heap*: Always keep a single instance of each entry (default)
-- * *multi*: Allow an entry to be in multiple positions
--
-- @property mode
-- @param string
--- The maximum number of entries.
--
-- Use `math.huge` for unlimited.
--
-- @property maximum_entries
-- @param[opt=math.huge] number
-- @see entry::ousted
--- Count the number of time an entry is used.
-- @property count
-- @param[opt=false] boolean
--- Is the history tracking currently paused.
--
-- When paused, all `push` will be ignored.
--
-- @property paused
-- @param boolean
--- Resume tracking.
--
-- Note that this is a function, not a method. It is safe to use directly
-- in `connect_signal` or as a callback.
--
-- @method resume
--- Pause tracking.
--
-- Note that this is a function, not a method. It is safe to use directly
-- in `connect_signal` or as a callback.
--
-- @method pause
--- Push an entry to the front (most recent) spot.
--
-- Note that this is a function, not a method. It is safe to use directly
-- in `connect_signal` or as a callback.
--
-- Note that when the history tracking is paused, calling this has no effect.
-- This allows to connect the function directly without additional boilerplate
-- to take the state into account.
--
-- **Usage for clients:**
--
-- client.connect_signal("focus" , my_history.push )
-- client.connect_signal("manage" , my_history.push )
--
-- @method push
-- @param object Something to push.
--- Remove a value.
--
-- Note that this is a function, not a method. It is safe to use directly
-- in `connect_signal` or as a callback.
--
-- **Usage for clients:**
--
-- client.connect_signal("unmanage", my_history.remove )
--
-- @method remove
-- @param value
--- Create a new iterator that starts with the **newest** entry.
--
-- @tparam table args The arguments.
-- @tparam[opt=true] boolean args.rotate Define if the `next` and `previous`
-- iterator methods will rotate once the reach the boundary. This does **not**
-- affect the behavior when iterating using a `for` loop.
-- @tparam[opt=nil] function args.filter A filter function. It takes the object
-- as sole parameter and it will allowed if the function returns true.
-- Otherwise, the next element will be passed to the filter until it returns
-- true.
-- @method iterate_newest
-- @usage my_history.paused = true
-- for my_object in my_history:iterate_newest() do
-- -- something
-- end
-- my_history.paused = false
--- Create a new iterator that starts with the **oldest** entry.
--
-- @tparam table args The arguments.
-- @tparam[opt=true] boolean args.rotate Define if the `next` and `previous`
-- iterator methods will rotate once the reach the boundary. This does **not**
-- affect the behavior when iterating using a `for` loop.
-- @tparam[opt=nil] function args.filter A filter function. It takes the object
-- as sole parameter and it will allowed if the function returns true.
-- Otherwise, the next element will be passed to the filter until it returns
-- true.
-- @method iterate_oldest
-- @usage my_history.paused = true
-- for my_object in my_history:iterate_oldest() do
-- -- something
-- end
-- my_history.paused = false
--- Returns the oldest object tracked by this history.
-- @return The oldest object.
-- @method oldest
--- Returns the newest object tracked by this history.
-- @return The newest object.
-- @method newest
--- Emitted when the history tracking is paused.
-- @signal paused
--- Emitted when the history tracking is resumed.
-- @signal resumed
--- Emitted when the iterator `:select()` method is called.
--
-- Calling `:select()` itself does nothing beside emitting this signal. It is
-- up to the API user to implement a handler for it.
--
-- **Usage for clients:**
--
-- my_history:connect_signal("request::select", function(_, c)
-- client.focus = c
-- c:jump_to()
-- end)
--
-- @signal request::select
-- @param o The object.
--- Emitted when an entry is ousted because it exeed the `maximum_entries`.
-- @signal entry::ousted
-- @param o The object.
--- Emitted when an object is pushed to the history.
--
-- It is sent for both new and existing objects.
--
-- @signal entry::pushed
-- @param o The object.
-- @see added
--- Emitted when an object is added to the history.
--
-- It is sent only for *new* entries that were not already in the stack.
--
-- @signal entry::added
-- @param o The object.
-- @see pushed
--- Emitted when an object is removed from the stack.
--
-- @signal entry::removed
-- @param o The object.
local it_methods = {}
local function init_current(self)
if self._current then return self._current end
self._current = self._delta > 0 and 1 or #self._history._private.entries
return self._current
end
local function it_rotate(self)
init_current(self)
-- Don't allow the index to go out of bound.
if not self.rotate then
self._current = math.max(1, self._current)
self._current = math.min(self._current, #self._history._private.entries)
return
end
-- Rotate from the edges
if self._current > #self._history._private.entries then
self._current = 1
elseif self._current < 1 then
self._current = #self._history._private.entries
end
end
function it_methods:reset()
self._current = nil
end
function it_methods:next()
assert(self._history.paused, "Iterators are only available on paused history objects")
init_current(self)
self._current = self._current + self._delta
it_rotate(self)
end
function it_methods:previous()
assert(self._history.paused, "Iterators are only available on paused history objects")
init_current(self)
self._current = self._current - self._delta
it_rotate(self)
end
function it_methods:select()
assert(self._history.paused, "Iterators are only available on paused history objects")
self._history:emit_signal(
"request::select",
self._history._private.entries[init_current(self)]
)
end
-- Convenience wrapper intended to be passed to connect_signal.
function it_methods:select_next()
self:next()
self:select()
end
-- Convenience wrapper intended to be passed to connect_signal.
function it_methods:select_previous()
self:previous()
self:select()
end
local function remove(self, value, value2)
-- Allow to be used as function or method
if self == value then value = value2 end
local idx = self._private.values[value]
if idx then
table.remove(self._private.entries, idx)
-- Update the index
for i = idx, #self._private.entries do
local v = self._private.entries[i]
self._private.values[v] = i
end
end
self._private.counter[value] = nil
-- Invalidate all iterators.
for _, i in ipairs(self._private.iterators) do
i:reset()
end
self:emit_signal("entry::removed", value)
end
local function push(self, value, value2)
-- Remove all the boilerplate necessary to implement Windows/macOS Alt+Tab
-- like behavior. While navigating the list, the, for example, clients
-- should be focused but the history remain unchanged.
if self.paused then return end
-- Allow to be used as function or method
if self == value then value = value2 end
-- Don't allow direct duplicates, it probably happens because multiple
-- signals are connected.
if self._private.entries[#self._private.entries] == value then return end
local m = self.mode
if m == "multi" then
table.insert(self._private.entries, value)
else
remove(self, value)
table.insert(self._private.entries, value)
if not self._private.values[value] then
self:emit_signal("entry::added", value)
end
self._private.values[value] = #self._private.entries
end
if self.count then
self._private.counter[value] = (self._private.counter[value] or 0) + 1
end
-- Remove enough entries to prevent going above the limite
while #self._private.entries > (self.maximum_entries or math.huge) do
local e = self._private.entries[1]
remove(self, e)
self:emit_signal("entry::ousted", e)
end
-- Invalidate all iterators.
for _, i in ipairs(self._private.iterators) do
i:reset()
end
self:emit_signal("entry::pushed", value)
end
--- Pop (remove) the most recent entry.
--
-- Note that this is a function, not a method. It is safe to use directly
-- in `connect_signal` or as a callback.
--
-- @method pop_newest
--- Pop (remove) the least recent entry.
--
-- Note that this is a function, not a method. It is safe to use directly
-- in `connect_signal` or as a callback.
--
-- @method pop_oldest
local function pop_newest(self)
local v = self._private.entries[#self._private.entries]
if not v then return end
self._private.values[v] = nil
table.remove(self._private.entries, #self._private.entries)
end
local function pop_oldest(self)
local v = self._private.entries[#self._private.entries]
if not v then return end
self._private.values[v] = nil
table.remove(self._private.entries, 1)
-- Update the reverse mapping.
for i = 1, #self._private.entries do
self._private.values[self._private.entries[i]] = i
end
end
-- Hack to make them available as functions *and* methods. It's useful when
-- used with `awful.keygrabber` or using:
--
-- capi.client.connect_signal("focus", myhistory.push_front)
--
-- It's unconventional, but on the other hand removes tons of boilerplate
-- `function() me:foo() end` style code. Given integration with existing modules
-- is a very important goal for this module, this is "a good idea".
--
local function add_functions(ret)
gtable.crush(ret, {
resume = function( ) ret.paused = false end,
pause = function( ) ret.paused = true end,
push = function(... ) return push (ret, ...) end,
pop_newest = function(value) return pop_oldest (ret ) end,
pop_oldest = function(value) return pop_newest (ret ) end,
remove = function(... ) return remove (ret, ...) end,
}, true)
end
-- Apply the filters and emit the signals.
local function iterate_common(self, delta)
--TODO
end
-- It isn't "really" a `gears.object`, but mimics some aspects.
local function iterator_common(self, delta, args)
local ret = {
_delta = delta,
_history = self,
_signals = {},
rotate = args.rotate == nil and true or args.rotate,
filter = args.filter,
}
-- Hacky trick to get the signals without yet another implementation.
ret.connect_signal = gobject.connect_signal
ret.disconnect_signal = gobject.disconnect_signal
ret.emit_signal = gobject.emit_signal
-- Make functions (instead of methods) for next/previous/select so they
-- can directly be attached to `connect_signal`.
for name, f in pairs(it_methods) do
ret[name] = function() f(ret) end
end
table.insert(self._private.iterators, ret)
return setmetatable(ret, {
__call = function()
-- Allow direct `for` loop iteration
local cur = init_current(ret)
ret._current = cur + delta
return ret._history._private.entries[cur]
end,
__index = function(_, k)
if k == "current" then
return ret._history._private.entries[init_current(ret)]
elseif k == "index" then
return init_current(ret)
end
return it_methods[k]
end,
__newindex = function(_, k, v)
if k == "index" then
assert(v > 0 and v <= #ret._history._private.entries)
init_current(ret)
ret._current = v
elseif k == "current" then
-- This isn't very good when values are present multiple time,
-- but otherwise ok.
for idx, val in ipairs(ret._history._private.entries) do
if val == v then
init_current(ret)
ret._current = idx
return
end
end
else
rawset(ret, k, v)
end
end
})
end
function module:iterate_newest(args)
return iterator_common(self, -1, args or {})
end
function module:iterate_oldest(args)
return iterator_common(self, 1, args or {})
end
function module:oldest()
return self._private.entries[1]
end
function module:newest()
return self._private.entries[#self._private.entries]
end
function module:get_paused()
return self._private.paused or false
end
function module:set_paused(v)
if self._private.paused == v then return end
self._private.paused = v
if v then
self:emit_signal("paused")
else
self:emit_signal("resumed")
end
end
for _, prop in ipairs {"count", "maximum_entries"} do
module["get_"..prop] = function(self) return self._private[prop] end
module["set_"..prop] = function(self, value)
self._private[prop] = value
self:emit_signal("property::"..prop)
end
end
--- Create a new history tracking object.
-- @tparam table args
-- @tparam[opt=false] boolean args.count Track the number of time an item is
-- pushed.
-- @return A new history tracking object.
-- @constructorfct gears.history
local function new(_, args)
local ret = gobject {
enable_auto_signals = true,
enable_properties = true,
}
rawset(ret, "_private", {
entries = {},
values = {},
counter = {},
iterators = setmetatable({}, {__mode="v"}),
count = args.count and true or false,
})
ret.mode = "heap"
gtable.crush(ret, module, true)
add_functions(ret)
gtable.crush(ret, args)
return ret
end
--@DOC_object_COMMON@
--- Iterator methods.
-- Iterators objects are created using `:iterate_oldest` and `:iterate_newest`.
--
-- Note that that is a function rather than a method, it can be passed directly
-- to `connect_signal`.
--
-- @section iterator
--- Move the iterator to the next element.
--
-- Note that that is a function rather than a method, it can be passed directly
-- to `connect_signal`.
--
-- @method next
-- @see previous
-- @see select_next
--- Move the iterator to the previous element.
--
-- Note that that is a function rather than a method, it can be passed directly
-- to `connect_signal`.
--
-- @method previous
-- @see next
-- @see select_previous
--- Move the iterator to the next element and select it.
--
-- Note that that is a function rather than a method, it can be passed directly
-- to `connect_signal`.
--
-- @method select_next
-- @see previous
--- Move the iterator to the previous element and select it.
--
-- Note that that is a function rather than a method, it can be passed directly
-- to `connect_signal`.
--
-- @method select_previous
-- @see next
--- Emit the `request::select` signal on the current object.
--
-- Note that that is a function rather than a method, it can be passed directly
-- to `connect_signal`.
--
-- @method select
--- Reset the iterator position.
-- @method reset
--- Iterator properties.
-- @section iterator_properties
--- The current entry.
--
-- The type depends on the type of object being stored in the history.
--
-- @property current
--- The current index.
-- @property index
-- @param number
--- Start over when `:next()` or `:previous()` reach the end.
-- @property rotate
-- @param[opt=true] boolean
--- The filter function used to skip some entries.
--
-- **Clients from the current tag:**
--
-- function(c)
-- return (not client.focus) or (
-- c.screen == client.focus.screen and c.first_tag.selected
-- )
-- end
--
-- **Clients from the current screen:**
--
-- function(c) return c.screen == awful.screen.focused() end
--
-- **Clients from the same application as the focused client:**
--
-- function(c) return (not client.focus) or client.focus.class == c.class end
--
-- **Tags from the current screen:**
--
-- function(t) return t.screen == awful.screen.focused() end
--
-- **Tags with clients in them:**
--
-- function(t) return #t:clients() > 0 end
--
-- @property filter
-- @param function
return setmetatable(module, {__call = new})

View File

@ -23,6 +23,7 @@ return
string = require("gears.string");
sort = require("gears.sort");
filesystem = require("gears.filesystem");
history = require("gears.history");
}
-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80