diff --git a/docs/common/rule.ldoc b/docs/common/rule.ldoc index 1d2c0fc8..ec0fa7a5 100644 --- a/docs/common/rule.ldoc +++ b/docs/common/rule.ldoc @@ -2,8 +2,6 @@ --- A table which content will be used to set the target object properties. -- --- foo --- -- @rulecomponent properties -- @param table -- @see callbacks @@ -44,3 +42,22 @@ -- @param table -- @see rule -- @see except + +--- Matches when one of every "category" of components match. +-- +-- @rulecomponent rule_every +-- @param table +-- @see rule +-- @see except + +--- An identifier for this rule. +-- +-- It can be anything. It will be compared with the `==` operator. Strings are +-- highly recommended. +-- +-- Setting an `id` is useful to be able to remove the rule by using its id +-- instead of a table reference. Modules can also listen to `rule::appended` and +-- modify or disable a rule. +-- +-- @rulecomponent id +-- @param table|string|number|function diff --git a/lib/gears/matcher.lua b/lib/gears/matcher.lua index fac9ffc1..e389bf96 100644 --- a/lib/gears/matcher.lua +++ b/lib/gears/matcher.lua @@ -25,6 +25,10 @@ -- -- @DOC_text_gears_matcher_default_EXAMPLE@ -- +-- This example shows the different matching sections: +-- +-- @DOC_text_gears_matcher_types_EXAMPLE@ +-- -- More examples are available in `awful.rules`. -- -- @author Julien Danjou <julien@danjou.info> @@ -35,62 +39,173 @@ local gtable = require("gears.table") local gsort = require("gears.sort") local gdebug = require("gears.debug") +local gobject = require("gears.object") local protected_call = require("gears.protected_call") local matcher = {} +--- A rule has been added to a set of matching rules. +-- @signal rule::appended +-- @tparam table gears.matcher The matcher. +-- @tparam table rule The rule. +-- @tparam table source The matching rules source name. +-- @tparam table content The matching rules source content. +-- @see append_rule +-- @see append_rules + +--- A rule has been removed to a set of matching rules. +-- @signal rule::removed +-- @tparam table gears.matcher The matcher. +-- @tparam table rule The rule. +-- @tparam table source The matching rules source name. +-- @tparam table content The matching rules source content. +-- @see remove_rule + +--- A matching source function has been added. +-- @signal matching_function::added +-- @tparam table gears.matcher The matcher. +-- @tparam function callback The callback. +-- @see add_matching_function + +--- A matching source table has been added. +-- @signal matching_rules::added +-- @tparam table gears.matcher The matcher. +-- @tparam function callback The callback. +-- @see add_matching_rules + +--- A matching source function has been removed. +-- @signal matching_source::removed +-- @tparam table gears.matcher The matcher. +-- @see remove_matching_source + +local function default_matcher(a, b) + return a == b or (type(a) == "string" and a:match(b)) +end + +local function greater_matcher(a, b) + return a > b +end + +local function lesser_matcher(a, b) + return a < b +end + -- Check if an object matches a rule. -- @param o The object. -- #tparam table rule The rule to check. -- @treturn boolean True if it matches, false otherwise. -function matcher:_match(o, rule) +-- @method _match +function matcher:_match(o, rule, matcher_f) if not rule then return false end + + matcher_f = matcher_f or default_matcher + for field, value in pairs(rule) do - if o[field] then - if type(o[field]) == "string" then - if not o[field]:match(value) and o[field] ~= value then - return false - end - elseif o[field] ~= value then - return false - end - else + local pm = self._private.prop_matchers[field] + if pm and pm(o, value, field) then + return true + elseif not matcher_f(o[field], value) then return false end end return true end +local function field_matcher(self, o, field, value, matcher_f) + matcher_f = matcher_f or default_matcher + + local pm = self._private.prop_matchers[field] + + if pm and pm(o, value, field) then + return true + elseif matcher_f(o[field] , value) then + return true + end + + return false +end + -- Check if an object matches any part of a rule. -- @param o The object. --- #tparam table rule The rule to check. +-- #tparam table rule The rule _match_anyto check. -- @treturn boolean True if at least one rule is matched, false otherwise. +-- @method _match_any function matcher:_match_any(o, rule) if not rule then return false end for field, values in pairs(rule) do if o[field] then + + -- Special case, "all" + if type(values) == "boolean" and values then + return true + end + for _, value in ipairs(values) do - if o[field] == value then - return true - elseif type(o[field]) == "string" and o[field]:match(value) then + if field_matcher(self, o, field, value) then return true end end end end + return false end +-- Check if an object matches at least one of every part of a rule. +-- +-- @param o The object. +-- @tparam table rule The rule _match_anyto check. +-- @tparam boolean multi If the entries are table of choices. +-- @treturn boolean True if all rules are matched. +-- @method _match_every +function matcher:_match_every(o, rule) + if not rule then return true end + + for field, values in pairs(rule) do + local found = false + for _, value in ipairs(values) do + if not field_matcher(self, o, field, value) then + found = true + break + end + end + + if not found then + return false + end + end + + return true +end + --- Does a given rule entry match an object? -- @param o The object. -- @tparam table entry Rule entry (with keys `rule`, `rule_any`, `except` and/or -- `except_any`). -- @treturn boolean If `o` matches `entry`. +-- @method matches_rule function matcher:matches_rule(o, entry) local match = self:_match(o, entry.rule) or self:_match_any(o, entry.rule_any) - return match - and (not self:_match(o, entry.except)) - and (not self:_match_any(o, entry.except_any)) + + -- If there was `rule` or `rule_any` and they failed to match, look no further. + if (not match) and (entry.rule or entry.rule_any) then return false end + + if not self:_match_every(o, entry.rule_every) then return false end + + -- Negative matching. + if entry.except and self:_match(o, entry.except) then return false end + if entry.except_any and self:_match_any(o, entry.except_any) then return false end + + -- Other operators. + if entry.rule_greater and not self:_match(o, entry.rule_greater, greater_matcher) then + return false + end + + if entry.rule_lesser and not self:_match(o, entry.rule_lesser, lesser_matcher) then + return false + end + + return true end --- Get list of matching rules for an object. @@ -100,10 +215,29 @@ end -- -- @param o The object. -- @tparam[opt=nil] table rules The rules to check. List with "rule", "rule_any", --- "except" and "except_any" keys. --- @treturn table The list of matched rules. +-- "except" and "except_any" keys. If no rules are provided, all rules +-- registered with a source will be matched. +-- @method matching_rules function matcher:matching_rules(o, rules) + + -- Match all sources. + if not rules then + local ret = {} + + for _, r in pairs(self._matching_rules) do + gtable.merge(ret, self:matching_rules(o, r)) + end + + return ret + end + local result = {} + + if not rules then + gdebug.print_warning("This matcher has no rule source") + return result + end + for _, entry in ipairs(rules) do if self:matches_rule(o, entry) then table.insert(result, entry) @@ -117,6 +251,7 @@ end -- @tparam table rules The rules to check. List of tables with `rule`, -- `rule_any`, `except` and `except_any` keys. -- @treturn boolean True if at least one rule is matched, false otherwise. +-- @method matches_rules function matcher:matches_rules(o, rules) for _, entry in ipairs(rules) do if self:matches_rule(o, entry) then @@ -126,6 +261,61 @@ function matcher:matches_rules(o, rules) return false end +--- Assign a function to match an object property against a value. +-- +-- The default matcher uses the `==` operator for all types. It also uses the +-- `:match()` method for string and allows pattern matching. If the value is a +-- function, then that function is called with the object and the current +-- properties to be applied. If the function returns true, the match is +-- accepted. +-- +-- Custom property matcher are useful when objects are compared. This avoids +-- having to implement custom metatable for everything. +-- +-- The `f` function receives 3 arguments: +-- +-- * The object to match against (anything) +-- * The value to compare +-- * The property/field name. +-- +-- It should return `true` if it matches and `false` otherwise. +-- +-- @tparam string name The property name. +-- @tparam function f The matching function. +-- @method add_property_matcher +-- @usage -- Manually match the screen in various ways. +-- matcher:add_property_matcher("screen", function(c, value) +-- return c.screen == value +-- or screen[c.screen] == value +-- or c.screen.outputs[value] ~= nil +-- or value == "any" +-- or (value == "primary" and c.screen == screen.primary) +-- end) +-- +function matcher:add_property_matcher(name, f) + assert(not self._private.prop_matchers[name], name .. " already has a matcher") + + self._private.prop_matchers[name] = f + + self:emit_signal("property_matcher::added", name, f) +end + +--- Add a special setter for a property. +-- +-- This is useful to add more properties to object which only make sense within +-- the context of a rule. +-- +-- @method add_property_setter +-- @tparam string name The property name. +-- @tparam function f The setter function. +function matcher:add_property_setter(name, f) + assert(not self._private.prop_setters[name], name .. " already has a matcher") + + self._private.prop_setters[name] = f + + self:emit_signal("property_setter::added", name, f) +end + local function default_rules_callback(self, o, props, callbacks, rules) for _, entry in ipairs(self:matching_rules(o, rules)) do gtable.crush(props, entry.properties or {}) @@ -146,6 +336,7 @@ end -- @tparam[opt={}] table precede A list of names of sources this source has a -- priority over. -- @treturn boolean Returns false if a dependency conflict was found. +-- @method add_matching_rules function matcher:add_matching_rules(name, rules, depends_on, precede) local function matching_fct(_self, c, props, callbacks) default_rules_callback(_self, c, props, callbacks, rules) @@ -153,6 +344,8 @@ function matcher:add_matching_rules(name, rules, depends_on, precede) self._matching_rules[name] = rules + self:emit_signal("matching_rules::added", rules) + return self:add_matching_function(name, matching_fct, depends_on, precede) end @@ -171,6 +364,7 @@ end -- @tparam[opt={}] table precede A list of names of sources this source has a -- priority over. -- @treturn boolean Returns false if a dependency conflict was found. +-- @method add_matching_function function matcher:add_matching_function(name, callback, depends_on, precede) depends_on = depends_on or {} precede = precede or {} @@ -221,6 +415,8 @@ function matcher:add_matching_function(name, callback, depends_on, precede) end end + self:emit_signal("matching_function::added", callback) + return true end @@ -231,16 +427,19 @@ end -- -- @tparam string name The source name. -- @treturn boolean If the source has been removed. +-- @method remove_matching_source function matcher:remove_matching_source(name) self._rule_source_sort:remove(name) for k, v in ipairs(self._matching_source) do if v.name == name then + self:emit_signal("matching_source::removed", v) table.remove(self._matching_source, k) return true end end + self._matching_rules[name] = nil return false @@ -252,6 +451,7 @@ end -- and rules. -- -- @param o The object. +-- @method apply function matcher:apply(o) local callbacks, props = {}, {} for _, v in ipairs(self._matching_source) do @@ -280,7 +480,9 @@ function matcher:_execute(o, props, callbacks) value = value(o, props) end - if type(o[property]) == "function" then + if self._private.prop_setters[property] then + self._private.prop_setters[property](o, value) + elseif type(o[property]) == "function" then o[property](o, value) else o[property] = value @@ -288,14 +490,59 @@ function matcher:_execute(o, props, callbacks) end end +--- Add a new rule to the default set. +-- @tparam string source The source name. +-- @tparam table rule A valid rule. +-- @method append_rule +function matcher:append_rule(source, rule) + if not self._matching_rules[source] then + self:add_matching_rules(source, {}, {}, {}) + end + table.insert(self._matching_rules[source], rule) + self:emit_signal("rule::appended", rule, source, self._matching_rules[source]) +end + +--- Add a new rules to the default set. +-- @tparam string source The source name. +-- @tparam table rules A table with rules. +-- @method append_rules +function matcher:append_rules(source, rules) + for _, rule in ipairs(rules) do + self:append_rule(source, rule) + end +end + +--- Remove a new rule to the default set. +-- @tparam string source The source name. +-- @tparam string|table rule An existing rule or its `id`. +-- @treturn boolean If the rule was removed. +-- @method remove_rule +function matcher:remove_rule(source, rule) + if not self._matching_rules[source] then return end + + for k, v in ipairs(self._matching_rules[source]) do + if v == rule or v.id == rule then + table.remove(self._matching_rules[source], k) + self:emit_signal("rule::removed", rule, source, self._matching_rules[source]) + return true + end + end + + return false +end + local module = {} --- Create a new rule solver object. --- @function gears.matcher +-- @constructorfct gears.matcher -- @return A new rule solver object. local function new() - local ret = {} + local ret = gobject() + + rawset(ret, "_private", { + rules = {}, prop_matchers = {}, prop_setters = {} + }) -- Contains the sources. -- The elements are ordered "first in, first executed". Thus, the higher the diff --git a/tests/examples/text/gears/matcher/default.lua b/tests/examples/text/gears/matcher/default.lua index ec60bc96..38da3216 100644 --- a/tests/examples/text/gears/matcher/default.lua +++ b/tests/examples/text/gears/matcher/default.lua @@ -11,7 +11,8 @@ local gears = {matcher = require("gears.matcher")} --DOC_HIDE -- This rule will match local rule1 = { rule = { - answer = 42, + answer = 42, + everything = true, }, properties = { name = "baz", @@ -69,6 +70,41 @@ local gears = {matcher = require("gears.matcher")} --DOC_HIDE -- This will add the `rules` to this matcher. matcher:add_matching_rules("second", rules, {"first"}, {}) +--DOC_NEWLINE + -- Some properties cannot be checked with the `==` operator (like those + -- with multiple possible types). In that case, it is possible to define + -- special comparator function. + matcher:add_property_matcher("everything", function(obj, value) + return value and obj.answer == 42 + end) + +--DOC_NEWLINE + + -- The same can be done for the property section. + matcher:add_property_setter("multiply_by", function(obj, value) + obj.answer = (obj.answer or 1) * value + end) + +--DOC_NEWLINE + + -- It is possible to append rules to existing (or new) sources. + matcher:append_rule( "second", { + id = "rule_with_id", + rule = { + has_elite = true, + }, + properties = { + multiply_by = "1337", + }, + }) + +--DOC_NEWLINE + + -- Or remove them. + local rm3 = --DOC_HIDE + matcher:remove_rule("second", "rule_with_id") + assert(rm3) --DOC_HIDE + --DOC_NEWLINE -- Apply the properties to `o` @@ -76,5 +112,12 @@ local gears = {matcher = require("gears.matcher")} --DOC_HIDE assert(o.is_everything) --DOC_HIDE assert(o.name == "baz") --DOC_HIDE +local rm1 = --DOC_HIDE matcher:remove_matching_source("first") --DOC_HIDE +assert(rm1) --DOC_HIDE + +matcher:append_rules("second", {{},{},{}}) --DOC_HIDE + +local rm2 = --DOC_HIDE matcher:remove_matching_source("second") --DOC_HIDE +assert(rm2) --DOC_HIDE diff --git a/tests/examples/text/gears/matcher/types.lua b/tests/examples/text/gears/matcher/types.lua new file mode 100644 index 00000000..d82b64c0 --- /dev/null +++ b/tests/examples/text/gears/matcher/types.lua @@ -0,0 +1,100 @@ +--DOC_HIDE --DOC_NO_USAGE +local gears = {matcher = require("gears.matcher")} --DOC_HIDE + + + local matcher = gears.matcher() + +--DOC_NEWLINE + + matcher:append_rule( "my.source", { + rule = { + my_any_rule = true, + }, + rule_every = { + every1 = {1, 42}, + every2 = {2, 42}, + every3 = {3, 42}, + }, + except = { + except1 = 1, + }, + properties = { + was_a_match = true, + }, + }) + +--DOC_NEWLINE + + local candidate1 = { + my_any_rule = true, + every1 = 1, + every2 = 2, + every3 = 3, + was_a_match = false, + } + +--DOC_NEWLINE + + local candidate2 = { + every2 = 2, + was_a_match = false, + } + +--DOC_NEWLINE + + local candidate3 = { + my_any_rule = true, + was_a_match = false, + every1 = 1, + every2 = 2, + every3 = 3, + except1 = 1, + } + + +--DOC_NEWLINE + + matcher:apply(candidate1) + matcher:apply(candidate2) + matcher:apply(candidate3) + +--DOC_NEWLINE + + -- Only candidate1 fits all criteria. + assert(candidate1.was_a_match == true ) + assert(candidate2.was_a_match == false) + assert(candidate3.was_a_match == false) + +--DOC_NEWLINE + + -- It is also possible to match number property by range. + matcher:append_rule( "my.source", { + rule_greater = { + value = 50, + }, + rule_lesser = { + value = 100, + }, + properties = { + was_a_match = true, + }, + }) + +--DOC_NEWLINE + + local candidate4 = { value = 40 , was_a_match = false } + local candidate5 = { value = 75 , was_a_match = false } + local candidate6 = { value = 101, was_a_match = false } + +--DOC_NEWLINE + + matcher:apply(candidate4) + matcher:apply(candidate5) + matcher:apply(candidate6) + +--DOC_NEWLINE + + -- Only candidate5 fits all criteria. + assert(candidate4.was_a_match == false) + assert(candidate5.was_a_match == true ) + assert(candidate6.was_a_match == false)