diff --git a/lib/awful/client.lua b/lib/awful/client.lua index be30a4076..ffa20e04d 100644 --- a/lib/awful/client.lua +++ b/lib/awful/client.lua @@ -8,7 +8,7 @@ -- Grab environment we need local gdebug = require("gears.debug") -local spawn = require("awful.spawn") +local spawn = nil --TODO v5 deprecate local set_shape = require("awful.client.shape").update.all local object = require("gears.object") local grect = require("gears.geometry").rectangle @@ -1170,8 +1170,11 @@ end -- @tparam bool|function merge If true then merge tags (select the client's -- first tag additionally) when the client is not visible. -- If it is a function, it will be called with the client as argument. +-- @see awful.spawn.once +-- @see awful.spawn.single_instance +-- @see awful.spawn.raise_or_spawn -- --- @function awful.client.run_or_raise +-- @deprecated awful.client.run_or_raise -- @usage -- run or raise urxvt (perhaps, with tabs) on modkey + semicolon -- awful.key({ modkey, }, 'semicolon', function () -- local matcher = function (c) @@ -1180,6 +1183,10 @@ end -- awful.client.run_or_raise('urxvt', matcher) -- end); function client.run_or_raise(cmd, matcher, merge) + gdebug.deprecate("Use awful.spawn.single_instance instead of".. + "awful.client.run_or_raise", {deprecated_in=5}) + + spawn = spawn or require("awful.spawn") local clients = capi.client.get() local findex = gtable.hasitem(clients, capi.client.focus) or 1 local start = gmath.cycle(#clients, findex + 1) diff --git a/lib/awful/ewmh.lua b/lib/awful/ewmh.lua index 4e74b2a8b..139e8e1a5 100644 --- a/lib/awful/ewmh.lua +++ b/lib/awful/ewmh.lua @@ -16,6 +16,7 @@ local aplace = require("awful.placement") local asuit = require("awful.layout.suit") local beautiful = require("beautiful") local alayout = require("awful.layout") +local atag = require("awful.tag") local ewmh = { generic_activate_filters = {}, @@ -83,6 +84,10 @@ end -- @tparam string context The context where this signal was used. -- @tparam[opt] table hints A table with additional hints: -- @tparam[opt=false] boolean hints.raise should the client be raised? +-- @tparam[opt=false] boolean hints.switch_to_tag should the client's first tag +-- be selected if none of the client's tags are selected? +-- @tparam[opt=false] boolean hints.switch_to_tags Select all tags associated +-- with the client. function ewmh.activate(c, context, hints) -- luacheck: no unused args hints = hints or {} @@ -121,12 +126,18 @@ function ewmh.activate(c, context, hints) -- luacheck: no unused args return end - if hints and hints.raise then + if hints.raise then c:raise() if not awesome.startup and not c:isvisible() then c.urgent = true end end + + -- The rules use `switchtotag`. For consistency and code re-use, support it, + -- but keep undocumented. --TODO v5 remove switchtotag + if hints.switchtotag or hints.switch_to_tag or hints.switch_to_tags then + atag.viewmore(c:tags(), c.screen, (not hints.switch_to_tags) and 0 or nil) + end end --- Add an activate (focus stealing) filter function. diff --git a/lib/awful/rules.lua b/lib/awful/rules.lua index 6bf07d525..8f9870715 100644 --- a/lib/awful/rules.lua +++ b/lib/awful/rules.lua @@ -15,7 +15,7 @@ -- * honor_workarea -- * tag -- * new_tag --- * switchtotag +-- * switch_to_tags (also called switchtotag) -- * focus -- * titlebars_enabled -- * callback @@ -83,7 +83,7 @@ If you want to put Emacs on a specific tag at startup, and immediately switch to that tag you can add: { rule = { class = "Emacs" }, - properties = { tag = mytagobject, switchtotag = true } } + properties = { tag = mytagobject, switch_to_tags = true } } If you want to apply a custom callback to execute when a rule matched, for example to pause playing music from mpd when you start dosbox, you @@ -374,6 +374,46 @@ end rules.add_rule_source("awful.spawn", apply_spawn_rules, {}, {"awful.rules"}) +local function apply_singleton_rules(c, props, callbacks) + local persis_id, info = c.single_instance_id, nil + + -- This is a persistent property set by `awful.spawn` + if awesome.startup and persis_id then + info = aspawn.single_instance_manager.by_uid[persis_id] + elseif c.startup_id then + info = aspawn.single_instance_manager.by_snid[c.startup_id] + aspawn.single_instance_manager.by_snid[c.startup_id] = nil + elseif aspawn.single_instance_manager.by_pid[c.pid] then + info = aspawn.single_instance_manager.by_pid[c.pid].matcher(c) and + aspawn.single_instance_manager.by_pid[c.pid] or nil + end + + if info then + c.single_instance_id = info.hash + gtable.crush(props, info.rules) + table.insert(callbacks, info.callback) + table.insert(info.instances, c) + + -- Prevent apps with multiple clients from re-using this too often in + -- the first 30 seconds before the PID is cleared. + aspawn.single_instance_manager.by_pid[c.pid] = nil + end +end + +--- The rule source for clients spawned by `awful.spawn.once` and `single_instance`. +-- +-- **Has priority over:** +-- +-- * `awful.rules` +-- +-- **Depends on:** +-- +-- * `awful.spawn` +-- +-- @rulesources awful.spawn_once + +rules.add_rule_source("awful.spawn_once", apply_singleton_rules, {"awful.spawn"}, {"awful.rules"}) + --- Apply awful.rules.rules to a client. -- @client c The client. function rules.apply(c) @@ -437,7 +477,7 @@ rules.high_priority_properties = {} -- @tfield table awful.rules.delayed_properties -- By default, the table has the following functions: -- --- * switchtotag +-- * switch_to_tags rules.delayed_properties = {} local force_ignore = { @@ -471,11 +511,17 @@ function rules.high_priority_properties.tag(c, value, props) end end -function rules.delayed_properties.switchtotag(c, value) +function rules.delayed_properties.switch_to_tags(c, value) if not value then return end atag.viewmore(c:tags(), c.screen) end +function rules.delayed_properties.switchtotag(c, value) + gdebug.deprecate("Use switch_to_tags instead of switchtotag", {deprecated_in=5}) + + rules.delayed_properties.switch_to_tags(c, value) +end + function rules.extra_properties.geometry(c, _, props) local cur_geo = c:geometry() diff --git a/lib/awful/spawn.lua b/lib/awful/spawn.lua index 5e02793dc..402dad5cc 100644 --- a/lib/awful/spawn.lua +++ b/lib/awful/spawn.lua @@ -165,7 +165,9 @@ local lgi = require("lgi") local Gio = lgi.Gio local GLib = lgi.GLib local util = require("awful.util") -local timer = require("gears.timer") +local gtable = require("gears.table") +local gtimer = require("gears.timer") +local aclient = require("awful.client") local protected_call = require("gears.protected_call") local spawn = {} @@ -210,6 +212,39 @@ do end end +local function hash_command(cmd, rules) + rules = rules or {} + cmd = type(cmd) == "string" and cmd or table.concat(cmd, ';') + + -- Do its best at adding some entropy + local concat_rules = nil + concat_rules = function (r, depth) + if depth > 2 then return end + + local keys = gtable.keys(rules) + + for _, k in ipairs(keys) do + local v = r[k] + local t = type(v) + + if t == "string" or t == "number" then + cmd = cmd..k..v + elseif t == "tag" then + cmd = cmd..k..v.name + elseif t == "table" and not t.connect_signal then + cmd = cmd .. k + concat_rules(v, depth + 1) + end + end + end + + concat_rules(rules, 1) + + local glibstr = GLib.String(cmd) + + return string.format('%x', math.ceil(GLib.String.hash(glibstr))) +end + spawn.snid_buffer = {} function spawn.on_snid_callback(c) @@ -220,7 +255,7 @@ function spawn.on_snid_callback(c) --TODO v5: Remove this signal c:emit_signal("spawn::completed_with_payload", props, callback) - timer.delayed_call(function() + gtimer.delayed_call(function() spawn.snid_buffer[c.startup_id] = nil end) end @@ -435,6 +470,199 @@ function spawn.read_lines(input_stream, line_callback, done_callback, close) start_read() end +-- When a command should only be executed once or only have a single instance, +-- track the SNID set on them to prevent multiple execution. +spawn.single_instance_manager = { + by_snid = {}, + by_pid = {}, + by_uid = {}, +} + +aclient.property.persist("single_instance_id", "string") + +-- Check if the client is running either using the rule source or the matcher. +local function is_running(hash, matcher) + local status = spawn.single_instance_manager.by_uid[hash] + if not status then return false end + + if #status.instances == 0 then return false end + + for _, c in ipairs(status.instances) do + if c.valid then return true end + end + + if matcher then + for _, c in ipairs(client.get()) do + if matcher(c) then return true end + end + end + + return false +end + +-- Keep the data related to this hash. +local function register_common(hash, rules, matcher, callback) + local status = spawn.single_instance_manager.by_uid[hash] + if status then return status end + + status = { + rules = rules, + callback = callback, + instances = setmetatable({}, {__mode = "v"}), + hash = hash, + exec = false, + matcher = matcher, + } + + spawn.single_instance_manager.by_uid[hash] = status + + return status +end + +local function run_once_common(hash, cmd, keep_pid) + local pid, snid = spawn.spawn(cmd) + + if type(pid) == "string" or not snid then return pid, snid end + + assert(spawn.single_instance_manager.by_uid[hash]) + + local status = spawn.single_instance_manager.by_uid[hash] + status.exec = true + + spawn.single_instance_manager.by_snid[snid] = status + + if keep_pid then + spawn.single_instance_manager.by_pid[pid] = status + end + + -- Prevent issues related to PID being re-used and a memory leak + gtimer { + single_shot = true, + autostart = true, + timeout = 30, + callback = function() + spawn.single_instance_manager.by_pid [pid ] = nil + spawn.single_instance_manager.by_snid[snid] = nil + end + } + + return pid, snid +end + +local function run_after_startup(f) + -- The clients are not yet managed during the first execution, so it will + -- miss existing instances. + if awesome.startup then + gtimer.delayed_call(f) + else + f() + end +end + +--- Spawn a command if it has not been spawned before. +-- +-- This function tries its best to preserve the state across `awesome.restart()`. +-- +-- By default, when no `unique_id` is specified, this function will generate one by +-- hashing the command and its rules. If you have multiple instance of the same +-- command and rules, you need to specify an UID or only the first one will be +-- executed. +-- +-- The `rules` are standard `awful.rules`. +-- +-- This function depends on the startup notification protocol to be correctly +-- implemented by the command. See `client.startup_id` for more information. +-- Note that this also wont work with shell or terminal commands. +-- +-- @tparam string|table cmd The command. +-- @tparam table rules The properties that need to be applied to the client. +-- @tparam[opt] function matcher A matching function to find the instance +-- among running clients. +-- @tparam[opt] string unique_id A string to identify the client so it isn't executed +-- multiple time. +-- @tparam[opt] function callback A callback function when the client is created. +-- @see awful.rules +function spawn.once(cmd, rules, matcher, unique_id, callback) + local hash = unique_id or hash_command(cmd, rules) + local status = register_common(hash, rules, matcher, callback) + run_after_startup(function() + if not status.exec and not is_running(hash, matcher) then + run_once_common(hash, cmd, matcher ~= nil) + end + end) +end + +--- Spawn a command if an instance is not already running. +-- +-- This is like `awful.spawn.once`, but will spawn new instances if the previous +-- has finished. +-- +-- The `rules` are standard `awful.rules`. +-- +-- This function depends on the startup notification protocol to be correctly +-- implemented by the command. See `client.startup_id` for more information. +-- Note that this also wont work with shell or terminal commands. +-- +-- Note that multiple instances can still be spawned if the command is called +-- faster than the client has time to start. +-- +-- @tparam string|table cmd The command. +-- @tparam table rules The properties that need to be applied to the client. +-- @tparam[opt] function matcher A matching function to find the instance +-- among running clients. +-- @tparam[opt] string unique_id A string to identify the client so it isn't executed +-- multiple time. +-- @tparam[opt] function callback A callback function when the client is created. +-- @see awful.rules +function spawn.single_instance(cmd, rules, matcher, unique_id, callback) + local hash = unique_id or hash_command(cmd, rules) + register_common(hash, rules, matcher, callback) + run_after_startup(function() + if not is_running(hash, matcher) then + return run_once_common(hash, cmd, matcher ~= nil) + end + end) +end + +local raise_rules = {focus = true, switch_to_tags = true, raise = true} + +--- Raise a client if it exists or spawn a new one then raise it. +-- +-- This function depends on the startup notification protocol to be correctly +-- implemented by the command. See `client.startup_id` for more information. +-- Note that this also wont work with shell or terminal commands. +-- +-- @tparam string|table cmd The command. +-- @tparam table rules The properties that need to be applied to the client. +-- @tparam[opt] function matcher A matching function to find the instance +-- among running clients. +-- @tparam[opt] string unique_id A string to identify the client so it isn't executed +-- multiple time. +-- @tparam[opt] function callback A callback function when the client is created. +-- @see awful.rules +-- @treturn client The client if it already exists. +function spawn.raise_or_spawn(cmd, rules, matcher, unique_id, callback) + local hash = unique_id or hash_command(cmd, rules) + + local status = spawn.single_instance_manager.by_uid[hash] + if status then + for _, c in ipairs(status.instances) do + if c.valid then + c:emit_signal("request::activate", "spawn.raise_or_spawn", raise_rules) + return c + end + end + end + + -- Do not modify the original. It also can't be a metatable.__index due to + -- its "broken" `pairs()` support. + local props = gtable.join(rules, raise_rules) + + spawn.single_instance(cmd, props, matcher, unique_id, callback) + + return nil +end + capi.awesome.connect_signal("spawn::canceled" , spawn.on_snid_cancel ) capi.awesome.connect_signal("spawn::timeout" , spawn.on_snid_cancel ) capi.client.connect_signal ("manage" , spawn.on_snid_callback ) diff --git a/lib/awful/tag.lua b/lib/awful/tag.lua index 9aa7066fa..621fa94ff 100644 --- a/lib/awful/tag.lua +++ b/lib/awful/tag.lua @@ -1281,19 +1281,39 @@ function tag.viewonly(t) end --- View only a set of tags. +-- +-- If `maximum` is set, there will be a limit on the number of new tag being +-- selected. The tags already selected do not count. To do nothing if one or +-- more of the tags are already selected, set `maximum` to zero. +-- -- @function awful.tag.viewmore -- @param tags A table with tags to view only. -- @param[opt] screen The screen of the tags. -function tag.viewmore(tags, screen) +-- @tparam[opt=#tags] number maximum The maximum number of tags to select. +function tag.viewmore(tags, screen, maximum) + maximum = maximum or #tags + local selected = 0 screen = get_screen(screen or ascreen.focused()) local screen_tags = screen.tags for _, _tag in ipairs(screen_tags) do if not gtable.hasitem(tags, _tag) then _tag.selected = false + elseif _tag.selected then + selected = selected + 1 end end for _, _tag in ipairs(tags) do - _tag.selected = true + if selected == 0 and maximum == 0 then + _tag.selected = true + break + end + + if selected >= maximum then break end + + if not _tag.selected then + selected = selected + 1 + _tag.selected = true + end end screen:emit_signal("tag::history::update") end diff --git a/lib/gears/table.lua b/lib/gears/table.lua index 54ceab4be..8b9bb6c26 100644 --- a/lib/gears/table.lua +++ b/lib/gears/table.lua @@ -18,7 +18,7 @@ local gtable = {} -- @return A new table containing all keys from the arguments. function gtable.join(...) local ret = {} - for _, t in pairs({...}) do + for _, t in ipairs({...}) do if t then for k, v in pairs(t) do if type(k) == "number" then diff --git a/tests/test-awful-rules.lua b/tests/test-awful-rules.lua index d99fccdb0..db89234d6 100644 --- a/tests/test-awful-rules.lua +++ b/tests/test-awful-rules.lua @@ -292,11 +292,11 @@ assert(not awful.rules.add_rule_source("invalid_source", function() end, {"awful.rules"}, {"awful.spawn"})) gears.debug.print_warning = temp --- Test tag and switchtotag +-- Test tag and switch_to_tags test_rule { properties = { - tag = "9", - switchtotag = true + tag = "9", + switch_to_tags = true }, test = function(class) local c = get_client_by_class(class) @@ -311,8 +311,8 @@ test_rule { } test_rule { properties = { - tag = "8", - switchtotag = false + tag = "8", + switch_to_tags = false }, test = function(class) local c = get_client_by_class(class) diff --git a/tests/test-spawn.lua b/tests/test-spawn.lua index 47758b3cc..368481e4b 100644 --- a/tests/test-spawn.lua +++ b/tests/test-spawn.lua @@ -10,6 +10,35 @@ local exit_yay, exit_snd = nil, nil -- * Using spawn with array is already covered by the test client. -- * spawn with startup notification is covered by test-spawn-snid.lua +local tiny_client = function(class) + return {"lua", "-e", [[ +local lgi = require 'lgi' +local Gtk = lgi.require('Gtk') +local class = ']]..class..[[' + +Gtk.init() + +window = Gtk.Window { + default_width = 100, + default_height = 100, + title = 'title', +} + +window:set_wmclass(class, class) + +local app = Gtk.Application {} + +function app:on_activate() + window.application = self + window:show_all() +end + +app:run {''} +]]} +end + +local matcher_called = false + local steps = { function() -- Test various error conditions. There are quite a number of them... @@ -119,6 +148,8 @@ local steps = { exit_snd = code end }) + + spawn.once(tiny_client("client1"), {tag=screen[1].tags[2]}) end if spawns_done == 3 then assert(exit_yay == 0) @@ -126,7 +157,174 @@ local steps = { assert(async_spawns_done == 2) return true end - end + end, + -- Test spawn_once + function() + if #client.get() ~= 1 then return end + + assert(client.get()[1].class == "client1") + assert(client.get()[1]:tags()[1] == screen[1].tags[2]) + + spawn.once(tiny_client("client1"), {tag=screen[1].tags[2]}) + spawn.once(tiny_client("client1"), {tag=screen[1].tags[2]}) + return true + end, + function(count) + -- Limit the odds of a race condition + if count ~= 3 then return end + + assert(#client.get() == 1) + assert(client.get()[1].class == "client1") + client.get()[1]:kill() + return true + end, + -- Test single_instance + function() + if #client.get() ~= 0 then return end + + -- This should do nothing + spawn.once(tiny_client("client1"), {tag=screen[1].tags[2]}) + + spawn.single_instance(tiny_client("client2"), {tag=screen[1].tags[3]}) + + return true + end, + -- Test that no extra clients are created + function() + if #client.get() ~= 1 then return end + + assert(client.get()[1].class == "client2") + assert(client.get()[1]:tags()[1] == screen[1].tags[3]) + + -- This should do nothing + spawn.single_instance(tiny_client("client2"), {tag=screen[1].tags[3]}) + spawn.single_instance(tiny_client("client2"), {tag=screen[1].tags[3]}) + + return true + end, + function() + if #client.get() ~= 1 then return end + + assert(client.get()[1].class == "client2") + assert(client.get()[1]:tags()[1] == screen[1].tags[3]) + + client.get()[1]:kill() + + return true + end, + -- Test that new instances can be spawned + function() + if #client.get() ~= 0 then return end + + spawn.single_instance(tiny_client("client2"), {tag=screen[1].tags[3]}) + + return true + end, + -- Test raise_or_spawn + function() + if #client.get() ~= 1 then return end + + assert(client.get()[1].class == "client2") + assert(client.get()[1]:tags()[1] == screen[1].tags[3]) + client.get()[1]:kill() + + spawn.raise_or_spawn(tiny_client("client3"), {tag=screen[1].tags[3]}) + + return true + end, + -- Add more clients to test the focus + function() + if #client.get() ~= 1 then return end + + -- In another iteration to make sure client4 has no focus + spawn(tiny_client("client4"), {tag = screen[1].tags[4]}) + spawn(tiny_client("client4"), {tag = screen[1].tags[4]}) + spawn(tiny_client("client4"), { + tag = screen[1].tags[4], switch_to_tags= true, focus = true, + }) + + return true + end, + function() + if #client.get() ~= 4 then return end + + assert(screen[1].tags[3].selected == false) + + for _, c in ipairs(client.get()) do + if c.class == "client4" then + assert(#c:tags() == 1) + assert(c:tags()[1] == screen[1].tags[4]) + end + end + + assert(screen[1].tags[4].selected == true) + + spawn.raise_or_spawn(tiny_client("client3"), {tag=screen[1].tags[3]}) + + return true + end, + -- Test that the client can be raised + function() + if #client.get() ~= 4 then return false end + + assert(client.focus.class == "client3") + assert(screen[1].tags[3].selected == true) + assert(screen[1].tags[4].selected == false) + + + for _, c in ipairs(client.get()) do + if c.class == "client4" then + c:tags()[1]:view_only() + client.focus = c + break + end + end + + assert(screen[1].tags[3].selected == false) + assert(screen[1].tags[4].selected == true ) + + for _, c in ipairs(client.get()) do + if c.class == "client3" then + c:kill() + end + end + + return true + end, + -- Test that a new instance can be spawned + function() + if #client.get() ~= 3 then return end + + spawn.raise_or_spawn(tiny_client("client3"), {tag=screen[1].tags[3]}) + + return true + end, + function() + if #client.get() ~= 4 then return end + + -- Cleanup + for _, c in ipairs(client.get()) do + c:kill() + end + + return true + end, + -- Test the matcher + function() + if #client.get() ~= 0 then return end + + spawn.single_instance("xterm", {tag=screen[1].tags[5]}, function(c) + matcher_called = true + return c.class == "xterm" + end) + + return true + end, + function() + if #client.get() ~= 1 then return end + assert(matcher_called) + return true + end, } runner.run_steps(steps) diff --git a/tests/test-urgent.lua b/tests/test-urgent.lua index c19087306..f0558097d 100644 --- a/tests/test-urgent.lua +++ b/tests/test-urgent.lua @@ -72,7 +72,7 @@ local steps = { awful.screen.focused().tags[1]:view_only() runner.add_to_default_rules({ rule = { class = "XTerm" }, - properties = { tag = "2", focus = true, switchtotag = true }}) + properties = { tag = "2", focus = true, switch_to_tags = true }}) awful.spawn("xterm")