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:
parent
9205cfaf1d
commit
be1ed5c90f
|
@ -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})
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue