From 12a3fae456e7903c8c25e0979c27f43b87c51224 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Mon, 11 Oct 2021 20:54:47 -0700 Subject: [PATCH 01/28] This commit begins the development of a more appropriate user facing screenshot API. It extends a prior commit which extended the lower level content API, which had been a property of the client object but is now available as a property of the screen object and a method of the root object. This commit creates a new screenshot module for the awful module. The public functions include root(), screen(), client(), snipper(), and snip(). These take root window, screen, and client window screenshots, launch an interactive snip tool for cropped screenshots, and take a cropped screenshot of a geometry passed by argument, respectively. The init() function is also available for configuration. Using this library is more appropriate for the average rc.lua. Since the API is new, this commit does not include any changes to rc.lua. The developers can modify rc.lua when there is sufficient confidence in API stability and robustness. lib/awful/init.lua is modified so that the awful module includes the new lib/awful/screenshot.lua submodule. Signed off: Brian Sobulefsky --- lib/awful/init.lua | 1 + lib/awful/screenshot.lua | 488 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 lib/awful/screenshot.lua diff --git a/lib/awful/init.lua b/lib/awful/init.lua index d7ebbf817..9a5cc3532 100644 --- a/lib/awful/init.lua +++ b/lib/awful/init.lua @@ -39,6 +39,7 @@ local ret = { rules = require("awful.rules"); popup = require("awful.popup"); spawn = require("awful.spawn"); + screenshot = require("awful.screenshot"); } -- Lazy load deprecated modules to reduce the numbers of loop dependencies. diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua new file mode 100644 index 000000000..ef735cf00 --- /dev/null +++ b/lib/awful/screenshot.lua @@ -0,0 +1,488 @@ +--------------------------------------------------------------------------- +--- Screenshots and related configuration settings +-- +-- @author Brian Sobulefsky <brian.sobulefsky@protonmail.com> +-- @copyright 2021 Brian Sobulefsky +-- @inputmodule awful.screenshot +--------------------------------------------------------------------------- + +-- Grab environment we need +local capi = { root = root, + screen = screen, + client = client, + mousegrabber = mousegrabber } +local aw_screen = require("awful.screen") +local gears = require("gears") +local beautiful = require("beautiful") +local wibox = require("wibox") +local cairo = require("lgi").cairo + +local frame = nil +local frame_color = gears.color("#000000") + +local screenshot = { mt = {} } + +-- Configuration data +local ss_dir = nil +local ss_prfx = nil +local initialized = nil + +-- Parameters for the mousegrabber for the snipper tool +local mg_dir = nil +local mg_prfx = nil +local mg_first_pnt = {} +local mg_onsuccess_cb = nil + +-- Routine to get the default screenshot directory +-- +-- Can be expanded to read X environment variables or configuration files. +local function get_default_dir() + -- This can be expanded + local d = os.getenv("HOME") + + if d then + d = string.gsub(d, '/$', '') .. '/Images' + if os.execute("bash -c \"if [ -d \\\"" .. d .. "\\\" -a -w \\\"" .. d .. + "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then + return d .. '/' + end + end + + return nil +end + +-- Routine to get the default filename prefix +local function get_default_prefix() + return "Screenshot-" +end + +-- Routine to check a directory string for existence and writability +local function check_directory(directory) + if directory and type(directory) == "string" then + + -- By putting everything in dquotes, we should only need to escape + -- dquotes and dollar signs. Exclamation must be escaped outside the quote. + directory = string.gsub(directory, '"', '\\\\\\"') + directory = string.gsub(directory, '%$', '\\$') + directory = string.gsub(directory, '!', '"\\!"') + -- Assure that we return exactly one trailing slash + directory = string.gsub(directory, '/+$', '') + + if os.execute("bash -c \"if [ -d \\\"" .. directory .. "\\\" -a -w \\\"" .. + directory .. "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then + return directory .. '/' + else + -- Currently returns nil if the requested directory string cannot be used. + -- This can be swapped to a silent fallback to the default directory if + -- desired. It is debatable which way is better. + return nil + end + + else + -- No directory argument means use the default. Technically an outrageously + -- invalid argument (i.e. not even a string) currently falls back to the + -- default as well. + return get_default_dir() + end +end + +-- Routine to sanitize a prefix string +-- +-- Currently only strips all path separators ('/') and assures non empty, +-- although we may consider dropping the nonempty requirement (allow for +-- screenshots to be named 'date_time.png') +local function check_prefix(prefix) + -- Maybe add more sanitizing eventually + if prefix and type(prefix) == "string" then + prefix = string.gsub(prefix, '/', '') + if string.len(prefix) ~= 0 then + return prefix + end + end + return get_default_prefix() +end + +-- Routine to initialize the screenshot object +-- +-- Currently only sets the screenshot directory, the filename, prefix and the +-- snipper tool outline color. More initialization can be added as the API +-- expands. +function screenshot.init(directory, prefix, color) + local tmp + + tmp = check_directory(directory) + + if tmp then + ss_dir = tmp + else + initialized = nil + return nil + end + + tmp = check_prefix(prefix) + + if tmp then + ss_prfx = tmp + else + -- Should be unreachable as the default will always be taken + initialized = nil + return nil + end + + -- Don't throw out prior init data if only color is misformed + initialized = true + + if color then + tmp = gears.color(color) + if tmp then + frame_color = tmp + end + end + + return true +end + +-- Routine to configure the directory/prefix pair for any call to the +-- screenshot API. +-- +-- This supports using a different directory or filename prefix for a particular +-- use by passing the optional arguments. In the case of an initalized object +-- with no arguments passed, the stored configuration parameters are quickly +-- returned. This also technically allows the user to take a screenshot in an +-- unitialized state and without passing any arguments. +local function configure_call(directory, prefix) + + local d, p + + if not initialized or directory then + d = check_directory(directory) + if not d then + return + end + else + d = ss_dir + end + + + if not initialized or prefix then + p = check_prefix(prefix) + if not p then + return + end + else + p = ss_prfx + end + + -- In the case of taking a screenshot in an unitialized state, store the + -- configuration parameters and toggle to initialized going forward. + if not initialized then + ss_dir = d + ss_prfx = p + initilialized = true + end + + return d, p + +end + +-- Internal routine to do the actual work of taking a cropped screenshot +-- +-- This function does not do any data checking. Public functions should do +-- all data checking. This function was made to combine the interactive sipper +-- run by the mousegrabber and the programmatically defined snip function, +-- though there may be other uses. +local function crop_shot(x, y, width, height) + local source, target, cr + + source = gears.surface(root.content()) + target = source:create_similar(cairo.Content.COLOR, + width, height) + + cr = cairo.Context(target) + cr:set_source_surface(source, -x, -y) + cr:rectangle(0, 0, width, height) + cr:fill() + + return target +end + +-- Routine used by the snipper mousegrabber to update the frame outline of the +-- current state of the cropped screenshot +-- +-- This function is largely a copy of awful.mouse.snap.show_placeholder(geo), +-- so it is probably a good idea to create a utility routine to handle both use +-- cases. It did not seem appropriate to make that change as a part of the pull +-- request that was creating the screenshot API, as it would clutter and +-- confuse the change. +local function show_frame(geo) + + if not geo then + if frame then + frame.visible = false + end + return + end + + frame = frame or wibox { + ontop = true, + bg = frame_color + } + + frame:geometry(geo) + + -- Perhaps the preexisting image of frame can be reused? I tried but could + -- not get it to work. I am not sure if there is a performance penalty + -- incurred by making a new Cairo ImageSurface each execution. + local img = cairo.ImageSurface(cairo.Format.A1, geo.width, geo.height) + local cr = cairo.Context(img) + + cr:set_operator(cairo.Operator.CLEAR) + cr:set_source_rgba(0,0,0,1) + cr:paint() + cr:set_operator(cairo.Operator.SOURCE) + cr:set_source_rgba(1,1,1,1) + + local line_width = 1 + cr:set_line_width(beautiful.xresources.apply_dpi(line_width)) + + cr:translate(line_width,line_width) + gears.shape.partially_rounded_rect(cr,geo.width-2*line_width,geo.height-2*line_width, false, false, false, false, nil) + + cr:stroke() + + frame.shape_bounding = img._native + img:finish() + + frame.visible = true + +end + +-- Internal routine functioning as the callback for implementing the snipper. +-- +-- The snipper tool is basically a mousegrabber, which takes a single function +-- of one argument, representing the mouse state data. This is a simple +-- starting point that hard codes the snipper tool as being a two press design +-- using button 1, with button 3 functioning as the cancel. These aspects of +-- the interface can be made into parameters at some point (and also +-- incorporated into the init() configuration routine). +local function mg_callback(mouse_data) + if mouse_data["buttons"][3] then + if frame and frame.visible then + show_frame() + end + return false + end + + if mg_first_pnt[1] then + + local min_x, max_x, min_y, max_y + min_x = math.min(mg_first_pnt[1], mouse_data["x"]) + max_x = math.max(mg_first_pnt[1], mouse_data["x"]) + min_y = math.min(mg_first_pnt[2], mouse_data["y"]) + max_y = math.max(mg_first_pnt[2], mouse_data["y"]) + + -- Force a minimum size to the box + if max_x - min_x < 4 or max_y - min_y < 4 then + if frame and frame.visible then + show_frame() + end + elseif not mouse_data["buttons"][1] then + show_frame({x = min_x, y = min_y, width = max_x - min_x, height = max_y - min_y}) + end + + if mouse_data["buttons"][1] then + + local dir, prfx + local date_time, fname + local snip_surf + + if frame and frame.visible then + show_frame() + end + + frame = nil + mg_first_pnt = {} + + -- This may fail gracefully anyway but require a minimum 2x2 of pixels + if min_x >= max_x-1 or min_y >= max_y-1 then + return false + end + + dir, prfx = configure_call(mg_dir, mg_prfx) + snip_surf = crop_shot(min_x, min_y, max_x - min_x, max_y - min_y) + + if dir and prfx and snip_surf then + date_time = tostring(os.date("%Y%m%d%H%M%S")) + fname = dir .. prfx .. date_time .. ".png" + + gears.surface(snip_surf):write_to_png(dir .. prfx .. date_time .. ".png") + + if mg_onsuccess_cb then + mg_onsuccess_cb(fname) -- This should probably be a separate thread + end + end + + mg_onsuccess_cb = nil -- Revert callback unconditionally + return false + + end + + else + if mouse_data["buttons"][1] then + mg_first_pnt[1] = mouse_data["x"] + mg_first_pnt[2] = mouse_data["y"] + end + end + + return true +end + +-- Take root window screenshots +-- +-- @function screenshot.root +-- @tparam[opt] string The directory path in which to save screenshots +-- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @treturn string The full path to the successfully written screenshot file +function screenshot.root(directory, prefix) + local dir, prfx + local date_time, fname + + dir, prfx = configure_call(directory, prefix) + + if dir and prfx then + date_time = tostring(os.date("%Y%m%d%H%M%S")) + fname = dir .. prfx .. date_time .. ".png" + gears.surface(capi.root.content()):write_to_png(fname) + return fname + end +end + +-- Take physical screen screenshots +-- +-- @function screenshot.screen +-- @tparam[opt] string The directory path in which to save screenshots +-- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @tparam[opt] number or screen The index of the screen, or the screen itself +-- @treturn string The full path to the successfully written screenshot file +function screenshot.screen(directory, prefix, target) + local s + local dir, prfx + local date_time, fname + + if target then + if type(target) == "number" then + s = capi.screen[target] and aw_screen.focused() + elseif target.index and type(target.index) == "number" then + s = capi.screen[target.index] and aw_screen.focused() + end + else + s = aw_screen.focused() + end + + dir, prfx = configure_call(directory, prefix) + + if s and dir and prfx then + date_time = tostring(os.date("%Y%m%d%H%M%S")) + fname = dir .. prfx .. date_time .. ".png" + gears.surface(s.content):write_to_png(fname) + return fname + end +end + +-- Take client window screenshots +-- +-- @function screenshot.client +-- @tparam[opt] string The directory path in which to save screenshots +-- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @treturn string The full path to the successfully written screenshot file +function screenshot.client(directory, prefix) + -- Looking at the properties and functions available, I'm not sure it is + -- wise to allow a "target" client argument, but if we want to add it as + -- arg 3 (which will match the screen ordering), we can. + + local c + local dir, prfx + local date_time, fname + + dir, prfx = configure_call(directory, prefix) + c = capi.client.focus + + if c and dir and prfx then + date_time = tostring(os.date("%Y%m%d%H%M%S")) + fname = dir .. prfx .. date_time .. ".png" + gears.surface(c.content):write_to_png(fname) + return fname + end +end + +-- Launch an interactive snipper tool to take cropped shots +-- +-- @function screenshot.snipper +-- @tparam[opt] string The directory path in which to save screenshots +-- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @tparam[opt] function A callback to be run upon successful writing of the +-- screenshot file with a single argument of the path to +-- the screenshot file. +function screenshot.snipper(directory, prefix, onsuccess_cb) + local dir, prfx + local date_time + + dir, prfx = configure_call(directory, prefix) + + if dir and prfx then + + mg_dir = dir + mg_prfx = prfx + + capi.mousegrabber.run(mg_callback, "crosshair") + + if onsuccess_cb and type(onsuccess_cb) == "function" then + mg_onsuccess_cb = onsuccess_cb + end + + return + + end +end + +-- Take a cropped screenshot of a defined geometry +-- +-- @function screenshot.snip +-- @tparam geometry The geometry of the cropped screenshot +-- @tparam[opt] string The directory path in which to save screenshots +-- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @treturn string The full path to the successfully written screenshot file +function screenshot.snip(geom, directory, prefix) + local dir, prfx + local root_w, root_h + local root_intrsct + local date_time, fname + local snip_surf + + root_w, root_h = root.size() + dir, prfx = configure_call(directory, prefix) + + if dir and prfx and geom and + geom.x and geom.y and geom.width and geom.height then + + if type(geom.x) == "number" and type(geom.y) == "number" and + type(geom.width) == "number" and type(geom.height) == "number" then + + root_intrsct = gears.geometry.rectangle.get_intersection(geom, + {x = 0, y = 0, + width = root_w, height = root_h}) + snip_surf = crop_shot(root_intrsct.x, root_intrsct.y, + root_intrsct.width, root_intrsct.height) + + date_time = tostring(os.date("%Y%m%d%H%M%S")) + fname = dir .. prfx .. date_time .. ".png" + gears.surface(snip_surf):write_to_png(fname) + + return fname + + end + + end +end + +return screenshot From 18ee0d6bf746efe176e8e5406bb6491d9512f83e Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Mon, 11 Oct 2021 21:39:34 -0700 Subject: [PATCH 02/28] Add luadoc comments for awful.screenshot.init and fix a typo in the mousegrabber callback (use the filename convenience variable 'fname'). --- lib/awful/screenshot.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index ef735cf00..2c57ee94d 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -107,6 +107,10 @@ end -- Currently only sets the screenshot directory, the filename, prefix and the -- snipper tool outline color. More initialization can be added as the API -- expands. +-- @function screenshot.init +-- @tparam string The directory path in which to save screenshots +-- @tparam string The prefix prepended to the screenshot filenames +-- @return true or nil depending on success function screenshot.init(directory, prefix, color) local tmp @@ -315,7 +319,7 @@ local function mg_callback(mouse_data) date_time = tostring(os.date("%Y%m%d%H%M%S")) fname = dir .. prfx .. date_time .. ".png" - gears.surface(snip_surf):write_to_png(dir .. prfx .. date_time .. ".png") + gears.surface(snip_surf):write_to_png(fname) if mg_onsuccess_cb then mg_onsuccess_cb(fname) -- This should probably be a separate thread From efc9adae7cd1266ac4d0cbb2c690e6a4da58d37d Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Sat, 13 Nov 2021 12:27:05 -0800 Subject: [PATCH 03/28] Corrections to the lua documentation comment syntax. --- lib/awful/screenshot.lua | 59 +++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 2c57ee94d..3328977b0 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -102,15 +102,16 @@ local function check_prefix(prefix) return get_default_prefix() end --- Routine to initialize the screenshot object +--- Routine to initialize the screenshot object -- -- Currently only sets the screenshot directory, the filename, prefix and the -- snipper tool outline color. More initialization can be added as the API -- expands. -- @function screenshot.init --- @tparam string The directory path in which to save screenshots --- @tparam string The prefix prepended to the screenshot filenames --- @return true or nil depending on success +-- @tparam[opt] string directory The directory path in which to save screenshots +-- @tparam[opt] string prefix The prefix prepended to the screenshot filenames +-- @tparam[opt] string color A gears color compatible string +-- @treturn boolean true or false depending on success function screenshot.init(directory, prefix, color) local tmp @@ -120,7 +121,7 @@ function screenshot.init(directory, prefix, color) ss_dir = tmp else initialized = nil - return nil + return false end tmp = check_prefix(prefix) @@ -130,7 +131,7 @@ function screenshot.init(directory, prefix, color) else -- Should be unreachable as the default will always be taken initialized = nil - return nil + return false end -- Don't throw out prior init data if only color is misformed @@ -250,8 +251,10 @@ local function show_frame(geo) cr:set_line_width(beautiful.xresources.apply_dpi(line_width)) cr:translate(line_width,line_width) - gears.shape.partially_rounded_rect(cr,geo.width-2*line_width,geo.height-2*line_width, false, false, false, false, nil) - + gears.shape.partially_rounded_rect(cr, geo.width - 2*line_width, + geo.height - 2*line_width, + false, false, false, false, nil) + cr:stroke() frame.shape_bounding = img._native @@ -341,11 +344,11 @@ local function mg_callback(mouse_data) return true end --- Take root window screenshots +--- Take root window screenshots -- -- @function screenshot.root --- @tparam[opt] string The directory path in which to save screenshots --- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @tparam[opt] string directory The directory path in which to save screenshots +-- @tparam[opt] string prefix The prefix prepended to the screenshot filenames -- @treturn string The full path to the successfully written screenshot file function screenshot.root(directory, prefix) local dir, prfx @@ -361,12 +364,12 @@ function screenshot.root(directory, prefix) end end --- Take physical screen screenshots +--- Take physical screen screenshots -- -- @function screenshot.screen --- @tparam[opt] string The directory path in which to save screenshots --- @tparam[opt] string The prefix prepended to the screenshot filenames --- @tparam[opt] number or screen The index of the screen, or the screen itself +-- @tparam[opt] string directory The directory path in which to save screenshots +-- @tparam[opt] string prefix The prefix prepended to the screenshot filenames +-- @tparam[opt] integer target or screen The index of the screen, or the screen itself -- @treturn string The full path to the successfully written screenshot file function screenshot.screen(directory, prefix, target) local s @@ -393,11 +396,11 @@ function screenshot.screen(directory, prefix, target) end end --- Take client window screenshots +--- Take client window screenshots -- -- @function screenshot.client --- @tparam[opt] string The directory path in which to save screenshots --- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @tparam[opt] string directory The directory path in which to save screenshots +-- @tparam[opt] string prefix The prefix prepended to the screenshot filenames -- @treturn string The full path to the successfully written screenshot file function screenshot.client(directory, prefix) -- Looking at the properties and functions available, I'm not sure it is @@ -419,14 +422,14 @@ function screenshot.client(directory, prefix) end end --- Launch an interactive snipper tool to take cropped shots +--- Launch an interactive snipper tool to take cropped shots -- -- @function screenshot.snipper --- @tparam[opt] string The directory path in which to save screenshots --- @tparam[opt] string The prefix prepended to the screenshot filenames --- @tparam[opt] function A callback to be run upon successful writing of the --- screenshot file with a single argument of the path to --- the screenshot file. +-- @tparam[opt] string directory The directory path in which to save screenshots +-- @tparam[opt] string prefix The prefix prepended to the screenshot filenames +-- @tparam[opt] function onsuccess_cb A callback to be run upon successful writing +-- of the screenshot file with a single argument of the path +-- to the screenshot file. function screenshot.snipper(directory, prefix, onsuccess_cb) local dir, prfx local date_time @@ -449,12 +452,12 @@ function screenshot.snipper(directory, prefix, onsuccess_cb) end end --- Take a cropped screenshot of a defined geometry +--- Take a cropped screenshot of a defined geometry -- -- @function screenshot.snip --- @tparam geometry The geometry of the cropped screenshot --- @tparam[opt] string The directory path in which to save screenshots --- @tparam[opt] string The prefix prepended to the screenshot filenames +-- @tparam table geom The geometry of the cropped screenshot +-- @tparam[opt] string directory The directory path in which to save screenshots +-- @tparam[opt] string prefix The prefix prepended to the screenshot filenames -- @treturn string The full path to the successfully written screenshot file function screenshot.snip(geom, directory, prefix) local dir, prfx From 5a7faa00100d6c9476360fe7e4a57fe4eeeaa74c Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Wed, 2 Feb 2022 23:47:44 -0800 Subject: [PATCH 04/28] Develop the awful.screenshot module into an object oriented format. Add tests for the awful.screenshot module. --- lib/awful/screenshot.lua | 925 +++++++++++++++++++++++++++----------- tests/test-screenshot.lua | 66 +++ 2 files changed, 723 insertions(+), 268 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 3328977b0..3258430e7 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -16,24 +16,23 @@ local gears = require("gears") local beautiful = require("beautiful") local wibox = require("wibox") local cairo = require("lgi").cairo +local naughty = require("naughty") -local frame = nil -local frame_color = gears.color("#000000") - -local screenshot = { mt = {} } +-- The module to be returned +local module = { mt = {} } +-- The screenshow object created by a call to the screenshow module +local screenshot = {} +-- The various methods of taking a screenshow (root window, client, etc). +local screenshot_methods = {} -- Configuration data local ss_dir = nil local ss_prfx = nil +local frame_color = gears.color("#000000") local initialized = nil --- Parameters for the mousegrabber for the snipper tool -local mg_dir = nil -local mg_prfx = nil -local mg_first_pnt = {} -local mg_onsuccess_cb = nil --- Routine to get the default screenshot directory +-- Internal function to get the default screenshot directory -- -- Can be expanded to read X environment variables or configuration files. local function get_default_dir() @@ -41,36 +40,50 @@ local function get_default_dir() local d = os.getenv("HOME") if d then - d = string.gsub(d, '/$', '') .. '/Images' + d = string.gsub(d, '/*$', '/') .. 'Images/' if os.execute("bash -c \"if [ -d \\\"" .. d .. "\\\" -a -w \\\"" .. d .. "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then - return d .. '/' + return d end end return nil end --- Routine to get the default filename prefix +-- Internal function to get the default filename prefix local function get_default_prefix() return "Screenshot-" end --- Routine to check a directory string for existence and writability +-- Internal function to check a directory string for existence and writability. +-- This only checks if the requested directory exists and is writeable, not if +-- such a directory is legal (i.e. it behaves as 'mkdir' and not 'mkdir -p'). +-- Adding 'mkdir -p' functionality can be considered in the future. local function check_directory(directory) + if directory and type(directory) == "string" then - -- By putting everything in dquotes, we should only need to escape - -- dquotes and dollar signs. Exclamation must be escaped outside the quote. - directory = string.gsub(directory, '"', '\\\\\\"') - directory = string.gsub(directory, '%$', '\\$') - directory = string.gsub(directory, '!', '"\\!"') - -- Assure that we return exactly one trailing slash - directory = string.gsub(directory, '/+$', '') + -- Fully qualify a "~/" path or a relative path to $HOME. One might argue + -- that the relative path should fully qualify to $PWD, but for a window + -- manager this should be the same thing to the extent that anything else + -- is arguably unexpected behavior. + if string.find(directory, "^~/") then + directory = string.gsub(directory, "^~/", + string.gsub(os.getenv("HOME"), "/*$", "/")) + elseif string.find(directory, "^[^/]") then + directory = string.gsub(os.getenv("HOME"), "/*$", "/") .. directory + end - if os.execute("bash -c \"if [ -d \\\"" .. directory .. "\\\" -a -w \\\"" .. - directory .. "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then - return directory .. '/' + -- Assure that we return exactly one trailing slash + directory = string.gsub(directory, '/*$', '/') + + -- If we use single quotes, we only need to deal with single quotes - (I + -- promise that's meaningful if you think about it from a bash perspective) + local dir = string.gsub(directory, "'", "'\\'\\\\\\'\\''") + + if os.execute("bash -c 'if [ -d '\\''" .. dir .. "'\\'' -a -w '\\''" .. + dir .. "'\\'' ] ; then exit 0 ; else exit 1 ; fi'") then + return directory else -- Currently returns nil if the requested directory string cannot be used. -- This can be swapped to a silent fallback to the default directory if @@ -84,70 +97,78 @@ local function check_directory(directory) -- default as well. return get_default_dir() end + end --- Routine to sanitize a prefix string +-- Internal function to sanitize a prefix string -- --- Currently only strips all path separators ('/') and assures non empty, --- although we may consider dropping the nonempty requirement (allow for --- screenshots to be named 'date_time.png') +-- Currently only strips all path separators ('/'). Allows for empty prefix. local function check_prefix(prefix) -- Maybe add more sanitizing eventually if prefix and type(prefix) == "string" then prefix = string.gsub(prefix, '/', '') - if string.len(prefix) ~= 0 then - return prefix - end + return prefix end return get_default_prefix() end ---- Routine to initialize the screenshot object --- --- Currently only sets the screenshot directory, the filename, prefix and the --- snipper tool outline color. More initialization can be added as the API --- expands. --- @function screenshot.init --- @tparam[opt] string directory The directory path in which to save screenshots --- @tparam[opt] string prefix The prefix prepended to the screenshot filenames --- @tparam[opt] string color A gears color compatible string --- @treturn boolean true or false depending on success -function screenshot.init(directory, prefix, color) - local tmp +-- Internal routine to verify that a filepath is valid. +local function check_filepath(filepath) - tmp = check_directory(directory) + -- This module is forcing png for now. In the event of adding more + -- options, this function is basically unchanged, except for trying + -- to match a regex for each supported format (e.g. (png|jpe?g|gif|bmp)) + -- NOTE: we should maybe make this case insensitive too? + local fname_start, fname_end = string.find(filepath,'/[^/]+%.png$') - if tmp then - ss_dir = tmp - else - initialized = nil - return false - end - - tmp = check_prefix(prefix) - - if tmp then - ss_prfx = tmp - else - -- Should be unreachable as the default will always be taken - initialized = nil - return false - end - - -- Don't throw out prior init data if only color is misformed - initialized = true - - if color then - tmp = gears.color(color) - if tmp then - frame_color = tmp + if fname_start and fname_end then + local directory = string.sub(filepath, 1, fname_start) + local file_name = string.sub(filepath, fname_start + 1, fname_end) + directory = check_directory(directory) + if directory then + return directory .. file_name + else + return nil end end - return true + return nil + end --- Routine to configure the directory/prefix pair for any call to the +-- Internal function to attempt to parse a filepath into directory and prefix. +-- Returns directory, prefix if both, directory if no prefix, or nil if invalid +local function parse_filepath(filepath) + + -- Same remark as above about adding more image formats in the future + local fname_start, fname_end = string.find(filepath,'/[^/]+%.png$') + + if fname_start and fname_end then + + local directory = string.sub(filepath, 1, fname_start) + local file_name = string.sub(filepath, fname_start + 1, fname_end) + + if fname_end - fname_start > 14 + 4 then + + local base_name = string.sub(file_name, 1, #file_name - 4) + -- Is there a repeat count in Lua patterns, like {14} for most regexes? + local date_start, date_end = string.find(base_name, '%d%d%d%d%d%d%d%d%d%d%d%d%d%d$') + + if date_start and date_end then + return directory, string.sub(base_name, 1, date_start - 1) + end + + end + + return directory + + end + + return nil + +end + +-- Internal function to configure the directory/prefix pair for any call to the -- screenshot API. -- -- This supports using a different directory or filename prefix for a particular @@ -155,7 +176,7 @@ end -- with no arguments passed, the stored configuration parameters are quickly -- returned. This also technically allows the user to take a screenshot in an -- unitialized state and without passing any arguments. -local function configure_call(directory, prefix) +local function configure_path(directory, prefix) local d, p @@ -190,13 +211,22 @@ local function configure_call(directory, prefix) end --- Internal routine to do the actual work of taking a cropped screenshot +local function make_filepath(directory, prefix) + local dir, prfx + local date_time = tostring(os.date("%Y%m%d%H%M%S")) + dir, prfx = configure_path(directory, prefix) + local filepath = dir .. prfx .. date_time .. ".png" + return dir, prfx, filepath +end + +-- Internal function to do the actual work of taking a cropped screenshot -- -- This function does not do any data checking. Public functions should do -- all data checking. This function was made to combine the interactive sipper -- run by the mousegrabber and the programmatically defined snip function, -- though there may be other uses. local function crop_shot(x, y, width, height) + local source, target, cr source = gears.surface(root.content()) @@ -209,30 +239,34 @@ local function crop_shot(x, y, width, height) cr:fill() return target + end --- Routine used by the snipper mousegrabber to update the frame outline of the --- current state of the cropped screenshot + +-- Internal function used by the snipper mousegrabber to update the frame outline +-- of the current state of the cropped screenshot -- -- This function is largely a copy of awful.mouse.snap.show_placeholder(geo), -- so it is probably a good idea to create a utility routine to handle both use -- cases. It did not seem appropriate to make that change as a part of the pull -- request that was creating the screenshot API, as it would clutter and -- confuse the change. -local function show_frame(geo) +function show_frame(ss, geo) if not geo then - if frame then - frame.visible = false + if ss._private.frame then + ss._private.frame.visible = false end return end - frame = frame or wibox { + ss._private.frame = ss._private.frame or wibox { ontop = true, bg = frame_color } + local frame = ss._private.frame + frame:geometry(geo) -- Perhaps the preexisting image of frame can be reused? I tried but could @@ -264,232 +298,587 @@ local function show_frame(geo) end --- Internal routine functioning as the callback for implementing the snipper. +-- Internal function that generates the callback to be passed to the +-- mousegrabber that implements the snipper. -- -- The snipper tool is basically a mousegrabber, which takes a single function -- of one argument, representing the mouse state data. This is a simple -- starting point that hard codes the snipper tool as being a two press design -- using button 1, with button 3 functioning as the cancel. These aspects of --- the interface can be made into parameters at some point (and also --- incorporated into the init() configuration routine). -local function mg_callback(mouse_data) - if mouse_data["buttons"][3] then - if frame and frame.visible then - show_frame() +-- the interface can be made into parameters at some point passed as arguments. +local function mk_mg_callback(ss) + + ss._private.mg_first_pnt = {} + + local function ret_mg_callback(mouse_data) + + if mouse_data["buttons"][3] then + if ss._private.frame and ss._private.frame.visible then + show_frame(ss) + end + ss._private.mg_first_pnt = nil + ss._private.frame = nil + return false end + + if ss._private.mg_first_pnt[1] then + + local min_x, max_x, min_y, max_y + min_x = math.min(ss._private.mg_first_pnt[1], mouse_data["x"]) + max_x = math.max(ss._private.mg_first_pnt[1], mouse_data["x"]) + min_y = math.min(ss._private.mg_first_pnt[2], mouse_data["y"]) + max_y = math.max(ss._private.mg_first_pnt[2], mouse_data["y"]) + + -- Force a minimum size to the box + if max_x - min_x < 4 or max_y - min_y < 4 then + if frame and frame.visible then + show_frame(ss) + end + elseif not mouse_data["buttons"][1] then + show_frame(ss, {x = min_x, y = min_y, width = max_x - min_x, height = max_y - min_y}) + end + + if mouse_data["buttons"][1] then + + local snip_surf + local date_time + + if ss._private.frame and ss._private.frame.visible then + show_frame(ss) + end + + ss._private.frame = nil + ss._private.mg_first_pnt = nil + + -- This may fail gracefully anyway but require a minimum 3x3 of pixels + if min_x >= max_x-1 or min_y >= max_y-1 then + return false + end + + snip_surf = crop_shot(min_x, min_y, max_x - min_x, max_y - min_y) + ss:filepath_builder() + ss._private.surface = gears.surface(snip_surf) -- surface has no setter + + if ss._private.on_success_cb then + ss._private.on_success_cb(ss) + end + + return false + + end + + else + if mouse_data["buttons"][1] then + ss._private.mg_first_pnt[1] = mouse_data["x"] + ss._private.mg_first_pnt[2] = mouse_data["y"] + end + end + + return true + + end + + return ret_mg_callback + +end + +-- Internal function to be passed as the default callback upon completion of +-- the mousgrabber for the snipper if the user does not pass one. +function default_on_success_cb(ss) + ss:save() +end + +-- Internal function exected when a root window screenshot is taken. +function root_screenshot(ss) + local w, h = root.size() + ss._private.geometry = {x = 0, y = 0, width = w, height = h} + ss:filepath_builder() + ss._private.surface = gears.surface(capi.root.content()) -- surface has no setter + return ss +end + +-- Internal function executed when a physical screen screenshot is taken. +function screen_screenshot(ss) + + -- note the use of _private because screen has no setter + if ss.screen then + if type(ss.screen) == "number" then + ss._private.screen = capi.screen[ss.screen] or aw_screen.focused() + elseif ss.screen.index and type(ss.screen.index) == "number" then + ss._private.screen = capi.screen[ss.screen.index] or aw_screen.focused() + end + else + ss._private.screen = aw_screen.focused() + end + + ss._private.geometry = ss.screen.geometry + ss:filepath_builder() + ss._private.surface = gears.surface(ss.screen.content) -- surface has no setter + + return ss + +end + +-- Internal function executed when a client window screenshot is taken. +function client_screenshot(ss) + -- note the use of _private becuase client has no setter + if ss.client and ss.client.content then + -- If the passed client arg has a content property we'll take it. + else + ss._private.client = capi.client.focus + end + ss._private.geometry = ss.client:geometry() + ss:filepath_builder() + ss._private.surface = gears.surface(ss.client.content) -- surface has no setter + return ss +end + +-- Internal function executed when a snipper screenshot tool is launched. +function snipper_screenshot(ss) + + if type(ss._private.on_success_cb) ~= "function" then + ss._private.on_success_cb = default_on_success_cb -- the cb has no setter + end + + local mg_callback = mk_mg_callback(ss) + capi.mousegrabber.run(mg_callback, "crosshair") + + return true + +end + +-- Internal function executed when a snip screenshow (a defined geometry) is +-- taken. +function snip_screenshot(ss) + + local root_w, root_h + local root_intrsct + local snip_surf + + root_w, root_h = root.size() + + if not(ss.geometry and + type(ss.geometry.x) == "number" and + type(ss.geometry.y) == "number" and + type(ss.geometry.width) == "number" and + type(ss.geometry.height) == "number") then + + -- Default to entire root window. Also geometry has no setter. + ss._private.geometry = {x = 0, y = 0, width = root_w, height = root_h} + + end + + root_intrsct = gears.geometry.rectangle.get_intersection(ss.geometry, + {x = 0, y = 0, + width = root_w, + height = root_h}) + + snip_surf = crop_shot(root_intrsct.x, root_intrsct.y, + root_intrsct.width, root_intrsct.height) + + ss:filepath_builder() + ss._private.surface = gears.surface(snip_surf) -- surface has no setter + + return ss + +end + +-- Screenshot methods +screenshot_methods.root = root_screenshot +screenshot_methods.screen = screen_screenshot +screenshot_methods.client = client_screenshot +screenshot_methods.snipper = snipper_screenshot +screenshot_methods.snip = snip_screenshot +-- Default method is root +screenshot_methods.default = root_screenshot +local default_method_name = "root" + +-- Module routines + +--- Function to initialize the screenshot library +-- +-- Currently only sets the screenshot directory, the filename, prefix and the +-- snipper tool outline color. More initialization can be added as the API +-- expands. +-- @staticfct awful.screenshot.set_defaults +-- @tparam[opt] table args Table passed with the configuration data. +-- @treturn boolean true or false depending on success +function module.set_defaults(args) + local tmp + + tmp = check_directory(args.directory) + + if tmp then + ss_dir = tmp + else + initialized = nil return false end - if mg_first_pnt[1] then - - local min_x, max_x, min_y, max_y - min_x = math.min(mg_first_pnt[1], mouse_data["x"]) - max_x = math.max(mg_first_pnt[1], mouse_data["x"]) - min_y = math.min(mg_first_pnt[2], mouse_data["y"]) - max_y = math.max(mg_first_pnt[2], mouse_data["y"]) - - -- Force a minimum size to the box - if max_x - min_x < 4 or max_y - min_y < 4 then - if frame and frame.visible then - show_frame() - end - elseif not mouse_data["buttons"][1] then - show_frame({x = min_x, y = min_y, width = max_x - min_x, height = max_y - min_y}) - end - - if mouse_data["buttons"][1] then - - local dir, prfx - local date_time, fname - local snip_surf - - if frame and frame.visible then - show_frame() - end - - frame = nil - mg_first_pnt = {} - - -- This may fail gracefully anyway but require a minimum 2x2 of pixels - if min_x >= max_x-1 or min_y >= max_y-1 then - return false - end - - dir, prfx = configure_call(mg_dir, mg_prfx) - snip_surf = crop_shot(min_x, min_y, max_x - min_x, max_y - min_y) - - if dir and prfx and snip_surf then - date_time = tostring(os.date("%Y%m%d%H%M%S")) - fname = dir .. prfx .. date_time .. ".png" - - gears.surface(snip_surf):write_to_png(fname) - - if mg_onsuccess_cb then - mg_onsuccess_cb(fname) -- This should probably be a separate thread - end - end - - mg_onsuccess_cb = nil -- Revert callback unconditionally - return false - - end + tmp = check_prefix(args.prefix) + if tmp then + ss_prfx = tmp else - if mouse_data["buttons"][1] then - mg_first_pnt[1] = mouse_data["x"] - mg_first_pnt[2] = mouse_data["y"] + -- Should be unreachable as the default will always be taken + initialized = nil + return false + end + + -- Don't throw out prior init data if only color is misformed + initialized = true + + if args.frame_color then + tmp = gears.color(args.frame_color) + if tmp then + frame_color = tmp end end return true end ---- Take root window screenshots +--- Take root window screenshots. -- --- @function screenshot.root --- @tparam[opt] string directory The directory path in which to save screenshots --- @tparam[opt] string prefix The prefix prepended to the screenshot filenames --- @treturn string The full path to the successfully written screenshot file -function screenshot.root(directory, prefix) - local dir, prfx - local date_time, fname - - dir, prfx = configure_call(directory, prefix) - - if dir and prfx then - date_time = tostring(os.date("%Y%m%d%H%M%S")) - fname = dir .. prfx .. date_time .. ".png" - gears.surface(capi.root.content()):write_to_png(fname) - return fname - end +-- This is a wrapper constructor for a root window screenshot. See the main +-- constructor, new(), for details about the arguments. +-- +-- @constructorfct awful.screenshot.root +-- @tparam[opt] table args Table of arguments to pass to the constructor +-- @treturn screenshot The screenshot object +function module.root(args) + local args = (type(args) == "table" and args) or {} + args.method = "root" + return module(args) end --- Take physical screen screenshots -- --- @function screenshot.screen --- @tparam[opt] string directory The directory path in which to save screenshots --- @tparam[opt] string prefix The prefix prepended to the screenshot filenames --- @tparam[opt] integer target or screen The index of the screen, or the screen itself --- @treturn string The full path to the successfully written screenshot file -function screenshot.screen(directory, prefix, target) - local s - local dir, prfx - local date_time, fname - - if target then - if type(target) == "number" then - s = capi.screen[target] and aw_screen.focused() - elseif target.index and type(target.index) == "number" then - s = capi.screen[target.index] and aw_screen.focused() - end - else - s = aw_screen.focused() - end - - dir, prfx = configure_call(directory, prefix) - - if s and dir and prfx then - date_time = tostring(os.date("%Y%m%d%H%M%S")) - fname = dir .. prfx .. date_time .. ".png" - gears.surface(s.content):write_to_png(fname) - return fname - end +-- This is a wrapper constructor for a physical screen screenshot. See the main +-- constructor, new(), for details about the arguments. +-- +-- @staticfct awful.screenshot.screen +-- @tparam[opt] table args Table of arguments to pass to the constructor +-- @treturn screenshot The screenshot object +function module.screen(args) + local args = (type(args) == "table" and args) or {} + args.method = "screen" + return module(args) end --- Take client window screenshots -- --- @function screenshot.client --- @tparam[opt] string directory The directory path in which to save screenshots --- @tparam[opt] string prefix The prefix prepended to the screenshot filenames --- @treturn string The full path to the successfully written screenshot file -function screenshot.client(directory, prefix) +-- This is a wrapper constructor for a client window screenshot. See the main +-- constructor, new(), for details about the arguments. +-- +-- @staticfct awful.screenshot.client +-- @tparam[opt] table args Table of arguments to pass to the constructor +-- @treturn screenshot The screenshot object +function module.client(args) -- Looking at the properties and functions available, I'm not sure it is -- wise to allow a "target" client argument, but if we want to add it as -- arg 3 (which will match the screen ordering), we can. - - local c - local dir, prfx - local date_time, fname - - dir, prfx = configure_call(directory, prefix) - c = capi.client.focus - - if c and dir and prfx then - date_time = tostring(os.date("%Y%m%d%H%M%S")) - fname = dir .. prfx .. date_time .. ".png" - gears.surface(c.content):write_to_png(fname) - return fname - end + local args = (type(args) == "table" and args) or {} + args.method = "client" + return module(args) end --- Launch an interactive snipper tool to take cropped shots -- --- @function screenshot.snipper --- @tparam[opt] string directory The directory path in which to save screenshots --- @tparam[opt] string prefix The prefix prepended to the screenshot filenames --- @tparam[opt] function onsuccess_cb A callback to be run upon successful writing --- of the screenshot file with a single argument of the path --- to the screenshot file. -function screenshot.snipper(directory, prefix, onsuccess_cb) - local dir, prfx - local date_time - - dir, prfx = configure_call(directory, prefix) - - if dir and prfx then - - mg_dir = dir - mg_prfx = prfx - - capi.mousegrabber.run(mg_callback, "crosshair") - - if onsuccess_cb and type(onsuccess_cb) == "function" then - mg_onsuccess_cb = onsuccess_cb - end - - return - - end +-- This is a wrapper constructor for a snipper tool screenshot. See the main +-- constructor, new(), for details about the arguments. +-- +-- @staticfct awful.screenshot.snipper +-- @tparam[opt] table args Table of arguments to pass to the constructor +-- @treturn screenshot The screenshot object +function module.snipper(args) + local args = (type(args) == "table" and args) or {} + args.method = "snipper" + return module(args) end --- Take a cropped screenshot of a defined geometry -- --- @function screenshot.snip --- @tparam table geom The geometry of the cropped screenshot --- @tparam[opt] string directory The directory path in which to save screenshots --- @tparam[opt] string prefix The prefix prepended to the screenshot filenames --- @treturn string The full path to the successfully written screenshot file -function screenshot.snip(geom, directory, prefix) - local dir, prfx - local root_w, root_h - local root_intrsct - local date_time, fname - local snip_surf - - root_w, root_h = root.size() - dir, prfx = configure_call(directory, prefix) - - if dir and prfx and geom and - geom.x and geom.y and geom.width and geom.height then - - if type(geom.x) == "number" and type(geom.y) == "number" and - type(geom.width) == "number" and type(geom.height) == "number" then - - root_intrsct = gears.geometry.rectangle.get_intersection(geom, - {x = 0, y = 0, - width = root_w, height = root_h}) - snip_surf = crop_shot(root_intrsct.x, root_intrsct.y, - root_intrsct.width, root_intrsct.height) - - date_time = tostring(os.date("%Y%m%d%H%M%S")) - fname = dir .. prfx .. date_time .. ".png" - gears.surface(snip_surf):write_to_png(fname) - - return fname - - end - - end +-- This is a wrapper constructor for a snip screenshot (defined geometry). See +-- the main constructor, new(), for details about the arguments. +-- +-- @staticfct awful.screenshot.snip +-- @tparam[opt] table args Table of arguments to pass to the constructor +-- @treturn screenshot The screenshot object +function module.snip(args) + local args = (type(args) == "table" and args) or {} + args.method = "snip" + return module(args) end -return screenshot +-- Various accessors for the screenshot object returned by any public +-- module method. + +--- Get screenshot directory property +-- +-- @property directory +function screenshot:get_directory() + return self._private.directory +end + +--- Set screenshot directory property +-- +-- @property directory +-- @tparam string directory The path to the screenshot directory +function screenshot:set_directory(directory) + if type(directory) == "string" then + local dir = check_directory(directory) + if dir then + self._private.directory = dir + end + end +end + +--- Get screenshot prefix property +-- +-- @property prefix +function screenshot:get_prefix() + return self._private.prefix +end + +--- Set screenshot prefix property +-- +-- @property prefix +-- @tparam string prefix The prefix prepended to screenshot files names. +function screenshot:set_prefix(prefix) + if type(prefix) == "string" then + local prfx = check_prefix(prefix) + if prfx then + self._private.prefix = prfx + end + end +end + +--- Get screenshot filepath +-- +-- @property filepath +function screenshot:get_filepath() + return self._private.filepath +end + +--- Set screenshot filepath +-- +-- @property filepath +-- @tparam[opt] string fp The full path to the filepath +function screenshot:set_filepath(fp) + self:filepath_builder({filepath = fp}) +end + +--- Get screenshot method name +-- +-- @property method_name +function screenshot:get_method_name() + return self._private.method_name +end + +--- Get screenshot screen +-- +-- @property screen +function screenshot:get_screen() + if self.method_name == "screen" then + return self._private.screen + else + return nil + end +end + +--- Get screenshot client +-- +-- @property client +function screenshot:get_client() + if self.method_name == "client" then + return self._private.client + else + return nil + end +end + +--- Get screenshot geometry +-- +-- @property geometry +function screenshot:get_geometry() + return self._private.geometry +end + +--- Get screenshot surface +-- +-- @property surface +function screenshot:get_surface() + return self._private.surface +end + +-- Methods for the screenshot object returned from taking a screenshot. + +--- Set the filepath to save a screenshot +-- +-- @function awful.screenshot:filepath_builder +-- @tparam[opt] table args Table with the filepath parameters +function screenshot:filepath_builder(args) + + local args = (type(args) == "table" and args) or {} + + local filepath = args.filepath + local directory = args.directory + local prefix = args.prefix + + + if filepath and check_filepath(filepath) then + + directory, prefix = parse_filepath(filepath) + + elseif directory or prefix then + + if directory and type(directory) == "string" then + directory = check_directory(directory) + elseif self.directory then + directory = self._private.directory -- The setter ran check_directory() + else + directory = get_default_dir() + end + + if prefix and type(prefix) == "string" then + prefix = check_prefix(prefix) + elseif self.prefix then + prefix = self._private.prefix -- The setter ran check_prefix() + else + prefix = get_default_prefix() + end + + directory, prefix, filepath = make_filepath(directory, prefix) + + elseif self.filepath and check_filepath(self.filepath) then + + filepath = self.filepath + directory, prefix = parse_filepath(filepath) + + else + + if directory and type(directory) == "string" then + directory = check_directory(directory) + elseif self.directory then + directory = self._private.directory -- The setter ran check_directory() + else + directory = get_default_dir() + end + + if prefix and type(prefix) == "string" then + prefix = check_prefix(prefix) + elseif self.prefix then + prefix = self._private.prefix -- The setter ran check_prefix() + else + prefix = get_default_prefix() + end + + directory, prefix, filepath = make_filepath(directory, prefix) + + end + + if filepath then + self._private.directory = directory -- These have already + self._private.prefix = prefix -- been checked. + self._private.filepath = filepath + end + +end + +--- Save screenshot +-- +-- @function awful.screenshot:save +-- @tparam[opt] table args Table with the filepath parameters +function screenshot:save(args) + + self:filepath_builder(args) + + if self._private.surface and self.filepath then + self._private.surface:write_to_png(self.filepath) + end + +end + +--- Screenshot constructor - it is possible to call this directly, but it is +-- recommended to use the helper constructors, such as awful.screenshot.root +-- +-- Possible args include: +-- directory [string]: the path to the screenshot directory +-- prefix [string]: the prefix to prepend to screenshot file names +-- frame_color: the color of the frame for a snipper tool +-- method: the method of screenshot to take (i.e. root window, etc). +-- screen: the screen for a physical screen screenshot +-- on_success_cb: the callback to run on the screenshot taken with +-- a snipper tool +-- geometry: a geometry object for a snip screenshot +-- +-- @constructorfct awful.screenshot.new +-- @tparam[opt] table args Table with the constructor parameters +local function new(_, args) + + local args = (type(args) == "table" and args) or {} + local ss = gears.object({ + enable_auto_signals = true, + enable_properties = true + }) + + dir, prfx = configure_path(args.directory, args.prefix) + + local fclr = nil + if args.frame_color then + fclr = gears.color(args.frame_color) + if not fclr then + fclr = frame_color + end + else + fclr = frame_color + end + + local mthd = nil + local mthd_name = "" + if screenshot_methods[args.method] then + mthd = screenshot_methods[args.method] + mthd_name = args.method + else + mthd = screenshot_methods.default + mthd_name = default_method_name + end + + local scrn, clnt, mg_cb, geom + if mthd_name == "screen" then + scrn = args.screen + elseif mthd_name == "client" then + clnt = args.client + elseif mthd_name == "snipper" then + mg_cb = args.on_success_cb + elseif mthd_name == "snip" then + geom = args.geometry + end + + if type(args.on_success_cb) == "function" then + mg_cb = args.on_success_cb + else + mg_cb = default_on_success_cb + end + + ss._private = { + directory = dir, + prefix = prfx, + filepath = nil, + method_name = mthd_name, + frame_color = fclr, + screen = scrn, + client = clnt, + on_success_cb = mg_cb, + geometry = geom, + surface = nil + } + + gears.table.crush(ss, screenshot, true) + + return mthd(ss) + +end + +return setmetatable(module, {__call = new}) diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 954a37d9f..7e9e544e7 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -1,6 +1,7 @@ -- This test suite tests the various screenshot related APIs. -- -- Credit: https://www.reddit.com/r/awesomewm/comments/i6nf7z/need_help_writing_a_color_picker_widget_using_lgi/ +local awful = require("awful") local wibox = require("wibox") local spawn = require("awful.spawn") local gsurface = require("gears.surface") @@ -167,4 +168,69 @@ table.insert(steps, function() return true end) +--Check root window with awful.screenshot module +table.insert(steps, function() + --Make sure client from last test is gone + if #client.get() ~= 0 then return end + local ss = awful.screenshot.root() + local img = ss.surface + + if get_pixel(img, 100, 100) ~= "#00ff00" then return end + if get_pixel(img, 2, 2) ~= "#ff0000" then return end + + assert(get_pixel(img, 100, 100) == "#00ff00") + assert(get_pixel(img, 199, 199) == "#00ff00") + assert(get_pixel(img, 201, 201) ~= "#00ff00") + + assert(get_pixel(img, 2, 2) == "#ff0000") + assert(get_pixel(img, root_width - 2, 2) == "#ff0000") + assert(get_pixel(img, 2, root_height - 2) == "#ff0000") + assert(get_pixel(img, root_width - 2, root_height - 2) == "#ff0000") + + return true +end) + +-- Check the screen.content +table.insert(steps, function() + for s in screen do + + local ss = awful.screenshot.screen({screen = s}) + local img = ss.surface + + assert(get_pixel(img, geo.x + 4, geo.y + 4) == "#ff0000") + assert(get_pixel(img, geo.x + geo.width - 4, geo.y + 4) == "#ff0000") + assert(get_pixel(img, geo.x + 4, geo.y + geo.height - 4) == "#ff0000") + assert(get_pixel(img, geo.x + geo.width - 4, geo.y + geo.height - 4) == "#ff0000") + + end + + -- Spawn for the client.content test + assert(#client.get() == 0) + spawn(tiny_client) + + return true +end) + +-- Check the client.content +table.insert(steps, function() + + if #client.get() ~= 1 then return end + + local c = client.get()[1] + local ss = awful.screenshot.client({client = c}) + local img = ss.surface + + if get_pixel(img, math.floor(geo.width / 2), math.floor(geo.height / 2)) ~= "#0000ff" then + return + end + + -- Make sure the process finishes. Just `c:kill()` only + -- closes the window. Adding some handlers to the GTK "app" + -- created some unwanted side effects in the CI. + awesome.kill(c.pid, 9) + + return true + +end) + require("_runner").run_steps(steps) From 1fdefad75047dba823faed701d5673b1935f94dc Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Thu, 3 Feb 2022 21:20:50 -0800 Subject: [PATCH 05/28] Fix the failed test case and some issues regarding variable naming. There will still be one more commit to replace some of the screenshot module code with available functionality from gears (e.g. the filesystem module). --- lib/awful/screenshot.lua | 211 +++++++++++++++++++------------------- tests/test-screenshot.lua | 42 +++++--- 2 files changed, 132 insertions(+), 121 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 3258430e7..89257ed7a 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -26,9 +26,9 @@ local screenshot = {} local screenshot_methods = {} -- Configuration data -local ss_dir = nil -local ss_prfx = nil -local frame_color = gears.color("#000000") +local module_default_directory = nil +local module_default_prefix = nil +local module_default_frame_color = gears.color("#000000") local initialized = nil @@ -37,13 +37,13 @@ local initialized = nil -- Can be expanded to read X environment variables or configuration files. local function get_default_dir() -- This can be expanded - local d = os.getenv("HOME") + local home_dir = os.getenv("HOME") - if d then - d = string.gsub(d, '/*$', '/') .. 'Images/' - if os.execute("bash -c \"if [ -d \\\"" .. d .. "\\\" -a -w \\\"" .. d .. - "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then - return d + if home_dir then + home_dir = string.gsub(home_dir, '/*$', '/') .. 'Images/' + if os.execute("bash -c \"if [ -d \\\"" .. home_dir .. "\\\" -a -w \\\"" .. + home_dir .. "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then + return home_dir end end @@ -79,10 +79,11 @@ local function check_directory(directory) -- If we use single quotes, we only need to deal with single quotes - (I -- promise that's meaningful if you think about it from a bash perspective) - local dir = string.gsub(directory, "'", "'\\'\\\\\\'\\''") + local bash_esc_dir = string.gsub(directory, "'", "'\\'\\\\\\'\\''") - if os.execute("bash -c 'if [ -d '\\''" .. dir .. "'\\'' -a -w '\\''" .. - dir .. "'\\'' ] ; then exit 0 ; else exit 1 ; fi'") then + if os.execute("bash -c 'if [ -d '\\''" .. bash_esc_dir .. + "'\\'' -a -w '\\''" .. bash_esc_dir .. + "'\\'' ] ; then exit 0 ; else exit 1 ; fi'") then return directory else -- Currently returns nil if the requested directory string cannot be used. @@ -119,11 +120,11 @@ local function check_filepath(filepath) -- options, this function is basically unchanged, except for trying -- to match a regex for each supported format (e.g. (png|jpe?g|gif|bmp)) -- NOTE: we should maybe make this case insensitive too? - local fname_start, fname_end = string.find(filepath,'/[^/]+%.png$') + local filename_start, filename_end = string.find(filepath,'/[^/]+%.png$') - if fname_start and fname_end then - local directory = string.sub(filepath, 1, fname_start) - local file_name = string.sub(filepath, fname_start + 1, fname_end) + if filename_start and filename_end then + local directory = string.sub(filepath, 1, filename_start) + local file_name = string.sub(filepath, filename_start + 1, filename_end) directory = check_directory(directory) if directory then return directory .. file_name @@ -141,18 +142,19 @@ end local function parse_filepath(filepath) -- Same remark as above about adding more image formats in the future - local fname_start, fname_end = string.find(filepath,'/[^/]+%.png$') + local filename_start, filename_end = string.find(filepath,'/[^/]+%.png$') - if fname_start and fname_end then + if filename_start and filename_end then - local directory = string.sub(filepath, 1, fname_start) - local file_name = string.sub(filepath, fname_start + 1, fname_end) + if filename_end - filename_start > 14 + 4 then - if fname_end - fname_start > 14 + 4 then + local directory = string.sub(filepath, 1, filename_start) + local file_name = string.sub(filepath, filename_start + 1, filename_end) local base_name = string.sub(file_name, 1, #file_name - 4) - -- Is there a repeat count in Lua patterns, like {14} for most regexes? - local date_start, date_end = string.find(base_name, '%d%d%d%d%d%d%d%d%d%d%d%d%d%d$') + -- Is there a repeat count in Lua, like %d{14}, as for most regexes? + local date_start, date_end = + string.find(base_name, '%d%d%d%d%d%d%d%d%d%d%d%d%d%d$') if date_start and date_end then return directory, string.sub(base_name, 1, date_start - 1) @@ -178,45 +180,45 @@ end -- unitialized state and without passing any arguments. local function configure_path(directory, prefix) - local d, p + local _directory, _prefix if not initialized or directory then - d = check_directory(directory) - if not d then + _directory = check_directory(directory) + if not _directory then return end else - d = ss_dir + _directory = module_default_directory end if not initialized or prefix then - p = check_prefix(prefix) - if not p then + _prefix = check_prefix(prefix) + if not _prefix then return end else - p = ss_prfx + _prefix = module_default_prefix end -- In the case of taking a screenshot in an unitialized state, store the -- configuration parameters and toggle to initialized going forward. if not initialized then - ss_dir = d - ss_prfx = p + module_default_directory = _directory + module_default_prefix = _prefix initilialized = true end - return d, p + return _directory, _prefix end local function make_filepath(directory, prefix) - local dir, prfx + local _directory, _prefix local date_time = tostring(os.date("%Y%m%d%H%M%S")) - dir, prfx = configure_path(directory, prefix) - local filepath = dir .. prfx .. date_time .. ".png" - return dir, prfx, filepath + _directory, _prefix = configure_path(directory, prefix) + local filepath = _directory .. _prefix .. date_time .. ".png" + return _directory, _prefix, filepath end -- Internal function to do the actual work of taking a cropped screenshot @@ -262,7 +264,7 @@ function show_frame(ss, geo) ss._private.frame = ss._private.frame or wibox { ontop = true, - bg = frame_color + bg = ss._private.frame_color } local frame = ss._private.frame @@ -404,8 +406,6 @@ function screen_screenshot(ss) if ss.screen then if type(ss.screen) == "number" then ss._private.screen = capi.screen[ss.screen] or aw_screen.focused() - elseif ss.screen.index and type(ss.screen.index) == "number" then - ss._private.screen = capi.screen[ss.screen.index] or aw_screen.focused() end else ss._private.screen = aw_screen.focused() @@ -421,12 +421,12 @@ end -- Internal function executed when a client window screenshot is taken. function client_screenshot(ss) + -- -- note the use of _private becuase client has no setter - if ss.client and ss.client.content then - -- If the passed client arg has a content property we'll take it. - else + if not ss.client then ss._private.client = capi.client.focus end + ss._private.geometry = ss.client:geometry() ss:filepath_builder() ss._private.surface = gears.surface(ss.client.content) -- surface has no setter @@ -504,12 +504,13 @@ local default_method_name = "root" -- @tparam[opt] table args Table passed with the configuration data. -- @treturn boolean true or false depending on success function module.set_defaults(args) + local tmp tmp = check_directory(args.directory) if tmp then - ss_dir = tmp + module_default_directory = tmp else initialized = nil return false @@ -518,7 +519,7 @@ function module.set_defaults(args) tmp = check_prefix(args.prefix) if tmp then - ss_prfx = tmp + module_default_prefix = tmp else -- Should be unreachable as the default will always be taken initialized = nil @@ -536,6 +537,7 @@ function module.set_defaults(args) end return true + end --- Take root window screenshots. @@ -557,7 +559,7 @@ end -- This is a wrapper constructor for a physical screen screenshot. See the main -- constructor, new(), for details about the arguments. -- --- @staticfct awful.screenshot.screen +-- @constructorfct awful.screenshot.screen -- @tparam[opt] table args Table of arguments to pass to the constructor -- @treturn screenshot The screenshot object function module.screen(args) @@ -571,7 +573,7 @@ end -- This is a wrapper constructor for a client window screenshot. See the main -- constructor, new(), for details about the arguments. -- --- @staticfct awful.screenshot.client +-- @constructorfct awful.screenshot.client -- @tparam[opt] table args Table of arguments to pass to the constructor -- @treturn screenshot The screenshot object function module.client(args) @@ -588,7 +590,7 @@ end -- This is a wrapper constructor for a snipper tool screenshot. See the main -- constructor, new(), for details about the arguments. -- --- @staticfct awful.screenshot.snipper +-- @constructorfct awful.screenshot.snipper -- @tparam[opt] table args Table of arguments to pass to the constructor -- @treturn screenshot The screenshot object function module.snipper(args) @@ -602,7 +604,7 @@ end -- This is a wrapper constructor for a snip screenshot (defined geometry). See -- the main constructor, new(), for details about the arguments. -- --- @staticfct awful.screenshot.snip +-- @constructorfct awful.screenshot.snip -- @tparam[opt] table args Table of arguments to pass to the constructor -- @treturn screenshot The screenshot object function module.snip(args) @@ -627,9 +629,9 @@ end -- @tparam string directory The path to the screenshot directory function screenshot:set_directory(directory) if type(directory) == "string" then - local dir = check_directory(directory) - if dir then - self._private.directory = dir + local checked_dir = check_directory(directory) + if checked_dir then + self._private.directory = checked_dir end end end @@ -647,9 +649,9 @@ end -- @tparam string prefix The prefix prepended to screenshot files names. function screenshot:set_prefix(prefix) if type(prefix) == "string" then - local prfx = check_prefix(prefix) - if prfx then - self._private.prefix = prfx + local checked_prefix = check_prefix(prefix) + if checked_prefix then + self._private.prefix = checked_prefix end end end @@ -758,17 +760,13 @@ function screenshot:filepath_builder(args) else - if directory and type(directory) == "string" then - directory = check_directory(directory) - elseif self.directory then + if self.directory then directory = self._private.directory -- The setter ran check_directory() else directory = get_default_dir() end - if prefix and type(prefix) == "string" then - prefix = check_prefix(prefix) - elseif self.prefix then + if self.prefix then prefix = self._private.prefix -- The setter ran check_prefix() else prefix = get_default_prefix() @@ -801,17 +799,17 @@ function screenshot:save(args) end --- Screenshot constructor - it is possible to call this directly, but it is --- recommended to use the helper constructors, such as awful.screenshot.root +-- recommended to use the helper constructors, such as awful.screenshot.root -- -- Possible args include: -- directory [string]: the path to the screenshot directory -- prefix [string]: the prefix to prepend to screenshot file names --- frame_color: the color of the frame for a snipper tool --- method: the method of screenshot to take (i.e. root window, etc). --- screen: the screen for a physical screen screenshot --- on_success_cb: the callback to run on the screenshot taken with +-- frame_color [gears color]: the color of the frame for a snipper tool +-- method [string]: the method of screenshot to take (i.e. root window, etc). +-- screen [index or screen]: the screen for a physical screen screenshot +-- on_success_cb [function]: the callback to run on the screenshot taken with -- a snipper tool --- geometry: a geometry object for a snip screenshot +-- geometry [gears geometry]: a geometry object for a snip screenshot -- -- @constructorfct awful.screenshot.new -- @tparam[opt] table args Table with the constructor parameters @@ -823,61 +821,60 @@ local function new(_, args) enable_properties = true }) - dir, prfx = configure_path(args.directory, args.prefix) + local directory, prefix = configure_path(args.directory, args.prefix) - local fclr = nil - if args.frame_color then - fclr = gears.color(args.frame_color) - if not fclr then - fclr = frame_color - end - else - fclr = frame_color - end - - local mthd = nil - local mthd_name = "" + local method = nil + local method_name = "" if screenshot_methods[args.method] then - mthd = screenshot_methods[args.method] - mthd_name = args.method + method = screenshot_methods[args.method] + method_name = args.method else - mthd = screenshot_methods.default - mthd_name = default_method_name + method = screenshot_methods.default + method_name = default_method_name end - local scrn, clnt, mg_cb, geom - if mthd_name == "screen" then - scrn = args.screen - elseif mthd_name == "client" then - clnt = args.client - elseif mthd_name == "snipper" then - mg_cb = args.on_success_cb - elseif mthd_name == "snip" then + local screen, client, on_success_cb, frame_color, geometry + if method_name == "screen" then + screen = args.screen + elseif method_name == "client" then + client = args.client + elseif method_name == "snipper" then + + if args.frame_color then + frame_color = gears.color(args.frame_color) + if not frame_color then + frame_color = module_default_frame_color + end + else + frame_color = module_default_frame_color + end + + if type(args.on_success_cb) == "function" then + on_success_cb = args.on_success_cb + else + on_success_cb = default_on_success_cb + end + + elseif method_name == "snip" then geom = args.geometry end - if type(args.on_success_cb) == "function" then - mg_cb = args.on_success_cb - else - mg_cb = default_on_success_cb - end - ss._private = { - directory = dir, - prefix = prfx, + directory = directory, + prefix = prefix, filepath = nil, - method_name = mthd_name, - frame_color = fclr, - screen = scrn, - client = clnt, - on_success_cb = mg_cb, - geometry = geom, + method_name = method_name, + frame_color = frame_color, + screen = screen, + client = client, + on_success_cb = on_success_cb, + geometry = geometry, surface = nil } gears.table.crush(ss, screenshot, true) - return mthd(ss) + return method(ss) end diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 7e9e544e7..fc2f845f8 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -11,7 +11,6 @@ local gdk = lgi.require('Gdk', '3.0') local capi = { root = _G.root } - -- Dummy blue client for the client.content test -- the lua_executable portion may need to get ironed out. I need to specify 5.3 local lua_executable = os.getenv("LUA") @@ -34,6 +33,10 @@ Gtk:main{...} local tiny_client = { lua_executable, "-e", string.format( tiny_client_code_template, client_dim, client_dim)} +-- We need a directory to configure the screenshot library to use even through +-- we never actually write a file. +local fake_screenshot_dir = "/tmp" + -- Split in the screen into 2 distict screens. screen[1]:split() @@ -107,7 +110,7 @@ end local steps = {} --- Check the whole root window. +-- Check the whole root window with root.content() table.insert(steps, function() local root_width, root_height = root.size() local img = copy_to_image_surface(capi.root.content(), root_width, @@ -128,17 +131,24 @@ table.insert(steps, function() return true end) --- Check the screen.content +-- Check screen.content table.insert(steps, function() for s in screen do local geo = s.geometry local img = copy_to_image_surface(s.content, geo.width, geo.height) + assert(get_pixel(img, 4, 4) == "#ff0000") + assert(get_pixel(img, geo.width - 4, 4) == "#ff0000") + assert(get_pixel(img, 4, geo.height - 4) == "#ff0000") + assert(get_pixel(img, geo.width - 4, geo.height - 4) == "#ff0000") + +--[[ assert(get_pixel(img, geo.x + 4, geo.y + 4) == "#ff0000") assert(get_pixel(img, geo.x + geo.width - 4, geo.y + 4) == "#ff0000") assert(get_pixel(img, geo.x + 4, geo.y + geo.height - 4) == "#ff0000") assert(get_pixel(img, geo.x + geo.width - 4, geo.y + geo.height - 4) == "#ff0000") +--]] end @@ -149,7 +159,7 @@ table.insert(steps, function() return true end) --- Check the client.content +-- Check client.content table.insert(steps, function() if #client.get() ~= 1 then return end local c = client.get()[1] @@ -168,11 +178,13 @@ table.insert(steps, function() return true end) ---Check root window with awful.screenshot module +--Check the root window with awful.screenshot.root() method table.insert(steps, function() --Make sure client from last test is gone if #client.get() ~= 0 then return end - local ss = awful.screenshot.root() + + local root_width, root_height = root.size() + local ss = awful.screenshot.root({directory = fake_screenshot_dir}) local img = ss.surface if get_pixel(img, 100, 100) ~= "#00ff00" then return end @@ -190,17 +202,18 @@ table.insert(steps, function() return true end) --- Check the screen.content +-- Check the awful.screenshot.screen() method table.insert(steps, function() for s in screen do - local ss = awful.screenshot.screen({screen = s}) + local geo = s.geometry + local ss = awful.screenshot.screen({screen = s, directory = fake_screenshot_dir}) local img = ss.surface - assert(get_pixel(img, geo.x + 4, geo.y + 4) == "#ff0000") - assert(get_pixel(img, geo.x + geo.width - 4, geo.y + 4) == "#ff0000") - assert(get_pixel(img, geo.x + 4, geo.y + geo.height - 4) == "#ff0000") - assert(get_pixel(img, geo.x + geo.width - 4, geo.y + geo.height - 4) == "#ff0000") + assert(get_pixel(img, 4, 4) == "#ff0000") + assert(get_pixel(img, geo.width - 4, 4) == "#ff0000") + assert(get_pixel(img, 4, geo.height - 4) == "#ff0000") + assert(get_pixel(img, geo.width - 4, geo.height - 4) == "#ff0000") end @@ -211,13 +224,14 @@ table.insert(steps, function() return true end) --- Check the client.content +-- Check the awful.screenshot.client() method table.insert(steps, function() if #client.get() ~= 1 then return end local c = client.get()[1] - local ss = awful.screenshot.client({client = c}) + local geo = c:geometry() + local ss = awful.screenshot.client({client = c, directory = fake_screenshot_dir}) local img = ss.surface if get_pixel(img, math.floor(geo.width / 2), math.floor(geo.height / 2)) ~= "#0000ff" then From 52e0242ec608f568c2c6248fd64c1b795100a5fc Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Sun, 17 Jul 2022 09:39:02 -0700 Subject: [PATCH 06/28] Move some filesystem related logic in the screenshot lib to gears request from awesome maintainers. --- lib/awful/screenshot.lua | 95 ++++++++++++++++++---------------------- lib/gears/filesystem.lua | 12 +++++ 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 89257ed7a..66c4e7427 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -41,8 +41,9 @@ local function get_default_dir() if home_dir then home_dir = string.gsub(home_dir, '/*$', '/') .. 'Images/' - if os.execute("bash -c \"if [ -d \\\"" .. home_dir .. "\\\" -a -w \\\"" .. - home_dir .. "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then + if gears.filesystem.dir_writable(home_dir) then +-- if os.execute("bash -c \"if [ -d \\\"" .. home_dir .. "\\\" -a -w \\\"" .. +-- home_dir .. "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then return home_dir end end @@ -81,9 +82,7 @@ local function check_directory(directory) -- promise that's meaningful if you think about it from a bash perspective) local bash_esc_dir = string.gsub(directory, "'", "'\\'\\\\\\'\\''") - if os.execute("bash -c 'if [ -d '\\''" .. bash_esc_dir .. - "'\\'' -a -w '\\''" .. bash_esc_dir .. - "'\\'' ] ; then exit 0 ; else exit 1 ; fi'") then + if gears.filesystem.dir_writable(directory) then return directory else -- Currently returns nil if the requested directory string cannot be used. @@ -160,9 +159,9 @@ local function parse_filepath(filepath) return directory, string.sub(base_name, 1, date_start - 1) end - end + return directory - return directory + end end @@ -391,7 +390,7 @@ function default_on_success_cb(ss) end -- Internal function exected when a root window screenshot is taken. -function root_screenshot(ss) +function screenshot_methods.root_screenshot(ss) local w, h = root.size() ss._private.geometry = {x = 0, y = 0, width = w, height = h} ss:filepath_builder() @@ -400,7 +399,7 @@ function root_screenshot(ss) end -- Internal function executed when a physical screen screenshot is taken. -function screen_screenshot(ss) +function screenshot_methods.screen_screenshot(ss) -- note the use of _private because screen has no setter if ss.screen then @@ -420,7 +419,7 @@ function screen_screenshot(ss) end -- Internal function executed when a client window screenshot is taken. -function client_screenshot(ss) +function screenshot_methods.client_screenshot(ss) -- -- note the use of _private becuase client has no setter if not ss.client then @@ -434,7 +433,7 @@ function client_screenshot(ss) end -- Internal function executed when a snipper screenshot tool is launched. -function snipper_screenshot(ss) +function screenshot_methods.snipper_screenshot(ss) if type(ss._private.on_success_cb) ~= "function" then ss._private.on_success_cb = default_on_success_cb -- the cb has no setter @@ -449,7 +448,7 @@ end -- Internal function executed when a snip screenshow (a defined geometry) is -- taken. -function snip_screenshot(ss) +function screenshot_methods.snip_screenshot(ss) local root_w, root_h local root_intrsct @@ -483,19 +482,13 @@ function snip_screenshot(ss) end --- Screenshot methods -screenshot_methods.root = root_screenshot -screenshot_methods.screen = screen_screenshot -screenshot_methods.client = client_screenshot -screenshot_methods.snipper = snipper_screenshot -screenshot_methods.snip = snip_screenshot -- Default method is root screenshot_methods.default = root_screenshot local default_method_name = "root" -- Module routines ---- Function to initialize the screenshot library +--- Function to initialize the screenshot library. -- -- Currently only sets the screenshot directory, the filename, prefix and the -- snipper tool outline color. More initialization can be added as the API @@ -554,7 +547,7 @@ function module.root(args) return module(args) end ---- Take physical screen screenshots +--- Take physical screen screenshots. -- -- This is a wrapper constructor for a physical screen screenshot. See the main -- constructor, new(), for details about the arguments. @@ -568,7 +561,7 @@ function module.screen(args) return module(args) end ---- Take client window screenshots +--- Take client window screenshots. -- -- This is a wrapper constructor for a client window screenshot. See the main -- constructor, new(), for details about the arguments. @@ -585,7 +578,7 @@ function module.client(args) return module(args) end ---- Launch an interactive snipper tool to take cropped shots +--- Launch an interactive snipper tool to take cropped shots. -- -- This is a wrapper constructor for a snipper tool screenshot. See the main -- constructor, new(), for details about the arguments. @@ -599,7 +592,7 @@ function module.snipper(args) return module(args) end ---- Take a cropped screenshot of a defined geometry +--- Take a cropped screenshot of a defined geometry. -- -- This is a wrapper constructor for a snip screenshot (defined geometry). See -- the main constructor, new(), for details about the arguments. @@ -616,16 +609,15 @@ end -- Various accessors for the screenshot object returned by any public -- module method. ---- Get screenshot directory property +--- Get screenshot directory property. -- -- @property directory function screenshot:get_directory() return self._private.directory end ---- Set screenshot directory property +--- Set screenshot directory property. -- --- @property directory -- @tparam string directory The path to the screenshot directory function screenshot:set_directory(directory) if type(directory) == "string" then @@ -636,16 +628,15 @@ function screenshot:set_directory(directory) end end ---- Get screenshot prefix property +--- Get screenshot prefix property. -- -- @property prefix function screenshot:get_prefix() return self._private.prefix end ---- Set screenshot prefix property +--- Set screenshot prefix property. -- --- @property prefix -- @tparam string prefix The prefix prepended to screenshot files names. function screenshot:set_prefix(prefix) if type(prefix) == "string" then @@ -656,16 +647,15 @@ function screenshot:set_prefix(prefix) end end ---- Get screenshot filepath +--- Get screenshot filepath. -- -- @property filepath function screenshot:get_filepath() return self._private.filepath end ---- Set screenshot filepath +--- Set screenshot filepath. -- --- @property filepath -- @tparam[opt] string fp The full path to the filepath function screenshot:set_filepath(fp) self:filepath_builder({filepath = fp}) @@ -678,7 +668,7 @@ function screenshot:get_method_name() return self._private.method_name end ---- Get screenshot screen +--- Get screenshot screen. -- -- @property screen function screenshot:get_screen() @@ -689,7 +679,7 @@ function screenshot:get_screen() end end ---- Get screenshot client +--- Get screenshot client. -- -- @property client function screenshot:get_client() @@ -700,14 +690,14 @@ function screenshot:get_client() end end ---- Get screenshot geometry +--- Get screenshot geometry. -- -- @property geometry function screenshot:get_geometry() return self._private.geometry end ---- Get screenshot surface +--- Get screenshot surface. -- -- @property surface function screenshot:get_surface() @@ -716,9 +706,9 @@ end -- Methods for the screenshot object returned from taking a screenshot. ---- Set the filepath to save a screenshot +--- Set the filepath to save a screenshot. -- --- @function awful.screenshot:filepath_builder +-- @method awful.screenshot:filepath_builder -- @tparam[opt] table args Table with the filepath parameters function screenshot:filepath_builder(args) @@ -784,9 +774,9 @@ function screenshot:filepath_builder(args) end ---- Save screenshot +--- Save screenshot. -- --- @function awful.screenshot:save +-- @method awful.screenshot:save -- @tparam[opt] table args Table with the filepath parameters function screenshot:save(args) @@ -798,21 +788,20 @@ function screenshot:save(args) end ---- Screenshot constructor - it is possible to call this directly, but it is +--- Screenshot constructor - it is possible to call this directly, but it is. -- recommended to use the helper constructors, such as awful.screenshot.root -- --- Possible args include: --- directory [string]: the path to the screenshot directory --- prefix [string]: the prefix to prepend to screenshot file names --- frame_color [gears color]: the color of the frame for a snipper tool --- method [string]: the method of screenshot to take (i.e. root window, etc). --- screen [index or screen]: the screen for a physical screen screenshot --- on_success_cb [function]: the callback to run on the screenshot taken with --- a snipper tool --- geometry [gears geometry]: a geometry object for a snip screenshot --- --- @constructorfct awful.screenshot.new --- @tparam[opt] table args Table with the constructor parameters +-- @constructorfct awful.screenshot +-- @tparam[opt] string args.directory The path to the screenshot directory. +-- @tparam[opt] string args.prefix The prefix to prepend to screenshot file names. +-- @tparam[opt] color args.frame_color The color of the frame for a snipper tool as +-- a gears color. +-- @tparam[opt] string args.method The method of screenshot to take (i.e. root window, etc). +-- @tparam[opt] screen args.screen The screen for a physical screen screenshot. Can be a +-- screen object of number. +-- @tparam[opt] function args.on_success_cb: the callback to run on the screenshot taken +-- with a snipper tool. +-- @tparam[opt] geometry args.geometry A gears geometry object for a snip screenshot. local function new(_, args) local args = (type(args) == "table" and args) or {} diff --git a/lib/gears/filesystem.lua b/lib/gears/filesystem.lua index 64aa43ae0..8019db572 100644 --- a/lib/gears/filesystem.lua +++ b/lib/gears/filesystem.lua @@ -83,6 +83,18 @@ function filesystem.dir_readable(path) gfileinfo:get_attribute_boolean("access::can-read") end +--- Check if a path exists, is writable and a directory. +-- @tparam string path The directory path. +-- @treturn boolean True if path exists and is writable. +-- @staticfct gears.filesystem.dir_writable +function filesystem.dir_writable(path) + local gfile = Gio.File.new_for_path(path) + local gfileinfo = gfile:query_info("standard::type,access::can-write", + Gio.FileQueryInfoFlags.NONE) + return gfileinfo and gfileinfo:get_file_type() == "DIRECTORY" and + gfileinfo:get_attribute_boolean("access::can-write") +end + --- Check if a path is a directory. -- @tparam string path The directory path -- @treturn boolean True if path exists and is a directory. From 8f7750e6f2e3b7fd2d0ca64669ca4430e6f9a4b0 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Mon, 22 Aug 2022 21:26:28 -0700 Subject: [PATCH 07/28] Add tests for the snipper tool and the snip (defined geometry) routine to increase test coverage. --- lib/awful/screenshot.lua | 14 ++--- tests/test-screenshot.lua | 110 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 66c4e7427..d0550a65f 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -390,7 +390,7 @@ function default_on_success_cb(ss) end -- Internal function exected when a root window screenshot is taken. -function screenshot_methods.root_screenshot(ss) +function screenshot_methods.root(ss) local w, h = root.size() ss._private.geometry = {x = 0, y = 0, width = w, height = h} ss:filepath_builder() @@ -399,7 +399,7 @@ function screenshot_methods.root_screenshot(ss) end -- Internal function executed when a physical screen screenshot is taken. -function screenshot_methods.screen_screenshot(ss) +function screenshot_methods.screen(ss) -- note the use of _private because screen has no setter if ss.screen then @@ -419,7 +419,7 @@ function screenshot_methods.screen_screenshot(ss) end -- Internal function executed when a client window screenshot is taken. -function screenshot_methods.client_screenshot(ss) +function screenshot_methods.client(ss) -- -- note the use of _private becuase client has no setter if not ss.client then @@ -433,7 +433,7 @@ function screenshot_methods.client_screenshot(ss) end -- Internal function executed when a snipper screenshot tool is launched. -function screenshot_methods.snipper_screenshot(ss) +function screenshot_methods.snipper(ss) if type(ss._private.on_success_cb) ~= "function" then ss._private.on_success_cb = default_on_success_cb -- the cb has no setter @@ -448,7 +448,7 @@ end -- Internal function executed when a snip screenshow (a defined geometry) is -- taken. -function screenshot_methods.snip_screenshot(ss) +function screenshot_methods.snip(ss) local root_w, root_h local root_intrsct @@ -483,7 +483,7 @@ function screenshot_methods.snip_screenshot(ss) end -- Default method is root -screenshot_methods.default = root_screenshot +screenshot_methods.default = screenshot_methods.root local default_method_name = "root" -- Module routines @@ -845,7 +845,7 @@ local function new(_, args) end elseif method_name == "snip" then - geom = args.geometry + geometry = args.geometry end ss._private = { diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index fc2f845f8..7e946d2e3 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -108,6 +108,17 @@ local function get_pixel(img, x, y) return "#" .. bytes:gsub('.', function(c) return ('%02x'):format(c:byte()) end) end +local snipper_success = nil +local function snipper_cb(ss) + local img = ss.surface + if img and get_pixel(img, 10, 10) == "#00ff00" then + snipper_success = "true" + return + else + snipper_success = "false" + end +end + local steps = {} -- Check the whole root window with root.content() @@ -247,4 +258,103 @@ table.insert(steps, function() end) +--Check the snipper toop with awful.screenshot.snipper() method +table.insert(steps, function() + --Make sure client from last test is gone + if #client.get() ~= 0 then return end + --Ensure mousegrabber is satisfied + root.fake_input("button_press",1) + return true +end) + +table.insert(steps, function() + root.fake_input("button_release",1) + return true +end) + +table.insert(steps, function() + --Ensure prior mouse presses go through + local t0 = os.time() + while os.time() - t0 < 1 do end + return true +end) + +table.insert(steps, function() + awful.screenshot.snipper({directory = fake_screenshot_dir, on_success_cb = snipper_cb}) + return true +end) + +table.insert(steps, function() + mouse.coords {x = 110, y = 110} + return true +end) + +table.insert(steps, function() + root.fake_input("button_press",1) + return true +end) + +table.insert(steps, function() + root.fake_input("button_release",1) + return true +end) + +table.insert(steps, function() + mouse.coords {x = 190, y = 190} + return true +end) + +table.insert(steps, function() + --Ensure prior mouse presses and movements go through + local t0 = os.time() + while os.time() - t0 < 1 do end + return true +end) + +table.insert(steps, function() + root.fake_input("button_press",1) + return true +end) + +table.insert(steps, function() + root.fake_input("button_release",1) + return true +end) + +table.insert(steps, function() + --Ensure prior mouse presses go through and callback runs + local t0 = os.time() + while os.time() - t0 < 1 do end + return true +end) + +table.insert(steps, function() + + --Check for success + if snipper_success then + if snipper_success == "true" then + return true + else + return false + end + else + return + end + + return true + +end) + +table.insert(steps, function() + + local ss = awful.screenshot.snip({geometry = {x = 100, y = 100, width = 100, height = 100}, + directory = fake_screenshot_dir}) + local img = ss.surface + if get_pixel(img, 10, 10) == "#00ff00" then + return true + else + return false + end +end) + require("_runner").run_steps(steps) From 75943e788b405511108b4ecbb8c0eff4f18b6f94 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Tue, 23 Aug 2022 21:38:29 -0700 Subject: [PATCH 08/28] Add some more test coverage to get this PR to the requisite 91%. --- lib/awful/screenshot.lua | 15 ++--- tests/test-screenshot.lua | 126 +++++++++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 19 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index d0550a65f..04f4054f5 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -42,8 +42,6 @@ local function get_default_dir() if home_dir then home_dir = string.gsub(home_dir, '/*$', '/') .. 'Images/' if gears.filesystem.dir_writable(home_dir) then --- if os.execute("bash -c \"if [ -d \\\"" .. home_dir .. "\\\" -a -w \\\"" .. --- home_dir .. "\\\" ] ; then exit 0 ; else exit 1 ; fi ;\"") then return home_dir end end @@ -78,10 +76,6 @@ local function check_directory(directory) -- Assure that we return exactly one trailing slash directory = string.gsub(directory, '/*$', '/') - -- If we use single quotes, we only need to deal with single quotes - (I - -- promise that's meaningful if you think about it from a bash perspective) - local bash_esc_dir = string.gsub(directory, "'", "'\\'\\\\\\'\\''") - if gears.filesystem.dir_writable(directory) then return directory else @@ -252,7 +246,7 @@ end -- cases. It did not seem appropriate to make that change as a part of the pull -- request that was creating the screenshot API, as it would clutter and -- confuse the change. -function show_frame(ss, geo) +local function show_frame(ss, geo) if not geo then if ss._private.frame then @@ -385,7 +379,7 @@ end -- Internal function to be passed as the default callback upon completion of -- the mousgrabber for the snipper if the user does not pass one. -function default_on_success_cb(ss) +local function default_on_success_cb(ss) ss:save() end @@ -498,9 +492,8 @@ local default_method_name = "root" -- @treturn boolean true or false depending on success function module.set_defaults(args) - local tmp - - tmp = check_directory(args.directory) + local args = (type(args) == "table" and args) or {} + local tmp = check_directory(args.directory) if tmp then module_default_directory = tmp diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 7e946d2e3..c64369075 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -189,13 +189,40 @@ table.insert(steps, function() return true end) ---Check the root window with awful.screenshot.root() method table.insert(steps, function() + --Make sure client from last test is gone if #client.get() ~= 0 then return end + awful.screenshot.set_defaults({}) + awful.screenshot.set_defaults({directory = "/dev/null", prefix = "Screenshot-", frame_color = "#000000"}) + awful.screenshot.set_defaults({directory = "~/"}) + awful.screenshot.set_defaults({directory = fake_screenshot_dir}) + + local ss = awful.screenshot.root() + local name_prfx = string.gsub(fake_screenshot_dir, "/*$", "/") .. "Screenshot-" + + local f, l = string.find(ss.filepath, name_prfx) + if f ~= 1 then + print("First if: " .. ss.filepath .. " : " .. name_prfx) + return false + end + + name_prfx = string.gsub(fake_screenshot_dir, "/*$", "/") .. "MyShot.png" + ss.filepath = name_prfx + + if ss.filepath ~= name_prfx then + return false + end + + return true + +end) + +--Check the root window with awful.screenshot.root() method +table.insert(steps, function() local root_width, root_height = root.size() - local ss = awful.screenshot.root({directory = fake_screenshot_dir}) + local ss = awful.screenshot.root() local img = ss.surface if get_pixel(img, 100, 100) ~= "#00ff00" then return end @@ -218,7 +245,7 @@ table.insert(steps, function() for s in screen do local geo = s.geometry - local ss = awful.screenshot.screen({screen = s, directory = fake_screenshot_dir}) + local ss = awful.screenshot.screen({screen = s}) local img = ss.surface assert(get_pixel(img, 4, 4) == "#ff0000") @@ -242,7 +269,7 @@ table.insert(steps, function() local c = client.get()[1] local geo = c:geometry() - local ss = awful.screenshot.client({client = c, directory = fake_screenshot_dir}) + local ss = awful.screenshot.client({client = c}) local img = ss.surface if get_pixel(img, math.floor(geo.width / 2), math.floor(geo.height / 2)) ~= "#0000ff" then @@ -280,7 +307,7 @@ table.insert(steps, function() end) table.insert(steps, function() - awful.screenshot.snipper({directory = fake_screenshot_dir, on_success_cb = snipper_cb}) + awful.screenshot.snipper({on_success_cb = snipper_cb}) return true end) @@ -345,10 +372,93 @@ table.insert(steps, function() end) -table.insert(steps, function() - local ss = awful.screenshot.snip({geometry = {x = 100, y = 100, width = 100, height = 100}, - directory = fake_screenshot_dir}) +--Check the snipper collapse and cancel +table.insert(steps, function() + --Make sure client from last test is gone + if #client.get() ~= 0 then return end + --Ensure mousegrabber is satisfied + root.fake_input("button_press",1) + return true +end) + +table.insert(steps, function() + root.fake_input("button_release",1) + return true +end) + +table.insert(steps, function() + --Ensure prior mouse presses go through + local t0 = os.time() + while os.time() - t0 < 1 do end + return true +end) + +table.insert(steps, function() + awful.screenshot.snipper({on_success_cb = snipper_cb}) + return true +end) + +table.insert(steps, function() + mouse.coords {x = 110, y = 110} + return true +end) + +table.insert(steps, function() + root.fake_input("button_press",1) + return true +end) + +table.insert(steps, function() + root.fake_input("button_release",1) + return true +end) + +table.insert(steps, function() + mouse.coords {x = 150, y = 150} + return true +end) + +table.insert(steps, function() + --Ensure prior mouse presses and movements go through + local t0 = os.time() + while os.time() - t0 < 1 do end + return true +end) + +table.insert(steps, function() + --Cause a rectangle collapse + mouse.coords {x = 150, y = 110} + return true +end) + +table.insert(steps, function() + --Ensure prior mouse presses and movements go through + local t0 = os.time() + while os.time() - t0 < 1 do end + return true +end) + +table.insert(steps, function() + --Cancel snipper tool + root.fake_input("button_press",3) + return true +end) + +table.insert(steps, function() + root.fake_input("button_release",3) + return true +end) + +table.insert(steps, function() + --Ensure prior mouse presses go through and callback runs + local t0 = os.time() + while os.time() - t0 < 1 do end + return true +end) + +table.insert(steps, function() + local ss = awful.screenshot.snip({geometry = {x = 100, y = 100, width = 100, height = 100}}) local img = ss.surface if get_pixel(img, 10, 10) == "#00ff00" then return true From 036b4551c74708351c5670b76dba38cb53289ab7 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Tue, 23 Aug 2022 21:48:04 -0700 Subject: [PATCH 09/28] Add more descriptive error message to screenshot test. --- tests/test-screenshot.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index c64369075..0680895f6 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -204,7 +204,7 @@ table.insert(steps, function() local f, l = string.find(ss.filepath, name_prfx) if f ~= 1 then - print("First if: " .. ss.filepath .. " : " .. name_prfx) + print("Failed first if: " .. ss.filepath .. " : " .. name_prfx) return false end @@ -212,6 +212,7 @@ table.insert(steps, function() ss.filepath = name_prfx if ss.filepath ~= name_prfx then + print("Failed second if: " .. ss.filepath .. " : " .. name_prfx) return false end From 1eb83713418cbb5577d8426d043be69c5df042f3 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Tue, 23 Aug 2022 22:09:55 -0700 Subject: [PATCH 10/28] Error message addition to debug lua 5.1 and 5.2 out on GitHub jobs. --- lib/awful/screenshot.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 04f4054f5..875c37f81 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -711,13 +711,17 @@ function screenshot:filepath_builder(args) local directory = args.directory local prefix = args.prefix + print("Entering filepath_builder(args)") + print(args) if filepath and check_filepath(filepath) then + print("First if in filepath_builder") directory, prefix = parse_filepath(filepath) elseif directory or prefix then + print("Second if in filepath_builder") if directory and type(directory) == "string" then directory = check_directory(directory) elseif self.directory then @@ -738,11 +742,13 @@ function screenshot:filepath_builder(args) elseif self.filepath and check_filepath(self.filepath) then + print("Third if in filepath_builder") filepath = self.filepath directory, prefix = parse_filepath(filepath) else + print("Else in filepath_builder") if self.directory then directory = self._private.directory -- The setter ran check_directory() else From ea3f82bdf081aa611bf4c6ab0bb6a544ed8de985 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Tue, 23 Aug 2022 22:18:40 -0700 Subject: [PATCH 11/28] Debugging Lua 5.1 and 5.2 on GitHub. --- lib/awful/screenshot.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 875c37f81..357235bb0 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -712,7 +712,9 @@ function screenshot:filepath_builder(args) local prefix = args.prefix print("Entering filepath_builder(args)") - print(args) + print(args.filepath) + print(args.directory) + print(args.prefix) if filepath and check_filepath(filepath) then From 00925a3039013e9dfaa74eb6fba6c556f5501405 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Tue, 23 Aug 2022 22:33:55 -0700 Subject: [PATCH 12/28] Keep debugging lua on GitHub. --- tests/test-screenshot.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 0680895f6..b671dac82 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -194,23 +194,31 @@ table.insert(steps, function() --Make sure client from last test is gone if #client.get() ~= 0 then return end + print("Set blank defaults") awful.screenshot.set_defaults({}) + print("Set explicit defaults") awful.screenshot.set_defaults({directory = "/dev/null", prefix = "Screenshot-", frame_color = "#000000"}) + print("Set tilde default") awful.screenshot.set_defaults({directory = "~/"}) + print("Set directory default") awful.screenshot.set_defaults({directory = fake_screenshot_dir}) + print("Taking root shot") local ss = awful.screenshot.root() local name_prfx = string.gsub(fake_screenshot_dir, "/*$", "/") .. "Screenshot-" + print("Checking assigned filepath") local f, l = string.find(ss.filepath, name_prfx) if f ~= 1 then print("Failed first if: " .. ss.filepath .. " : " .. name_prfx) return false end + print("Assigning new filepath") name_prfx = string.gsub(fake_screenshot_dir, "/*$", "/") .. "MyShot.png" ss.filepath = name_prfx + print("Checking assigned filepath") if ss.filepath ~= name_prfx then print("Failed second if: " .. ss.filepath .. " : " .. name_prfx) return false From fb77f90959055363614c08faf01b95ab247d438a Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Tue, 23 Aug 2022 22:40:11 -0700 Subject: [PATCH 13/28] Zeroing in on lua 5.1 and 5.2 bug on GitHub. --- lib/awful/screenshot.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 357235bb0..4e9261ff8 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -764,6 +764,10 @@ function screenshot:filepath_builder(args) end directory, prefix, filepath = make_filepath(directory, prefix) + print("Finished building filepath:") + print(directory) + print(prefix) + print(filepath) end From e8584f47b42a69c8650c721b5abb5ff6c01ba781 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Tue, 23 Aug 2022 22:52:03 -0700 Subject: [PATCH 14/28] Debugging lua 5.1, lua 5.2 error. --- lib/awful/screenshot.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 4e9261ff8..4cace492f 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -60,6 +60,8 @@ end -- Adding 'mkdir -p' functionality can be considered in the future. local function check_directory(directory) + print("Enter check_directory()") + print(directory) if directory and type(directory) == "string" then -- Fully qualify a "~/" path or a relative path to $HOME. One might argue @@ -73,15 +75,20 @@ local function check_directory(directory) directory = string.gsub(os.getenv("HOME"), "/*$", "/") .. directory end + print("After first sanitation -- " .. directory) + -- Assure that we return exactly one trailing slash directory = string.gsub(directory, '/*$', '/') + print("After second sanitation -- " .. directory) if gears.filesystem.dir_writable(directory) then + print("Returning directory") return directory else -- Currently returns nil if the requested directory string cannot be used. -- This can be swapped to a silent fallback to the default directory if -- desired. It is debatable which way is better. + print("Returning nil") return nil end @@ -89,6 +96,7 @@ local function check_directory(directory) -- No directory argument means use the default. Technically an outrageously -- invalid argument (i.e. not even a string) currently falls back to the -- default as well. + print("Returning default directory") return get_default_dir() end From 9d9fedf9458da146a7f99a57367e6d02e12cff37 Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Wed, 24 Aug 2022 00:17:41 -0700 Subject: [PATCH 15/28] Likely solution to lua 5.1 and 5.2 bug. --- lib/awful/screenshot.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 4cace492f..b18adf6be 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -40,7 +40,7 @@ local function get_default_dir() local home_dir = os.getenv("HOME") if home_dir then - home_dir = string.gsub(home_dir, '/*$', '/') .. 'Images/' + home_dir = string.gsub(home_dir, '/*$', '/', 1) .. 'Images/' if gears.filesystem.dir_writable(home_dir) then return home_dir end @@ -70,15 +70,15 @@ local function check_directory(directory) -- is arguably unexpected behavior. if string.find(directory, "^~/") then directory = string.gsub(directory, "^~/", - string.gsub(os.getenv("HOME"), "/*$", "/")) + string.gsub(os.getenv("HOME"), "/*$", "/", 1)) elseif string.find(directory, "^[^/]") then - directory = string.gsub(os.getenv("HOME"), "/*$", "/") .. directory + directory = string.gsub(os.getenv("HOME"), "/*$", "/", 1) .. directory end print("After first sanitation -- " .. directory) -- Assure that we return exactly one trailing slash - directory = string.gsub(directory, '/*$', '/') + directory = string.gsub(directory, '/*$', '/', 1) print("After second sanitation -- " .. directory) if gears.filesystem.dir_writable(directory) then From 37ec7cd1739d61cfc0ef00c958e4ee4a70f9f03c Mon Sep 17 00:00:00 2001 From: Brian Sobulefsky Date: Wed, 24 Aug 2022 00:45:49 -0700 Subject: [PATCH 16/28] Clean up logging from Lua 5.1 and 5.2 error debug. Add a little more test coverage. --- lib/awful/screenshot.lua | 21 --------------------- tests/test-screenshot.lua | 38 +++++++++++++++++++++++--------------- 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index b18adf6be..a765d0f79 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -60,8 +60,6 @@ end -- Adding 'mkdir -p' functionality can be considered in the future. local function check_directory(directory) - print("Enter check_directory()") - print(directory) if directory and type(directory) == "string" then -- Fully qualify a "~/" path or a relative path to $HOME. One might argue @@ -75,20 +73,15 @@ local function check_directory(directory) directory = string.gsub(os.getenv("HOME"), "/*$", "/", 1) .. directory end - print("After first sanitation -- " .. directory) - -- Assure that we return exactly one trailing slash directory = string.gsub(directory, '/*$', '/', 1) - print("After second sanitation -- " .. directory) if gears.filesystem.dir_writable(directory) then - print("Returning directory") return directory else -- Currently returns nil if the requested directory string cannot be used. -- This can be swapped to a silent fallback to the default directory if -- desired. It is debatable which way is better. - print("Returning nil") return nil end @@ -96,7 +89,6 @@ local function check_directory(directory) -- No directory argument means use the default. Technically an outrageously -- invalid argument (i.e. not even a string) currently falls back to the -- default as well. - print("Returning default directory") return get_default_dir() end @@ -719,19 +711,12 @@ function screenshot:filepath_builder(args) local directory = args.directory local prefix = args.prefix - print("Entering filepath_builder(args)") - print(args.filepath) - print(args.directory) - print(args.prefix) - if filepath and check_filepath(filepath) then - print("First if in filepath_builder") directory, prefix = parse_filepath(filepath) elseif directory or prefix then - print("Second if in filepath_builder") if directory and type(directory) == "string" then directory = check_directory(directory) elseif self.directory then @@ -752,13 +737,11 @@ function screenshot:filepath_builder(args) elseif self.filepath and check_filepath(self.filepath) then - print("Third if in filepath_builder") filepath = self.filepath directory, prefix = parse_filepath(filepath) else - print("Else in filepath_builder") if self.directory then directory = self._private.directory -- The setter ran check_directory() else @@ -772,10 +755,6 @@ function screenshot:filepath_builder(args) end directory, prefix, filepath = make_filepath(directory, prefix) - print("Finished building filepath:") - print(directory) - print(prefix) - print(filepath) end diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index b671dac82..4be2e66ca 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -194,33 +194,33 @@ table.insert(steps, function() --Make sure client from last test is gone if #client.get() ~= 0 then return end - print("Set blank defaults") + local fake_screenshot_dir = string.gsub(fake_screenshot_dir, "/*$", "/", 1) + awful.screenshot.set_defaults({}) - print("Set explicit defaults") awful.screenshot.set_defaults({directory = "/dev/null", prefix = "Screenshot-", frame_color = "#000000"}) - print("Set tilde default") awful.screenshot.set_defaults({directory = "~/"}) - print("Set directory default") awful.screenshot.set_defaults({directory = fake_screenshot_dir}) - print("Taking root shot") local ss = awful.screenshot.root() - local name_prfx = string.gsub(fake_screenshot_dir, "/*$", "/") .. "Screenshot-" + local name_prfx = fake_screenshot_dir .. "Screenshot-" - print("Checking assigned filepath") local f, l = string.find(ss.filepath, name_prfx) if f ~= 1 then - print("Failed first if: " .. ss.filepath .. " : " .. name_prfx) + print("Failed autogenerate filename: " .. ss.filepath .. " : " .. name_prfx) return false end - print("Assigning new filepath") - name_prfx = string.gsub(fake_screenshot_dir, "/*$", "/") .. "MyShot.png" + name_prfx = fake_screenshot_dir .. "MyShot.png" ss.filepath = name_prfx - print("Checking assigned filepath") if ss.filepath ~= name_prfx then - print("Failed second if: " .. ss.filepath .. " : " .. name_prfx) + print("Failed assign filename: " .. ss.filepath .. " : " .. name_prfx) + return false + end + + ss:filepath_builder({directory = fake_screenshot_dir, prefix = "Screenshot-"}) + if ss.directory ~= fake_screenshot_dir or ss.prefix ~= "Screenshot-" then + print("Failed assign directory/prefix: " .. ss.directory .. " : " .. ss.prefix) return false end @@ -230,13 +230,11 @@ end) --Check the root window with awful.screenshot.root() method table.insert(steps, function() + local root_width, root_height = root.size() local ss = awful.screenshot.root() local img = ss.surface - if get_pixel(img, 100, 100) ~= "#00ff00" then return end - if get_pixel(img, 2, 2) ~= "#ff0000" then return end - assert(get_pixel(img, 100, 100) == "#00ff00") assert(get_pixel(img, 199, 199) == "#00ff00") assert(get_pixel(img, 201, 201) ~= "#00ff00") @@ -246,11 +244,20 @@ table.insert(steps, function() assert(get_pixel(img, 2, root_height - 2) == "#ff0000") assert(get_pixel(img, root_width - 2, root_height - 2) == "#ff0000") + if ss.screen ~= nil or ss.client ~= nil then + print("Returned non nil screen or client for root screenshot") + print(ss.screen) + print(ss.client) + return false + end + return true + end) -- Check the awful.screenshot.screen() method table.insert(steps, function() + for s in screen do local geo = s.geometry @@ -269,6 +276,7 @@ table.insert(steps, function() spawn(tiny_client) return true + end) -- Check the awful.screenshot.client() method From aca9136b02aa4f9e46d73bdafc9aa8193ecfedcb Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 01:10:40 -0700 Subject: [PATCH 17/28] screenshot: Cleanup the API. * Fix all warnings * Make indentation consistent across the file * Simplify/unify the validation (use `error()` rather than try to silently fix problems, move to setters) and fallback code (move to the getters rather than... everywhere) * Write the documentation * Finish the tests * Remove unnecessary constructors * Remove path builder because it belongs in `gears.filesystem` and wasn't really used anyway * Add more properties and a beautiful variable to replace `set_defaults` and hardcoded values. * Replace callbacks with signals (common pattern in modern AwesomeWM APIs) * Moved from `os.date` to GLib and some boilerplate code to make Debian devs less angry * Changed the way the snipping tool selection wibox works. Now it freeze the screenshot before the selection. The old way had a bunch of side effects for things like Qt/GTK comboboxes. It also could crash. --- lib/awful/screenshot.lua | 1181 +++++++++++++++---------------------- tests/test-screenshot.lua | 172 ++---- 2 files changed, 540 insertions(+), 813 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index a765d0f79..e130f7e46 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -6,212 +6,139 @@ -- @inputmodule awful.screenshot --------------------------------------------------------------------------- + -- Grab environment we need -local capi = { root = root, - screen = screen, - client = client, - mousegrabber = mousegrabber } -local aw_screen = require("awful.screen") -local gears = require("gears") +local capi = { + root = root, + screen = screen, + client = client, + mousegrabber = mousegrabber +} + +local gears = require("gears") local beautiful = require("beautiful") -local wibox = require("wibox") -local cairo = require("lgi").cairo -local naughty = require("naughty") +local wibox = require("wibox") +local cairo = require("lgi").cairo +local abutton = require("awful.button") +local akey = require("awful.key") +local glib = require("lgi").GLib +local datetime = glib.DateTime +local timezone = glib.TimeZone -- The module to be returned -local module = { mt = {} } --- The screenshow object created by a call to the screenshow module -local screenshot = {} --- The various methods of taking a screenshow (root window, client, etc). -local screenshot_methods = {} +local module = { mt = {}, _screenshot_methods = {} } +local screenshot_validation = {} --- Configuration data -local module_default_directory = nil -local module_default_prefix = nil -local module_default_frame_color = gears.color("#000000") -local initialized = nil +local datetime_obj = datetime.new_now - --- Internal function to get the default screenshot directory --- --- Can be expanded to read X environment variables or configuration files. -local function get_default_dir() - -- This can be expanded - local home_dir = os.getenv("HOME") - - if home_dir then - home_dir = string.gsub(home_dir, '/*$', '/', 1) .. 'Images/' - if gears.filesystem.dir_writable(home_dir) then - return home_dir +-- When $SOURCE_DATE_EPOCH and $SOURCE_DIRECTORY are both set, then this code is +-- most likely being run by the test runner. Ensure reproducible dates. +local source_date_epoch = tonumber(os.getenv("SOURCE_DATE_EPOCH")) +if source_date_epoch and os.getenv("SOURCE_DIRECTORY") then + datetime_obj = function() + return datetime.new_from_unix_utc(source_date_epoch) end - end - - return nil end --- Internal function to get the default filename prefix -local function get_default_prefix() - return "Screenshot-" +-- Generate a date string with the same format as the `textclock` and also with +-- support Debian reproducible builds. +local function get_date(format) + return datetime_obj(timezone.new_local()):format(format) end --- Internal function to check a directory string for existence and writability. --- This only checks if the requested directory exists and is writeable, not if --- such a directory is legal (i.e. it behaves as 'mkdir' and not 'mkdir -p'). --- Adding 'mkdir -p' functionality can be considered in the future. -local function check_directory(directory) +-- Convert to a real image surface so it can be added to an imagebox. +local function to_surface(raw_surface, width, height) + local img = cairo.ImageSurface(cairo.Format.RGB24, width, height) + local cr = cairo.Context(img) + cr:set_source_surface(gears.surface(raw_surface)) + cr:paint() - if directory and type(directory) == "string" then + return img +end +--- The screenshot interactive frame color. +-- @beautiful beautiful.screenshot_frame_color +-- @tparam[opt="#ff0000"] color screenshot_frame_color + +--- The screenshot interactive frame shape. +-- @beautiful beautiful.screenshot_frame_shape +-- @tparam[opt=gears.shape.rectangle] shape screenshot_frame_shape + +function screenshot_validation.directory(directory) -- Fully qualify a "~/" path or a relative path to $HOME. One might argue -- that the relative path should fully qualify to $PWD, but for a window -- manager this should be the same thing to the extent that anything else -- is arguably unexpected behavior. if string.find(directory, "^~/") then - directory = string.gsub(directory, "^~/", + directory = string.gsub(directory, "^~/", string.gsub(os.getenv("HOME"), "/*$", "/", 1)) elseif string.find(directory, "^[^/]") then - directory = string.gsub(os.getenv("HOME"), "/*$", "/", 1) .. directory + directory = string.gsub(os.getenv("HOME"), "/*$", "/", 1) .. directory end -- Assure that we return exactly one trailing slash directory = string.gsub(directory, '/*$', '/', 1) - if gears.filesystem.dir_writable(directory) then - return directory - else - -- Currently returns nil if the requested directory string cannot be used. - -- This can be swapped to a silent fallback to the default directory if - -- desired. It is debatable which way is better. - return nil + -- Add a trailing "/" if none is present. + if directory:sub(-1) ~= "/" then + directory = directory .. "/" end - else - -- No directory argument means use the default. Technically an outrageously - -- invalid argument (i.e. not even a string) currently falls back to the - -- default as well. - return get_default_dir() - end + -- If the directory eixsts, but cannot be used, print and error. + if gears.filesystem.is_dir(directory) and not gears.filesystem.dir_writable(directory) then + gears.debug.print_error("`"..directory.. "` is not writable.") + end + return directory end -- Internal function to sanitize a prefix string -- -- Currently only strips all path separators ('/'). Allows for empty prefix. -local function check_prefix(prefix) - -- Maybe add more sanitizing eventually - if prefix and type(prefix) == "string" then - prefix = string.gsub(prefix, '/', '') +function screenshot_validation.prefix(prefix) + if prefix:match("[/.]") then + gears.debug.print_error("`"..prefix.. + "` is not a valid prefix because it contains `/` or `.`") + end + return prefix - end - return get_default_prefix() end --- Internal routine to verify that a filepath is valid. -local function check_filepath(filepath) - - -- This module is forcing png for now. In the event of adding more - -- options, this function is basically unchanged, except for trying - -- to match a regex for each supported format (e.g. (png|jpe?g|gif|bmp)) - -- NOTE: we should maybe make this case insensitive too? - local filename_start, filename_end = string.find(filepath,'/[^/]+%.png$') - - if filename_start and filename_end then - local directory = string.sub(filepath, 1, filename_start) - local file_name = string.sub(filepath, filename_start + 1, filename_end) - directory = check_directory(directory) - if directory then - return directory .. file_name - else - return nil +-- Internal routine to verify that a file_path is valid. +function screenshot_validation.file_path(file_path) + if gears.filesystem.file_readable(file_path) then + gears.debug.print_error("`"..file_path.."` already exist.") end - end - - return nil + return file_path end --- Internal function to attempt to parse a filepath into directory and prefix. --- Returns directory, prefix if both, directory if no prefix, or nil if invalid -local function parse_filepath(filepath) - - -- Same remark as above about adding more image formats in the future - local filename_start, filename_end = string.find(filepath,'/[^/]+%.png$') - - if filename_start and filename_end then - - if filename_end - filename_start > 14 + 4 then - - local directory = string.sub(filepath, 1, filename_start) - local file_name = string.sub(filepath, filename_start + 1, filename_end) - - local base_name = string.sub(file_name, 1, #file_name - 4) - -- Is there a repeat count in Lua, like %d{14}, as for most regexes? - local date_start, date_end = - string.find(base_name, '%d%d%d%d%d%d%d%d%d%d%d%d%d%d$') - - if date_start and date_end then - return directory, string.sub(base_name, 1, date_start - 1) - end - - return directory - +-- Warn about invalid geometries. +function screenshot_validation.geometry(geo) + for _, part in ipairs {"x", "y", "width", "height" } do + if not geo[part] then + gears.debug.print_error("The screenshot geometry must be a table with ".. + "`x`, `y`, `width` and `height`" + ) + break + end end - end - - return nil - + return geo end --- Internal function to configure the directory/prefix pair for any call to the --- screenshot API. --- --- This supports using a different directory or filename prefix for a particular --- use by passing the optional arguments. In the case of an initalized object --- with no arguments passed, the stored configuration parameters are quickly --- returned. This also technically allows the user to take a screenshot in an --- unitialized state and without passing any arguments. -local function configure_path(directory, prefix) - - local _directory, _prefix - - if not initialized or directory then - _directory = check_directory(directory) - if not _directory then - return - end - else - _directory = module_default_directory - end - - - if not initialized or prefix then - _prefix = check_prefix(prefix) - if not _prefix then - return - end - else - _prefix = module_default_prefix - end - - -- In the case of taking a screenshot in an unitialized state, store the - -- configuration parameters and toggle to initialized going forward. - if not initialized then - module_default_directory = _directory - module_default_prefix = _prefix - initilialized = true - end - - return _directory, _prefix - +function screenshot_validation.screen(scr) + return capi.screen[scr] end -local function make_filepath(directory, prefix) - local _directory, _prefix - local date_time = tostring(os.date("%Y%m%d%H%M%S")) - _directory, _prefix = configure_path(directory, prefix) - local filepath = _directory .. _prefix .. date_time .. ".png" - return _directory, _prefix, filepath +local function make_file_name(self, method) + local date_time = get_date(self.date_format) + method = method and method.."_" or "" + return self.prefix .. "_" .. method .. date_time .. ".png" +end + +local function make_file_path(self, method) + return self.directory .. (self._private.file_name or make_file_name(self, method)) end -- Internal function to do the actual work of taking a cropped screenshot @@ -220,383 +147,225 @@ end -- all data checking. This function was made to combine the interactive sipper -- run by the mousegrabber and the programmatically defined snip function, -- though there may be other uses. -local function crop_shot(x, y, width, height) +local function crop_shot(source, geo) + local target = source:create_similar(cairo.Content.COLOR, geo.width, geo.height) - local source, target, cr - - source = gears.surface(root.content()) - target = source:create_similar(cairo.Content.COLOR, - width, height) - - cr = cairo.Context(target) - cr:set_source_surface(source, -x, -y) - cr:rectangle(0, 0, width, height) - cr:fill() - - return target + local cr = cairo.Context(target) + cr:set_source_surface(source, -geo.x, -geo.y) + cr:rectangle(0, 0, geo.width, geo.height) + cr:fill() + return target end - --- Internal function used by the snipper mousegrabber to update the frame outline --- of the current state of the cropped screenshot +-- Internal function used by the interactive mode mousegrabber to update the +-- frame outline of the current state of the cropped screenshot -- -- This function is largely a copy of awful.mouse.snap.show_placeholder(geo), -- so it is probably a good idea to create a utility routine to handle both use -- cases. It did not seem appropriate to make that change as a part of the pull -- request that was creating the screenshot API, as it would clutter and -- confuse the change. -local function show_frame(ss, geo) +local function show_frame(self, surface, geo) + local col = self._private.frame_color + or beautiful.screenshot_frame_color + or "#ff0000" - if not geo then - if ss._private.frame then - ss._private.frame.visible = false - end - return - end + local shape = self.frame_shape + or beautiful.screenshot_frame_shape + or gears.shape.rectangle - ss._private.frame = ss._private.frame or wibox { - ontop = true, - bg = ss._private.frame_color - } + local w, h = root.size() - local frame = ss._private.frame + self._private.selection_widget = wibox.widget { + border_width = 3, + border_color = col, + shape = shape, + color = "transparent", + visible = false, + widget = wibox.widget.separator + } + self._private.selection_widget.point = {x=0, y=0} + self._private.selection_widget.fit = function() return 0,0 end - frame:geometry(geo) + self._private.canvas_widget = wibox.widget { + widget = wibox.layout.manual + } - -- Perhaps the preexisting image of frame can be reused? I tried but could - -- not get it to work. I am not sure if there is a performance penalty - -- incurred by making a new Cairo ImageSurface each execution. - local img = cairo.ImageSurface(cairo.Format.A1, geo.width, geo.height) - local cr = cairo.Context(img) + self._private.imagebox = wibox.widget { + image = surface, + widget = wibox.widget.imagebox + } - cr:set_operator(cairo.Operator.CLEAR) - cr:set_source_rgba(0,0,0,1) - cr:paint() - cr:set_operator(cairo.Operator.SOURCE) - cr:set_source_rgba(1,1,1,1) - - local line_width = 1 - cr:set_line_width(beautiful.xresources.apply_dpi(line_width)) - - cr:translate(line_width,line_width) - gears.shape.partially_rounded_rect(cr, geo.width - 2*line_width, - geo.height - 2*line_width, - false, false, false, false, nil) - - cr:stroke() - - frame.shape_bounding = img._native - img:finish() - - frame.visible = true + self._private.imagebox.point = geo + self._private.canvas_widget:add(self._private.imagebox) + self._private.canvas_widget:add(self._private.selection_widget) + self._private.frame = wibox { + ontop = true, + x = 0, + y = 0, + width = w, + height = h, + widget = self._private.canvas_widget, + visible = true, + } end +--- Emitted when the interactive succeed. +-- @signal snipping::success +-- @tparam awful.screenshot self + +--- Emitted when the interactive is cancelled. +-- @signal snipping::cancelled +-- @tparam awful.screenshot self +-- @tparam string reason Either `"mouse_button"`, `"key"`, or `"too_small"`. + -- Internal function that generates the callback to be passed to the --- mousegrabber that implements the snipper. +-- mousegrabber that implements the interactive mode. -- --- The snipper tool is basically a mousegrabber, which takes a single function --- of one argument, representing the mouse state data. This is a simple --- starting point that hard codes the snipper tool as being a two press design --- using button 1, with button 3 functioning as the cancel. These aspects of --- the interface can be made into parameters at some point passed as arguments. -local function mk_mg_callback(ss) +-- The interactive tool is basically a mousegrabber, which takes a single function +-- of one argument, representing the mouse state data. +local function start_snipping(self, surface, geometry, method) + self._private.mg_first_pnt = {} - ss._private.mg_first_pnt = {} + local accept_buttons, reject_buttons = {}, {} - local function ret_mg_callback(mouse_data) - - if mouse_data["buttons"][3] then - if ss._private.frame and ss._private.frame.visible then - show_frame(ss) - end - ss._private.mg_first_pnt = nil - ss._private.frame = nil - return false + --TODO support modifiers. + for _, btn in ipairs(self.accept_buttons) do + accept_buttons[btn.button] = true end - - if ss._private.mg_first_pnt[1] then - - local min_x, max_x, min_y, max_y - min_x = math.min(ss._private.mg_first_pnt[1], mouse_data["x"]) - max_x = math.max(ss._private.mg_first_pnt[1], mouse_data["x"]) - min_y = math.min(ss._private.mg_first_pnt[2], mouse_data["y"]) - max_y = math.max(ss._private.mg_first_pnt[2], mouse_data["y"]) - - -- Force a minimum size to the box - if max_x - min_x < 4 or max_y - min_y < 4 then - if frame and frame.visible then - show_frame(ss) - end - elseif not mouse_data["buttons"][1] then - show_frame(ss, {x = min_x, y = min_y, width = max_x - min_x, height = max_y - min_y}) - end - - if mouse_data["buttons"][1] then - - local snip_surf - local date_time - - if ss._private.frame and ss._private.frame.visible then - show_frame(ss) - end - - ss._private.frame = nil - ss._private.mg_first_pnt = nil - - -- This may fail gracefully anyway but require a minimum 3x3 of pixels - if min_x >= max_x-1 or min_y >= max_y-1 then - return false - end - - snip_surf = crop_shot(min_x, min_y, max_x - min_x, max_y - min_y) - ss:filepath_builder() - ss._private.surface = gears.surface(snip_surf) -- surface has no setter - - if ss._private.on_success_cb then - ss._private.on_success_cb(ss) - end - - return false - - end - - else - if mouse_data["buttons"][1] then - ss._private.mg_first_pnt[1] = mouse_data["x"] - ss._private.mg_first_pnt[2] = mouse_data["y"] - end + for _, btn in ipairs(self.reject_buttons) do + reject_buttons[btn.button] = true end - - return true - end + local pressed = false - return ret_mg_callback + show_frame(self, surface, geometry) -end + local function ret_mg_callback(mouse_data, accept, reject) + local frame = self._private.frame --- Internal function to be passed as the default callback upon completion of --- the mousgrabber for the snipper if the user does not pass one. -local function default_on_success_cb(ss) - ss:save() + for btn, status in pairs(mouse_data.buttons) do + accept = accept or (status and accept_buttons[btn]) + reject = reject or (status and reject_buttons[btn]) + end + + if reject then + frame.visible = false + self._private.frame, self._private.mg_first_pnt = nil, nil + self:emit_signal("snipping::cancelled", "mouse_button") + + return false + elseif pressed then + local min_x = math.min(self._private.mg_first_pnt[1], mouse_data.x) + local max_x = math.max(self._private.mg_first_pnt[1], mouse_data.x) + local min_y = math.min(self._private.mg_first_pnt[2], mouse_data.y) + local max_y = math.max(self._private.mg_first_pnt[2], mouse_data.y) + + local new_geo = { + x = min_x, + y = min_y, + width = max_x - min_x, + height = max_y - min_y, + } + + if not accept then + -- Released + + self._private.frame, self._private.mg_first_pnt = nil, nil + + -- This may fail gracefully anyway but require a minimum 3x3 of pixels + if min_x >= max_x-1 or min_y >= max_y-1 then + self:emit_signal("snipping::cancelled", "too_small") + return false + end + + self._private.selection_widget.visible = false + + self._private.surfaces[method] = { + surface = crop_shot(surface, new_geo), + geometry = new_geo + } + + self:emit_signal("snipping::success") + self:save() + + frame.visible = false + self._private.frame, self._private.mg_first_pnt = nil, nil + + return false + else + -- Update position + self._private.selection_widget.point.x = min_x + self._private.selection_widget.point.y = min_y + self._private.selection_widget.fit = function() + return new_geo.width, new_geo.height + end + self._private.selection_widget:emit_signal("widget::layout_changed") + self._private.canvas_widget:emit_signal("widget::redraw_needed") + end + elseif accept then + pressed = true + self._private.selection_widget.visible = true + self._private.selection_widget.point.x = mouse_data.x + self._private.selection_widget.point.y = mouse_data.y + self._private.mg_first_pnt[1] = mouse_data.x + self._private.mg_first_pnt[2] = mouse_data.y + end + + return true + end + + capi.mousegrabber.run(ret_mg_callback, self.cursor) end -- Internal function exected when a root window screenshot is taken. -function screenshot_methods.root(ss) - local w, h = root.size() - ss._private.geometry = {x = 0, y = 0, width = w, height = h} - ss:filepath_builder() - ss._private.surface = gears.surface(capi.root.content()) -- surface has no setter - return ss +function module._screenshot_methods.root() + local w, h = root.size() + return to_surface(capi.root.content(), w, h), {x = 0, y = 0, width = w, height = h} end -- Internal function executed when a physical screen screenshot is taken. -function screenshot_methods.screen(ss) - - -- note the use of _private because screen has no setter - if ss.screen then - if type(ss.screen) == "number" then - ss._private.screen = capi.screen[ss.screen] or aw_screen.focused() - end - else - ss._private.screen = aw_screen.focused() - end - - ss._private.geometry = ss.screen.geometry - ss:filepath_builder() - ss._private.surface = gears.surface(ss.screen.content) -- surface has no setter - - return ss - +function module._screenshot_methods.screen(self) + local geo = self.screen.geometry + return to_surface(self.screen.content, geo.width, geo.height), geo end -- Internal function executed when a client window screenshot is taken. -function screenshot_methods.client(ss) - -- - -- note the use of _private becuase client has no setter - if not ss.client then - ss._private.client = capi.client.focus - end +function module._screenshot_methods.client(self) + local c = self.client + local bw = c.border_width + local _, top_size = c:titlebar_top() + local _, right_size = c:titlebar_right() + local _, bottom_size = c:titlebar_bottom() + local _, left_size = c:titlebar_left() - ss._private.geometry = ss.client:geometry() - ss:filepath_builder() - ss._private.surface = gears.surface(ss.client.content) -- surface has no setter - return ss -end + local c_geo = c:geometry() --- Internal function executed when a snipper screenshot tool is launched. -function screenshot_methods.snipper(ss) - - if type(ss._private.on_success_cb) ~= "function" then - ss._private.on_success_cb = default_on_success_cb -- the cb has no setter - end - - local mg_callback = mk_mg_callback(ss) - capi.mousegrabber.run(mg_callback, "crosshair") - - return true + local actual_geo = { + x = c_geo.x + left_size + bw, + y = c_geo.y + top_size + bw, + width = c_geo.width - right_size - left_size, + height = c_geo.height - bottom_size - top_size, + } + return to_surface(c.content, actual_geo.width, actual_geo.height), actual_geo end -- Internal function executed when a snip screenshow (a defined geometry) is -- taken. -function screenshot_methods.snip(ss) +function module._screenshot_methods.geometry(self) + local root_w, root_h = root.size() - local root_w, root_h - local root_intrsct - local snip_surf + local root_intrsct = gears.geometry.rectangle.get_intersection(self.geometry, { + x = 0, + y = 0, + width = root_w, + height = root_h + }) - root_w, root_h = root.size() - - if not(ss.geometry and - type(ss.geometry.x) == "number" and - type(ss.geometry.y) == "number" and - type(ss.geometry.width) == "number" and - type(ss.geometry.height) == "number") then - - -- Default to entire root window. Also geometry has no setter. - ss._private.geometry = {x = 0, y = 0, width = root_w, height = root_h} - - end - - root_intrsct = gears.geometry.rectangle.get_intersection(ss.geometry, - {x = 0, y = 0, - width = root_w, - height = root_h}) - - snip_surf = crop_shot(root_intrsct.x, root_intrsct.y, - root_intrsct.width, root_intrsct.height) - - ss:filepath_builder() - ss._private.surface = gears.surface(snip_surf) -- surface has no setter - - return ss - -end - --- Default method is root -screenshot_methods.default = screenshot_methods.root -local default_method_name = "root" - --- Module routines - ---- Function to initialize the screenshot library. --- --- Currently only sets the screenshot directory, the filename, prefix and the --- snipper tool outline color. More initialization can be added as the API --- expands. --- @staticfct awful.screenshot.set_defaults --- @tparam[opt] table args Table passed with the configuration data. --- @treturn boolean true or false depending on success -function module.set_defaults(args) - - local args = (type(args) == "table" and args) or {} - local tmp = check_directory(args.directory) - - if tmp then - module_default_directory = tmp - else - initialized = nil - return false - end - - tmp = check_prefix(args.prefix) - - if tmp then - module_default_prefix = tmp - else - -- Should be unreachable as the default will always be taken - initialized = nil - return false - end - - -- Don't throw out prior init data if only color is misformed - initialized = true - - if args.frame_color then - tmp = gears.color(args.frame_color) - if tmp then - frame_color = tmp - end - end - - return true - -end - ---- Take root window screenshots. --- --- This is a wrapper constructor for a root window screenshot. See the main --- constructor, new(), for details about the arguments. --- --- @constructorfct awful.screenshot.root --- @tparam[opt] table args Table of arguments to pass to the constructor --- @treturn screenshot The screenshot object -function module.root(args) - local args = (type(args) == "table" and args) or {} - args.method = "root" - return module(args) -end - ---- Take physical screen screenshots. --- --- This is a wrapper constructor for a physical screen screenshot. See the main --- constructor, new(), for details about the arguments. --- --- @constructorfct awful.screenshot.screen --- @tparam[opt] table args Table of arguments to pass to the constructor --- @treturn screenshot The screenshot object -function module.screen(args) - local args = (type(args) == "table" and args) or {} - args.method = "screen" - return module(args) -end - ---- Take client window screenshots. --- --- This is a wrapper constructor for a client window screenshot. See the main --- constructor, new(), for details about the arguments. --- --- @constructorfct awful.screenshot.client --- @tparam[opt] table args Table of arguments to pass to the constructor --- @treturn screenshot The screenshot object -function module.client(args) - -- Looking at the properties and functions available, I'm not sure it is - -- wise to allow a "target" client argument, but if we want to add it as - -- arg 3 (which will match the screen ordering), we can. - local args = (type(args) == "table" and args) or {} - args.method = "client" - return module(args) -end - ---- Launch an interactive snipper tool to take cropped shots. --- --- This is a wrapper constructor for a snipper tool screenshot. See the main --- constructor, new(), for details about the arguments. --- --- @constructorfct awful.screenshot.snipper --- @tparam[opt] table args Table of arguments to pass to the constructor --- @treturn screenshot The screenshot object -function module.snipper(args) - local args = (type(args) == "table" and args) or {} - args.method = "snipper" - return module(args) -end - ---- Take a cropped screenshot of a defined geometry. --- --- This is a wrapper constructor for a snip screenshot (defined geometry). See --- the main constructor, new(), for details about the arguments. --- --- @constructorfct awful.screenshot.snip --- @tparam[opt] table args Table of arguments to pass to the constructor --- @treturn screenshot The screenshot object -function module.snip(args) - local args = (type(args) == "table" and args) or {} - args.method = "snip" - return module(args) + return crop_shot(module._screenshot_methods.root(self), root_intrsct), root_intrsct end -- Various accessors for the screenshot object returned by any public @@ -605,258 +374,288 @@ end --- Get screenshot directory property. -- -- @property directory -function screenshot:get_directory() - return self._private.directory -end - ---- Set screenshot directory property. --- --- @tparam string directory The path to the screenshot directory -function screenshot:set_directory(directory) - if type(directory) == "string" then - local checked_dir = check_directory(directory) - if checked_dir then - self._private.directory = checked_dir - end - end -end +-- @tparam[opt=os.getenv("HOME")] string directory +-- @propemits true false --- Get screenshot prefix property. -- -- @property prefix -function screenshot:get_prefix() - return self._private.prefix -end +-- @tparam[opt="Screenshot-"] string prefix +-- @propemits true false ---- Set screenshot prefix property. +--- Get screenshot file path. -- --- @tparam string prefix The prefix prepended to screenshot files names. -function screenshot:set_prefix(prefix) - if type(prefix) == "string" then - local checked_prefix = check_prefix(prefix) - if checked_prefix then - self._private.prefix = checked_prefix - end - end -end +-- @property file_path +-- @tparam[opt=self.directory..self.prefix..os.date()..".png"] string file_path +-- @propemits true false +-- @see file_name ---- Get screenshot filepath. +--- Get screenshot file name. -- --- @property filepath -function screenshot:get_filepath() - return self._private.filepath -end +-- @property file_name +-- @tparam[opt=self.prefix..os.date()..".png"] string file_name +-- @propemits true false +-- @see file_path ---- Set screenshot filepath. --- --- @tparam[opt] string fp The full path to the filepath -function screenshot:set_filepath(fp) - self:filepath_builder({filepath = fp}) -end +--- The date format used in the default suffix. +-- @property date_format +-- @tparam[opt="%Y%m%d%H%M%S"] string date_format +-- @see wibox.widget.textclock ---- Get screenshot method name +--- The cursor used in interactive mode. -- --- @property method_name -function screenshot:get_method_name() - return self._private.method_name -end +-- @property cursor +-- @tparam[opt="crosshair"] string cursor +-- @propemits true false + +--- Use the mouse to select an area (snipping tool). +-- +-- @property interactive +-- @tparam[opt=false] boolean interactive --- Get screenshot screen. -- -- @property screen -function screenshot:get_screen() - if self.method_name == "screen" then - return self._private.screen - else - return nil - end -end +-- @tparam[opt=nil] screen|nil screen +-- @see mouse.screen +-- @see awful.screen.focused +-- @see screen.primary --- Get screenshot client. -- -- @property client -function screenshot:get_client() - if self.method_name == "client" then - return self._private.client - else - return nil - end -end +-- @tparam[opt=nil] client|nil client +-- @see mouse.client +-- @see client.focus --- Get screenshot geometry. -- -- @property geometry -function screenshot:get_geometry() - return self._private.geometry -end +-- @tparam table geometry +-- @tparam table geometry.x +-- @tparam table geometry.y +-- @tparam table geometry.width +-- @tparam table geometry.height --- Get screenshot surface. -- --- @property surface -function screenshot:get_surface() - return self._private.surface -end - --- Methods for the screenshot object returned from taking a screenshot. - ---- Set the filepath to save a screenshot. +-- If none, or only one of: `screen`, `client` or `geometry` is provided, then +-- this screenshot object will have a single target image. While specifying +-- multiple target isn't recommended, you can use the `surfaces` properties +-- to access them. -- --- @method awful.screenshot:filepath_builder --- @tparam[opt] table args Table with the filepath parameters -function screenshot:filepath_builder(args) +-- Note that this is empty until either `save` or `refresh` is called. +-- +-- @property surface +-- @tparam nil|image surface +-- @propertydefault `nil` if the screenshot hasn't been taken yet, a `gears.surface` +-- compatible image otherwise. +-- @readonly +-- @see surfaces - local args = (type(args) == "table" and args) or {} +--- Get screenshot surfaces. +-- +-- When multiple methods are enabled for the same screenshot, then it will +-- have multiple images. This property exposes each image individually. +-- +-- Note that this is empty until either `save` or `refresh` is called. Also +-- note that you should make multiple `awful.screenshot` objects rather than +-- put multiple targets in the same object. +-- +-- @property surfaces +-- @tparam[opt={}] table surfaces +-- @tparam[opt=nil] image surfaces.root The screenshot of all screens. This is +-- the default if none of `client`, screen` or `geometry` have been set. +-- @tparam[opt=nil] image surfaces.client The surface for the `client` property. +-- @tparam[opt=nil] image surfaces.screen The surface for the `screen` property. +-- @tparam[opt=nil] image surfaces.geometry The surface for the `geometry` property. +-- @readonly +-- @see surface - local filepath = args.filepath - local directory = args.directory - local prefix = args.prefix +--- The interactive frame color. +-- @property frame_color +-- @tparam color|nil frame_color +-- @propbeautiful +-- @propemits true false - if filepath and check_filepath(filepath) then +--- The interactive frame shape. +-- @property frame_shape +-- @tparam shape|nil frame_shape +-- @propbeautiful +-- @propemits true false - directory, prefix = parse_filepath(filepath) +--- Define which mouse button exit the interactive snipping mode. +-- +-- @property reject_buttons +-- @tparam[opt={awful.button({}, 3)}}] table reject_buttons +-- @tablerowtype A list of `awful.button` objects. +-- @propemits true false +-- @see accept_buttons - elseif directory or prefix then +--- Mouse buttons used to save the screenshot when using the interactive snipping mode. +-- +-- @property accept_buttons +-- @tparam[opt={awful.button({}, 1)}}] table accept_buttons +-- @tablerowtype A list of `awful.button` objects. +-- @propemits true false +-- @see reject_buttons - if directory and type(directory) == "string" then - directory = check_directory(directory) - elseif self.directory then - directory = self._private.directory -- The setter ran check_directory() - else - directory = get_default_dir() +local defaults = { + prefix = "Screenshot-", + directory = screenshot_validation.directory(os.getenv("HOME")), + cursor = "crosshair", + date_format = "%Y%m%d%H%M%S", + interactive = false, + reject_buttons = {abutton({}, 3)}, + accept_buttons = {abutton({}, 1)}, + reject_keys = {akey({}, "Escape")}, + accept_keys = {akey({}, "Return")}, +} + +-- Create the standard properties. +for _, prop in ipairs { "frame_color", "geometry", "screen", "client", "date_format", + "prefix", "directory", "file_path", "file_name", "cursor", + "interactive", "reject_buttons", "accept_buttons", + "reject_keys", "accept_keys", "frame_shape" } do + module["set_"..prop] = function(self, value) + self._private[prop] = screenshot_validation[prop] + and screenshot_validation[prop](value) or value + self:emit_signal("property::"..prop, value) end - if prefix and type(prefix) == "string" then - prefix = check_prefix(prefix) - elseif self.prefix then - prefix = self._private.prefix -- The setter ran check_prefix() - else - prefix = get_default_prefix() + module["get_"..prop] = function(self) + return self._private[prop] or defaults[prop] end - - directory, prefix, filepath = make_filepath(directory, prefix) - - elseif self.filepath and check_filepath(self.filepath) then - - filepath = self.filepath - directory, prefix = parse_filepath(filepath) - - else - - if self.directory then - directory = self._private.directory -- The setter ran check_directory() - else - directory = get_default_dir() - end - - if self.prefix then - prefix = self._private.prefix -- The setter ran check_prefix() - else - prefix = get_default_prefix() - end - - directory, prefix, filepath = make_filepath(directory, prefix) - - end - - if filepath then - self._private.directory = directory -- These have already - self._private.prefix = prefix -- been checked. - self._private.filepath = filepath - end - end +function module:get_file_path() + return self._private.file_path or make_file_path(self) +end + +function module:get_file_name() + return self._private.file_name or make_file_name(self) +end + +function module:get_surface() + return self.surfaces[1] +end + +function module:get_surfaces() + local ret = {} + + for method, surface in pairs(self._private.surfaces or {}) do + ret[method] = surface.surface + end + + return ret +end + +function module:get_surfaces() + local ret = {} + + for _, surface in pairs(self._private.surfaces or {}) do + table.insert(ret, surface.surface) + end + + return ret +end + +--- Take new screenshot(s) now. +-- +-- +-- @method refresh +-- @treturn table A table with the method name as key and the images as value. +-- @see save + +function module:refresh() + local methods = {} + self._private.surfaces = {} + + for method in pairs(module._screenshot_methods) do + if self._private[method] then + table.insert(methods, method) + end + end + + -- Fallback to a screenshot of everything. + methods = #methods > 0 and methods or {"root"} + + for _, method in ipairs(methods) do + local surface, geo = module._screenshot_methods[method](self) + + if self.interactive then + start_snipping(self, surface, geo, method) + else + self._private.surfaces[method] = {surface = surface, geometry = geo} + end + + end + + return self.surfaces +end + +--- Emitted when a the screenshot is saved. +-- +-- This can be due to `:save()` being called, the `interactive` mode finishing +-- or `auto_save_delay` timing out. +-- +-- @signal file::saved +-- @tparam awful.screenshot self The screenshot. +-- @tparam string file_path The path. +-- @tparam[opt] string|nil method The method associated with the file_path. This +-- can be `root`, `geometry`, `client` or `screen`. + --- Save screenshot. -- --- @method awful.screenshot:save --- @tparam[opt] table args Table with the filepath parameters -function screenshot:save(args) +-- @method save +-- @tparam[opt=self.file_path] string file_path Optionally override the file path. +-- @noreturn +-- @emits saved +-- @see refresh +function module:save(file_path) + if not self._private.surfaces then self:refresh() end - self:filepath_builder(args) - - if self._private.surface and self.filepath then - self._private.surface:write_to_png(self.filepath) - end + for method, surface in pairs(self._private.surfaces) do + file_path = file_path + or self._private.file_path + or make_file_path(self, #self._private.surfaces > 1 and method or nil) + surface.surface:write_to_png(file_path) + self:emit_signal("file::saved", file_path, method) + end end --- Screenshot constructor - it is possible to call this directly, but it is. -- recommended to use the helper constructors, such as awful.screenshot.root -- -- @constructorfct awful.screenshot --- @tparam[opt] string args.directory The path to the screenshot directory. --- @tparam[opt] string args.prefix The prefix to prepend to screenshot file names. --- @tparam[opt] color args.frame_color The color of the frame for a snipper tool as --- a gears color. --- @tparam[opt] string args.method The method of screenshot to take (i.e. root window, etc). --- @tparam[opt] screen args.screen The screen for a physical screen screenshot. Can be a --- screen object of number. --- @tparam[opt] function args.on_success_cb: the callback to run on the screenshot taken --- with a snipper tool. --- @tparam[opt] geometry args.geometry A gears geometry object for a snip screenshot. +-- @tparam[opt={}] table args +-- @tparam[opt] string args.directory Get screenshot directory property. +-- @tparam[opt] string args.prefix Get screenshot prefix property. +-- @tparam[opt] string args.file_path Get screenshot file path. +-- @tparam[opt] string args.file_name Get screenshot file name. +-- @tparam[opt] screen args.screen Get screenshot screen. +-- @tparam[opt] string args.date_format The date format used in the default suffix. +-- @tparam[opt] string args.cursor The cursor used in interactive mode. +-- @tparam[opt] boolean args.interactive Rather than take a screenshot of an +-- object, use the mouse to select an area. +-- @tparam[opt] client args.client Get screenshot client. +-- @tparam[opt] table args.geometry Get screenshot geometry. +-- @tparam[opt] color args.frame_color The frame color. + local function new(_, args) + args = (type(args) == "table" and args) or {} + local self = gears.object({ + enable_auto_signals = true, + enable_properties = true + }) - local args = (type(args) == "table" and args) or {} - local ss = gears.object({ - enable_auto_signals = true, - enable_properties = true - }) - - local directory, prefix = configure_path(args.directory, args.prefix) - - local method = nil - local method_name = "" - if screenshot_methods[args.method] then - method = screenshot_methods[args.method] - method_name = args.method - else - method = screenshot_methods.default - method_name = default_method_name - end - - local screen, client, on_success_cb, frame_color, geometry - if method_name == "screen" then - screen = args.screen - elseif method_name == "client" then - client = args.client - elseif method_name == "snipper" then - - if args.frame_color then - frame_color = gears.color(args.frame_color) - if not frame_color then - frame_color = module_default_frame_color - end - else - frame_color = module_default_frame_color - end - - if type(args.on_success_cb) == "function" then - on_success_cb = args.on_success_cb - else - on_success_cb = default_on_success_cb - end - - elseif method_name == "snip" then - geometry = args.geometry - end - - ss._private = { - directory = directory, - prefix = prefix, - filepath = nil, - method_name = method_name, - frame_color = frame_color, - screen = screen, - client = client, - on_success_cb = on_success_cb, - geometry = geometry, - surface = nil - } - - gears.table.crush(ss, screenshot, true) - - return method(ss) + self._private = {} + gears.table.crush(self, module, true) + gears.table.crush(self, args) + return self end return setmetatable(module, {__call = new}) +-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80 diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 4be2e66ca..37d6214c2 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -111,12 +111,7 @@ end local snipper_success = nil local function snipper_cb(ss) local img = ss.surface - if img and get_pixel(img, 10, 10) == "#00ff00" then - snipper_success = "true" - return - else - snipper_success = "false" - end + snipper_success = img and get_pixel(img, 10, 10) == "#00ff00" end local steps = {} @@ -190,37 +185,25 @@ table.insert(steps, function() end) table.insert(steps, function() - --Make sure client from last test is gone if #client.get() ~= 0 then return end - local fake_screenshot_dir = string.gsub(fake_screenshot_dir, "/*$", "/", 1) + local fake_screenshot_dir2 = string.gsub(fake_screenshot_dir, "/*$", "/", 1) - awful.screenshot.set_defaults({}) - awful.screenshot.set_defaults({directory = "/dev/null", prefix = "Screenshot-", frame_color = "#000000"}) - awful.screenshot.set_defaults({directory = "~/"}) - awful.screenshot.set_defaults({directory = fake_screenshot_dir}) + local ss = awful.screenshot { directory = "/tmp" } + local name_prfx = fake_screenshot_dir2 .. "Screenshot-" - local ss = awful.screenshot.root() - local name_prfx = fake_screenshot_dir .. "Screenshot-" - - local f, l = string.find(ss.filepath, name_prfx) + local f = string.find(ss.file_path, name_prfx) if f ~= 1 then - print("Failed autogenerate filename: " .. ss.filepath .. " : " .. name_prfx) + error("Failed autogenerate filename: " .. ss.file_path .. " : " .. name_prfx) return false end - name_prfx = fake_screenshot_dir .. "MyShot.png" - ss.filepath = name_prfx + name_prfx = fake_screenshot_dir2 .. "MyShot.png" + ss.file_path = name_prfx - if ss.filepath ~= name_prfx then - print("Failed assign filename: " .. ss.filepath .. " : " .. name_prfx) - return false - end - - ss:filepath_builder({directory = fake_screenshot_dir, prefix = "Screenshot-"}) - if ss.directory ~= fake_screenshot_dir or ss.prefix ~= "Screenshot-" then - print("Failed assign directory/prefix: " .. ss.directory .. " : " .. ss.prefix) + if ss.file_path ~= name_prfx then + error("Failed assign filename: " .. ss.file_path .. " : " .. name_prfx) return false end @@ -232,8 +215,11 @@ end) table.insert(steps, function() local root_width, root_height = root.size() - local ss = awful.screenshot.root() + local ss = awful.screenshot { directory = "/tmp" } + ss:refresh() + local img = ss.surface + assert(img) assert(get_pixel(img, 100, 100) == "#00ff00") assert(get_pixel(img, 199, 199) == "#00ff00") @@ -245,30 +231,28 @@ table.insert(steps, function() assert(get_pixel(img, root_width - 2, root_height - 2) == "#ff0000") if ss.screen ~= nil or ss.client ~= nil then - print("Returned non nil screen or client for root screenshot") - print(ss.screen) - print(ss.client) + error("Returned non nil screen or client for root screenshot") return false end - return true + return true end) -- Check the awful.screenshot.screen() method table.insert(steps, function() - for s in screen do + local geo = s.geometry + local ss = awful.screenshot {screen = s, directory = "/tmp" } + ss:refresh() - local geo = s.geometry - local ss = awful.screenshot.screen({screen = s}) - local img = ss.surface - - assert(get_pixel(img, 4, 4) == "#ff0000") - assert(get_pixel(img, geo.width - 4, 4) == "#ff0000") - assert(get_pixel(img, 4, geo.height - 4) == "#ff0000") - assert(get_pixel(img, geo.width - 4, geo.height - 4) == "#ff0000") + local img = ss.surface + assert(img) + assert(get_pixel(img, 4, 4) == "#ff0000") + assert(get_pixel(img, geo.width - 4, 4) == "#ff0000") + assert(get_pixel(img, 4, geo.height - 4) == "#ff0000") + assert(get_pixel(img, geo.width - 4, geo.height - 4) == "#ff0000") end -- Spawn for the client.content test @@ -286,8 +270,10 @@ table.insert(steps, function() local c = client.get()[1] local geo = c:geometry() - local ss = awful.screenshot.client({client = c}) + local ss = awful.screenshot {client = c, directory = "/tmp" } + ss:refresh() local img = ss.surface + assert(img) if get_pixel(img, math.floor(geo.width / 2), math.floor(geo.height / 2)) ~= "#0000ff" then return @@ -308,23 +294,21 @@ table.insert(steps, function() if #client.get() ~= 0 then return end --Ensure mousegrabber is satisfied root.fake_input("button_press",1) - return true + return true end) table.insert(steps, function() root.fake_input("button_release",1) + awesome.sync() return true end) table.insert(steps, function() - --Ensure prior mouse presses go through - local t0 = os.time() - while os.time() - t0 < 1 do end - return true -end) + local ss = awful.screenshot { interactive = true, directory = "/tmp" } + ss:refresh() + + ss:connect_signal("snipping::success", snipper_cb) -table.insert(steps, function() - awful.screenshot.snipper({on_success_cb = snipper_cb}) return true end) @@ -338,25 +322,9 @@ table.insert(steps, function() return true end) -table.insert(steps, function() - root.fake_input("button_release",1) - return true -end) - table.insert(steps, function() mouse.coords {x = 190, y = 190} - return true -end) - -table.insert(steps, function() - --Ensure prior mouse presses and movements go through - local t0 = os.time() - while os.time() - t0 < 1 do end - return true -end) - -table.insert(steps, function() - root.fake_input("button_press",1) + awesome.sync() return true end) @@ -366,27 +334,9 @@ table.insert(steps, function() end) table.insert(steps, function() - --Ensure prior mouse presses go through and callback runs - local t0 = os.time() - while os.time() - t0 < 1 do end - return true -end) - -table.insert(steps, function() - - --Check for success - if snipper_success then - if snipper_success == "true" then - return true - else - return false - end - else - return - end - - return true + if snipper_success == nil then return end + return snipper_success end) @@ -396,23 +346,19 @@ table.insert(steps, function() if #client.get() ~= 0 then return end --Ensure mousegrabber is satisfied root.fake_input("button_press",1) - return true + return true end) table.insert(steps, function() root.fake_input("button_release",1) + + awesome.sync() return true end) table.insert(steps, function() - --Ensure prior mouse presses go through - local t0 = os.time() - while os.time() - t0 < 1 do end - return true -end) - -table.insert(steps, function() - awful.screenshot.snipper({on_success_cb = snipper_cb}) + local ss = awful.screenshot { interactive = true, directory = "/tmp" } + ss:connect_signal("snipping::success", snipper_cb) return true end) @@ -433,26 +379,14 @@ end) table.insert(steps, function() mouse.coords {x = 150, y = 150} - return true -end) - -table.insert(steps, function() - --Ensure prior mouse presses and movements go through - local t0 = os.time() - while os.time() - t0 < 1 do end + awesome.sync() return true end) table.insert(steps, function() --Cause a rectangle collapse mouse.coords {x = 150, y = 110} - return true -end) - -table.insert(steps, function() - --Ensure prior mouse presses and movements go through - local t0 = os.time() - while os.time() - t0 < 1 do end + awesome.sync() return true end) @@ -464,24 +398,18 @@ end) table.insert(steps, function() root.fake_input("button_release",3) + awesome.sync() return true end) table.insert(steps, function() - --Ensure prior mouse presses go through and callback runs - local t0 = os.time() - while os.time() - t0 < 1 do end - return true -end) + local ss = awful.screenshot { + geometry = {x = 100, y = 100, width = 100, height = 100} + } + ss:refresh() -table.insert(steps, function() - local ss = awful.screenshot.snip({geometry = {x = 100, y = 100, width = 100, height = 100}}) local img = ss.surface - if get_pixel(img, 10, 10) == "#00ff00" then - return true - else - return false - end + return get_pixel(img, 10, 10) == "#00ff00" end) require("_runner").run_steps(steps) From f0a7f904f9231f31badfcff83a9ef9f1bdc94a69 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 15:50:43 -0700 Subject: [PATCH 18/28] keygrabber: Add a new signal to catch awful.keys activations. Some downstream modules with keys don't control the callbacks. Using signals on multiple keys is more cumbersome than simply exposing this at the keygrabber level. --- lib/awful/keygrabber.lua | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/awful/keygrabber.lua b/lib/awful/keygrabber.lua index 444f4db4b..74c47a9a8 100644 --- a/lib/awful/keygrabber.lua +++ b/lib/awful/keygrabber.lua @@ -236,11 +236,18 @@ local function runner(self, modifiers, key, event) for _,v2 in ipairs(v.modifiers) do match = match and mod[v2] end - if match and v.on_press then - v.on_press(self) - if self.mask_event_callback ~= false then - return + if match then + self:emit_signal("keybinding::triggered", v, event) + + if event == "press" and v.on_press then + v.on_press(self) + + if self.mask_event_callback ~= false then + return + end + elseif event == "release" and v.on_release then + v.on_release(self) end end end @@ -630,6 +637,12 @@ end --- When the keygrabber stops. -- @signal stopped +--- When an `awful.key` is pressed. +-- @signal keybinding::triggered +-- @tparam awful.keybinding self +-- @tparam awful.key key The keybinding. +-- @tparam string event Either `"press"` or `"release"`. + --- A function called when a keygrabber starts. -- @callback start_callback -- @tparam keygrabber keygrabber The keygrabber. From c391fc7fe4c5be06a04d998da58e4330d159eb7c Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 15:57:23 -0700 Subject: [PATCH 19/28] keygrabber: Add `:remove_keybinding()`. It was possible to add new keys, but not remove existing ones. --- lib/awful/keygrabber.lua | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/awful/keygrabber.lua b/lib/awful/keygrabber.lua index 74c47a9a8..bf404035e 100644 --- a/lib/awful/keygrabber.lua +++ b/lib/awful/keygrabber.lua @@ -549,6 +549,7 @@ end -- @tparam awful.key key The key. -- @tparam string description.group The keybinding group -- @noreturn +-- @see remove_keybinding function keygrabber:add_keybinding(key, keycode, callback, description) local mods = not key._is_awful_key and key or nil @@ -581,6 +582,27 @@ function keygrabber:add_keybinding(key, keycode, callback, description) end end +--- Remove a keybinding from the keygrabber. +-- +-- @method remove_keybinding +-- @treturn boolean `true` if removed, `false` if not found. +-- @see add_keybinding + +function keygrabber:remove_keybinding(key) + for idx, obj in ipairs(self._private.keybindings[key.key]) do + if obj == key then + table.remove(self._private.keybindings[key.key], idx) + + if #self._private.keybindings[key.key] == 0 then + self._private.keybindings[key.key] = nil + end + + return true + end + end + return false +end + function keygrabber:set_root_keybindings(keys) local real_keys = {} From 6338412b5f4ac59858eb44f8f9f04a04af0c91b0 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 17:20:56 -0700 Subject: [PATCH 20/28] awful.key: Fix the `modifiers` property. It was called `mod` rather than `modifiers`. --- lib/awful/key.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/awful/key.lua b/lib/awful/key.lua index 1fc4b17cc..e078abd7d 100644 --- a/lib/awful/key.lua +++ b/lib/awful/key.lua @@ -321,7 +321,7 @@ local function new_common(mod, keys, press, release, data) -- append custom userdata (like description) to a hotkey data = data and gtable.clone(data) or {} - data.mod = mod + data.mod, data.modifiers = mod, mod data.keys = keys data.on_press = press data.on_release = release From fa971ceb7ccd536a5083631079d98e351d992725 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 17:23:42 -0700 Subject: [PATCH 21/28] tmp add keygrabber signal --- lib/awful/keygrabber.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/awful/keygrabber.lua b/lib/awful/keygrabber.lua index bf404035e..7abacdf92 100644 --- a/lib/awful/keygrabber.lua +++ b/lib/awful/keygrabber.lua @@ -222,7 +222,7 @@ local function runner(self, modifiers, key, event) local filtered_modifiers = {} -- User defined cases - if self._private.keybindings[key] and event == "press" then + if self._private.keybindings[key] then -- Remove caps and num lock for _, m in ipairs(modifiers) do if not gtable.hasitem(akey.ignore_modifiers, m) then From b7dcece40c2fe2fa7df619ae70dfbbbd38552e78 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 17:44:21 -0700 Subject: [PATCH 22/28] awful.screenshot: Add keyboard support. --- lib/awful/screenshot.lua | 229 +++++++++++++++++++++++++++++++------- tests/test-screenshot.lua | 35 ++++++ 2 files changed, 224 insertions(+), 40 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index e130f7e46..517f4b67d 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -6,7 +6,6 @@ -- @inputmodule awful.screenshot --------------------------------------------------------------------------- - -- Grab environment we need local capi = { root = root, @@ -21,6 +20,7 @@ local wibox = require("wibox") local cairo = require("lgi").cairo local abutton = require("awful.button") local akey = require("awful.key") +local akgrabber = require("awful.keygrabber") local glib = require("lgi").GLib local datetime = glib.DateTime local timezone = glib.TimeZone @@ -212,14 +212,19 @@ local function show_frame(self, surface, geo) } end ---- Emitted when the interactive succeed. +--- Emitted when the interactive snipping starts. +-- @signal snipping::start +-- @tparam awful.screenshot self + +--- Emitted when the interactive snipping succeed. -- @signal snipping::success -- @tparam awful.screenshot self ---- Emitted when the interactive is cancelled. +--- Emitted when the interactive snipping is cancelled. -- @signal snipping::cancelled -- @tparam awful.screenshot self --- @tparam string reason Either `"mouse_button"`, `"key"`, or `"too_small"`. +-- @tparam string reason Either `"mouse_button"`, `"key"`, `"no_selection"` +-- or `"too_small"`. -- Internal function that generates the callback to be passed to the -- mousegrabber that implements the interactive mode. @@ -244,18 +249,13 @@ local function start_snipping(self, surface, geometry, method) show_frame(self, surface, geometry) local function ret_mg_callback(mouse_data, accept, reject) - local frame = self._private.frame - for btn, status in pairs(mouse_data.buttons) do accept = accept or (status and accept_buttons[btn]) reject = reject or (status and reject_buttons[btn]) end if reject then - frame.visible = false - self._private.frame, self._private.mg_first_pnt = nil, nil - self:emit_signal("snipping::cancelled", "mouse_button") - + self:reject("mouse_button") return false elseif pressed then local min_x = math.min(self._private.mg_first_pnt[1], mouse_data.x) @@ -263,44 +263,25 @@ local function start_snipping(self, surface, geometry, method) local min_y = math.min(self._private.mg_first_pnt[2], mouse_data.y) local max_y = math.max(self._private.mg_first_pnt[2], mouse_data.y) - local new_geo = { - x = min_x, - y = min_y, - width = max_x - min_x, - height = max_y - min_y, + self._private.selected_geometry = { + x = min_x, + y = min_y, + width = max_x - min_x, + height = max_y - min_y, + method = method, + surface = surface, } + self:emit_signal("property::selected_geometry", self._private.selected_geometry) if not accept then -- Released - - self._private.frame, self._private.mg_first_pnt = nil, nil - - -- This may fail gracefully anyway but require a minimum 3x3 of pixels - if min_x >= max_x-1 or min_y >= max_y-1 then - self:emit_signal("snipping::cancelled", "too_small") - return false - end - - self._private.selection_widget.visible = false - - self._private.surfaces[method] = { - surface = crop_shot(surface, new_geo), - geometry = new_geo - } - - self:emit_signal("snipping::success") - self:save() - - frame.visible = false - self._private.frame, self._private.mg_first_pnt = nil, nil - - return false + return self:accept() else -- Update position self._private.selection_widget.point.x = min_x self._private.selection_widget.point.y = min_y self._private.selection_widget.fit = function() - return new_geo.width, new_geo.height + return self._private.selected_geometry.width, self._private.selected_geometry.height end self._private.selection_widget:emit_signal("widget::layout_changed") self._private.canvas_widget:emit_signal("widget::redraw_needed") @@ -317,7 +298,9 @@ local function start_snipping(self, surface, geometry, method) return true end + self.keygrabber:start() capi.mousegrabber.run(ret_mg_callback, self.cursor) + self:emit_signal("snipping::start") end -- Internal function exected when a root window screenshot is taken. @@ -400,6 +383,7 @@ end --- The date format used in the default suffix. -- @property date_format -- @tparam[opt="%Y%m%d%H%M%S"] string date_format +-- @propemits true false -- @see wibox.widget.textclock --- The cursor used in interactive mode. @@ -412,11 +396,13 @@ end -- -- @property interactive -- @tparam[opt=false] boolean interactive +-- @propemits true false --- Get screenshot screen. -- -- @property screen -- @tparam[opt=nil] screen|nil screen +-- @propemits true false -- @see mouse.screen -- @see awful.screen.focused -- @see screen.primary @@ -425,6 +411,7 @@ end -- -- @property client -- @tparam[opt=nil] client|nil client +-- @propemits true false -- @see mouse.client -- @see client.focus @@ -436,6 +423,7 @@ end -- @tparam table geometry.y -- @tparam table geometry.width -- @tparam table geometry.height +-- @propemits true false --- Get screenshot surface. -- @@ -472,6 +460,21 @@ end -- @readonly -- @see surface +--- Set a minimum size to save a screenshot. +-- +-- When the screenshot is very small (like 1x1 pixels), it is probably a mistake. +-- Rather than save useless files, set this property to auto-reject tiny images. +-- +-- @property minimum_size +-- @tparam[opt={width=3, height=3}] nil|integer|table minimum_size +-- @tparam integer minimum_size.width +-- @tparam integer minimum_size.height +-- @propertytype nil No minimum size. +-- @propertytype integer Same minimum size for the height and width. +-- @propertytype table Different size for the height and width. +-- @negativeallowed false +-- @propemits true false + --- The interactive frame color. -- @property frame_color -- @tparam color|nil frame_color @@ -500,6 +503,30 @@ end -- @propemits true false -- @see reject_buttons +--- The `awful.keygrabber` object used for the accept and reject keys. +-- +-- This can be used to add new keybindings to the interactive snipper mode. For +-- examples, this can be used to change the saving path or add some annotations +-- to the image. +-- +-- @property keygrabber +-- @tparam awful.keygrabber keygrabber +-- @propertydefault Autogenerated. +-- @readonly + +--- The current interactive snipping mode seletion. +-- @property selected_geometry +-- @tparam nil|table selected_geometry +-- @tparam integer selected_geometry.x +-- @tparam integer selected_geometry.y +-- @tparam integer selected_geometry.width +-- @tparam integer selected_geometry.height +-- @tparam image selected_geometry.surface +-- @tparam string selected_geometry.method Either `"root"`, `"client"`, +-- `"screen"` or `"geometry"`. +-- @propemits true false +-- @readonly + local defaults = { prefix = "Screenshot-", directory = screenshot_validation.directory(os.getenv("HOME")), @@ -510,13 +537,14 @@ local defaults = { accept_buttons = {abutton({}, 1)}, reject_keys = {akey({}, "Escape")}, accept_keys = {akey({}, "Return")}, + minimum_size = {width = 3, height = 3}, } -- Create the standard properties. for _, prop in ipairs { "frame_color", "geometry", "screen", "client", "date_format", "prefix", "directory", "file_path", "file_name", "cursor", "interactive", "reject_buttons", "accept_buttons", - "reject_keys", "accept_keys", "frame_shape" } do + "reject_keys", "accept_keys", "frame_shape", "minimum_size" } do module["set_"..prop] = function(self, value) self._private[prop] = screenshot_validation[prop] and screenshot_validation[prop](value) or value @@ -528,6 +556,10 @@ for _, prop in ipairs { "frame_color", "geometry", "screen", "client", "date_for end end +function module:get_selected_geometry() + return self._private.selected_geometry +end + function module:get_file_path() return self._private.file_path or make_file_path(self) end @@ -560,6 +592,59 @@ function module:get_surfaces() return ret end +function module:get_keygrabber() + if self._private.keygrabber then return self._private.keygrabber end + + self._private.keygrabber = akgrabber { + stop_key = self.reject_buttons + } + self._private.keygrabber:connect_signal("keybinding::triggered", function(_, key, event) + if event == "press" then return end + if self._private.accept_keys_set[key] then + self:accept() + elseif self._private.reject_keys_set[key] then + self:reject() + end + end) + + -- Reload the keys. + self.accept_keys, self.reject_keys = self.accept_keys, self.reject_keys + + return self._private.keygrabber +end + +-- Put the key in a set rather than a list and add/remove them from the keygrabber. +for _, prop in ipairs {"accept_keys", "reject_keys"} do + local old = module["set_"..prop] + + module["set_"..prop] = function(self, new_keys) + -- Remove old keys. + if self._private.keygrabber then + for _, key in ipairs(self._private[prop] or {}) do + self._private.keygrabber:remove_keybinding(key) + end + end + + local new_set = {} + self._private[prop.."_set"] = new_set + + for _, key in ipairs(new_keys) do + self.keygrabber:add_keybinding(key) + new_set[key] = true + end + + old(self, new_keys) + end +end + +function module:set_minimum_size(size) + if size and type(size) ~= "table" then + size = {width = math.ceil(size), height = math.ceil(size)} + end + self._private.minimum_size = size + self:emit_signal("property::minimum_size", size) +end + --- Take new screenshot(s) now. -- -- @@ -625,6 +710,70 @@ function module:save(file_path) end end +--- Save and exit the interactive snipping mode. +-- @method accept +-- @treturn boolean `true` if the screenshot were save, `false` otherwise. It +-- can be `false` because the selection is below `minimum_size` or because +-- there is nothing to save (so selection). +-- @emits snipping::cancelled +-- @emits snipping::success + +function module:accept() + -- Nothing to do. + if not self.interactive then return true end + + local new_geo = self._private.selected_geometry + local min_size = self.minimum_size + + if not new_geo then + self:reject("no_selection") + return false + end + + -- This may fail gracefully anyway but require a minimum 3x3 of pixels + if min_size and new_geo.width < min_size.width or new_geo.height < min_size.height then + self:reject("too_small") + return false + end + + self._private.selection_widget.visible = false + + self._private.surfaces[new_geo.method] = { + surface = crop_shot(new_geo.surface, new_geo), + geometry = new_geo + } + + self:emit_signal("snipping::success") + self:save() + + self._private.frame.visible = false + self._private.frame, self._private.mg_first_pnt = nil, nil + self.keygrabber:stop() + capi.mousegrabber.stop() + + return true +end + +--- Exit the interactive snipping mode without saving. +-- @method reject +-- @tparam[opt=nil] string||nil reason The reason why it was rejected. This is +-- passed to the `"snipping::cancelled"` signal. +-- @emits snipping::cancelled + +function module:reject(reason) + -- Nothing to do. + if not self.interactive then return end + + if self._private.frame then + self._private.frame.visible = false + self._private.frame = nil + end + self._private.mg_first_pnt = nil + self:emit_signal("snipping::cancelled", reason or "reject_called") + capi.mousegrabber.stop() + self.keygrabber:stop() +end + --- Screenshot constructor - it is possible to call this directly, but it is. -- recommended to use the helper constructors, such as awful.screenshot.root -- diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 37d6214c2..269433fd7 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -412,4 +412,39 @@ table.insert(steps, function() return get_pixel(img, 10, 10) == "#00ff00" end) +local escape_works, enter_works = false, false + +table.insert(steps, function() + local ss = awful.screenshot { interactive = true } + + ss:connect_signal("snipping::start", function() + ss:connect_signal("snipping::cancelled", function() enter_works = true end) + end) + + ss:refresh() + return true +end) + +table.insert(steps, function() + if not enter_works then + root.fake_input("key_press","Return") + root.fake_input("key_release","Return") + return + end + local ss = awful.screenshot { interactive = true } + ss:connect_signal("snipping::cancelled", function() escape_works = true end) + ss:refresh() + return true +end) + +table.insert(steps, function() + if not escape_works then + root.fake_input("key_press","Escape") + root.fake_input("key_release","Escape") + return + end + + return true +end) + require("_runner").run_steps(steps) From f55a3349728f74466372ed3bb50fe1a0440eab43 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 21:56:02 -0700 Subject: [PATCH 23/28] awful.screenshot: Add a delay feature. This can also act as an auto-save feature if the delay is zero. It also adds more signals. These signals are intended for creating notifications. `awful` cannot safely depend on `naughty`, so this intergration will have to be done by the users. --- lib/awful/screenshot.lua | 118 +++++++++++++++++++++++++++++++++----- tests/test-screenshot.lua | 31 ++++++++++ 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 517f4b67d..80a3ca1dd 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -3,7 +3,7 @@ -- -- @author Brian Sobulefsky <brian.sobulefsky@protonmail.com> -- @copyright 2021 Brian Sobulefsky --- @inputmodule awful.screenshot +-- @classmod awful.screenshot --------------------------------------------------------------------------- -- Grab environment we need @@ -21,6 +21,7 @@ local cairo = require("lgi").cairo local abutton = require("awful.button") local akey = require("awful.key") local akgrabber = require("awful.keygrabber") +local gtimer = require("gears.timer") local glib = require("lgi").GLib local datetime = glib.DateTime local timezone = glib.TimeZone @@ -226,6 +227,23 @@ end -- @tparam string reason Either `"mouse_button"`, `"key"`, `"no_selection"` -- or `"too_small"`. +--- Emitted when the `auto_save_delay` timer starts. +-- @signal timer::started +-- @tparam awful.screenshot self + +--- Emitted after each elapsed second when `auto_save_delay` is set. +-- @signal timer::tick +-- @tparam awful.screenshot self +-- @tparam integer remaining Number of seconds remaining. + +--- Emitted when the `auto_save_delay` timer stops. +-- +-- This is before the screenshot is taken. If you need to hide notifications +-- or other element, it has to be done using this signal. +-- +-- @signal timer::timeout +-- @tparam awful.screenshot self + -- Internal function that generates the callback to be passed to the -- mousegrabber that implements the interactive mode. -- @@ -527,24 +545,44 @@ end -- @propemits true false -- @readonly +--- Number of seconds before auto-saving (or entering the interactive snipper). +-- +-- You can use `0` to auto-save immediatly. +-- +-- @property auto_save_delay +-- @tparam[opt=nil] integer|nil auto_save_delay +-- @propertytype nil Do not auto-save. +-- @propertyunit second +-- @negativeallowed false +-- @propemits true false + +--- Duration between the `"timer::tick"` signals when using `auto_save_delay`. +-- @property auto_save_tick_duration +-- @tparam[opt=1.0] number auto_save_tick_duration +-- @propertyunit second +-- @negativeallowed false +-- @propemits true false + local defaults = { - prefix = "Screenshot-", - directory = screenshot_validation.directory(os.getenv("HOME")), - cursor = "crosshair", - date_format = "%Y%m%d%H%M%S", - interactive = false, - reject_buttons = {abutton({}, 3)}, - accept_buttons = {abutton({}, 1)}, - reject_keys = {akey({}, "Escape")}, - accept_keys = {akey({}, "Return")}, - minimum_size = {width = 3, height = 3}, + prefix = "Screenshot-", + directory = screenshot_validation.directory(os.getenv("HOME")), + cursor = "crosshair", + date_format = "%Y%m%d%H%M%S", + interactive = false, + reject_buttons = {abutton({}, 3)}, + accept_buttons = {abutton({}, 1)}, + reject_keys = {akey({}, "Escape")}, + accept_keys = {akey({}, "Return")}, + minimum_size = {width = 3, height = 3}, + auto_save_tick_duration = 1, } -- Create the standard properties. for _, prop in ipairs { "frame_color", "geometry", "screen", "client", "date_format", - "prefix", "directory", "file_path", "file_name", "cursor", - "interactive", "reject_buttons", "accept_buttons", - "reject_keys", "accept_keys", "frame_shape", "minimum_size" } do + "prefix", "directory", "file_path", "file_name", "auto_save_delay", + "interactive", "reject_buttons", "accept_buttons", "cursor", + "reject_keys", "accept_keys", "frame_shape", "minimum_size", + "auto_save_tick_duration" } do module["set_"..prop] = function(self, value) self._private[prop] = screenshot_validation[prop] and screenshot_validation[prop](value) or value @@ -645,6 +683,58 @@ function module:set_minimum_size(size) self:emit_signal("property::minimum_size", size) end +function module:set_auto_save_delay(value) + self._private.auto_save_delay = math.ceil(value) + self:emit_signal("property::auto_save_delay", value) + + if not value then + if self._private.timer then + self._private.timer:stop() + self._private.timer = nil + end + return + end + + if value == 0 then + self:refresh() + if not self.interactive then + self:save() + end + return + end + + self._private.current_delay = self._private.auto_save_delay + + if not self._private.timer then + local dur = self.auto_save_tick_duration + self._private.timer = gtimer {timeout = dur} + local fct + fct = function() + self._private.current_delay = self._private.current_delay - dur + self:emit_signal("timer::tick", self._private.current_delay) + + if self._private.current_delay <= 0 then + self:emit_signal("timer::timeout") + awesome.sync() + self:refresh() + + if not self.interactive then + self:save() + end + + -- For garbage collection of the `awful.screenshot` object. + self._private.timer:stop() + self._private.timer:disconnect_signal("timeout", fct) + self._private.timer = nil + end + end + self._private.timer:connect_signal("timeout", fct) + end + + self._private.timer:again() + self:emit_signal("timer::started") +end + --- Take new screenshot(s) now. -- -- diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 269433fd7..af84fd2e9 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -437,6 +437,7 @@ table.insert(steps, function() return true end) +-- Test auto-save table.insert(steps, function() if not escape_works then root.fake_input("key_press","Escape") @@ -444,6 +445,36 @@ table.insert(steps, function() return end + local called = false + local save = awful.screenshot.save + awful.screenshot.save = function() called = true end + local ss = awful.screenshot { auto_save_delay = 0 } + ss:connect_signal("snipping::cancelled", function() escape_works = true end) + awful.screenshot.save = save + + assert(called) + + return true +end) + +local timer_start, timer_tick, timer_timeout, saved = false, false, false, false + +-- Test delayed auto-save +table.insert(steps, function() + local ss = awful.screenshot { auto_save_tick_duration = 0.05 } + + ss:connect_signal("timer::started", function() timer_start = true end) + ss:connect_signal("timer::tick", function() timer_tick = true end) + ss:connect_signal("timer::timeout", function() timer_timeout = true end) + ss:connect_signal("file::saved", function() saved = true end) + + ss.auto_save_delay = 1 + + return true +end) + +table.insert(steps, function() + if not (timer_start and timer_tick and timer_timeout and saved) then return end return true end) From d92a1c498bc0610339d6dadd8b500c478e8d054e Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 22:36:03 -0700 Subject: [PATCH 24/28] awful.screenshot: Allow to export widgets instead of files. Useful for alt-tab widgets and CDE / E16 style Iconified clients. --- lib/awful/screenshot.lua | 45 ++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index 80a3ca1dd..fff381b1a 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -563,6 +563,21 @@ end -- @negativeallowed false -- @propemits true false +--- Export this screenshot as an `wibox.widget.imagebox` instead of a file. +-- +-- This can be used to place the screenshot in a `wibox`, `awful.popup` +-- or `awful.wibar`. Note that this isn't a live view of the object, you have +-- to call `:refresh()` to update the content. +-- +-- Note that it only makes sense when only 1 surface is exported by the +-- screenhot. If that doesn't work for your use case, consider making multiple +-- `awful.screenshot` objects. +-- +-- @property content_widget +-- @tparam wibox.widget.imagebox content_widget +-- @readonly +-- @propertydefault Autogenerated on first access. + local defaults = { prefix = "Screenshot-", directory = screenshot_validation.directory(os.getenv("HOME")), @@ -620,14 +635,9 @@ function module:get_surfaces() return ret end -function module:get_surfaces() - local ret = {} - - for _, surface in pairs(self._private.surfaces or {}) do - table.insert(ret, surface.surface) - end - - return ret +function module:get_surface() + local pair = select(2, next(self._private.surfaces or {})) + return pair and pair.surface end function module:get_keygrabber() @@ -735,9 +745,22 @@ function module:set_auto_save_delay(value) self:emit_signal("timer::started") end +function module:get_content_widget() + if not self._private.output_imagebox then + self._private.output_imagebox = wibox.widget.imagebox() + end + + local s = self.surface + + if s then + self._private.output_imagebox.image = s + end + + return self._private.output_imagebox +end + --- Take new screenshot(s) now. -- --- -- @method refresh -- @treturn table A table with the method name as key and the images as value. -- @see save @@ -766,6 +789,10 @@ function module:refresh() end + if self._private.output_imagebox then + self._private.output_imagebox.image = self.surface + end + return self.surfaces end From fc9b06787bdbdabbbc5420c9707319c5f6380fb6 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Mon, 5 Sep 2022 22:57:19 -0700 Subject: [PATCH 25/28] shims: Implement the titlebar size API properly. Needed for the screenshot API. --- tests/examples/shims/client.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/examples/shims/client.lua b/tests/examples/shims/client.lua index 10d7d14f7..6fe8dec09 100644 --- a/tests/examples/shims/client.lua +++ b/tests/examples/shims/client.lua @@ -19,8 +19,10 @@ end local function titlebar_meta(c) for _, position in ipairs {"top", "bottom", "left", "right" } do - c["titlebar_"..position] = function(size) --luacheck: no unused - return drawin{} + c["titlebar_"..position] = function(_, size) + local prop = "titlebar_"..position.."_size" + c._private[prop] = c._private[prop] or size + return drawin{}, c._private[prop] or 0 end end end From 6a862f786bfc6cc77582d462dafe9a56b45315e9 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Tue, 6 Sep 2022 00:12:32 -0700 Subject: [PATCH 26/28] doc: Add support for `awfu.screenshot` in the `awful` template. --- tests/examples/awful/template.lua | 214 ++++++++++++++++-------------- 1 file changed, 117 insertions(+), 97 deletions(-) diff --git a/tests/examples/awful/template.lua b/tests/examples/awful/template.lua index dd7ab4dbd..90f87f68d 100644 --- a/tests/examples/awful/template.lua +++ b/tests/examples/awful/template.lua @@ -2,11 +2,123 @@ local file_path, image_path = ... require("_common_template")(...) local cairo = require("lgi").cairo +local color = require( "gears.color" ) +local shape = require( "gears.shape" ) +local beautiful = require( "beautiful" ) +local wibox = require( "wibox" ) +local screenshot = require( "awful.screenshot") -local color = require( "gears.color" ) -local shape = require( "gears.shape" ) -local beautiful = require( "beautiful" ) -local wibox = require( "wibox" ) +local function wrap_titlebar(tb, width, height, args) + local bg, fg + + if args.honor_titlebar_colors then + bg = tb.drawable.background_color or tb.args.bg_normal + fg = tb.drawable.foreground_color or tb.args.fg_normal + else + bg, fg = tb.args.bg_normal, tb.args.fg_normal + end + + return wibox.widget { + tb.drawable.widget, + bg = bg, + fg = fg, + forced_width = width, + forced_height = height, + widget = wibox.container.background + } +end + +local function client_widget(c, col, label, args) + local geo = c:geometry() + local bw = c.border_width or beautiful.border_width or 0 + local bc = c.border_color or beautiful.border_color + + local l = wibox.layout.align.vertical() + l.fill_space = true + + local tbs = c._private and c._private.titlebars or {} + + local map = { + top = "set_first", + left = "set_first", + bottom = "set_third", + right = "set_third", + } + + for _, position in ipairs{"top", "bottom"} do + local tb = tbs[position] + if tb then + l[map[position]](l, wrap_titlebar(tb, c:geometry().width, tb.args.height or 16, args)) + end + end + + for _, position in ipairs{"left", "right"} do + local tb = tbs[position] + if tb then + l[map[position]](l, wrap_titlebar(tb, tb.args.width or 16, c:geometry().height), args) + end + end + + local l2 = wibox.layout.align.horizontal() + l2.fill_space = true + l:set_second(l2) + l.forced_width = c.width + l.forced_height = c.height + + return wibox.widget { + { + l, + { + text = label or "", + halign = "center", + valign = "center", + widget = wibox.widget.textbox + }, + layout = wibox.layout.stack + }, + border_width = bw, + border_color = bc, + shape_clip = true, + border_strategy = "inner", + opacity = c.opacity, + fg = beautiful.fg_normal or "#000000", + bg = col, + shape = function(cr2, w, h) + if c.shape then + c.shape(cr2, w, h) + else + return shape.rounded_rect(cr2, w, h, args.radius or 5) + end + end, + + forced_width = geo.width + 2*bw, + forced_height = geo.height + 2*bw, + widget = wibox.container.background, + } +end + +-- Mock the c:content(), it cannot be shimmed because the "real" one uses +-- a raw surface rather than a LGI one. +function screenshot._screenshot_methods.client(self) + local c = self.client + local geo = c:geometry() + + local wdg = client_widget( + c, c.color or geo._color or beautiful.bg_normal, "", {} + ) + + local sur = wibox.widget.draw_to_image_surface(wdg, geo.width, geo.height) + + return sur, geo +end + +function screenshot._screenshot_methods.root() + local w, h = root.size() + + local img = cairo.ImageSurface.create(cairo.Format.ARGB32, 1, 1) + + return img, {x=0,y=0,width=w,height=h} +end -- Run the test local args = loadfile(file_path)() or {} @@ -97,98 +209,6 @@ local total_area = wibox.layout { layout = wibox.layout.manual, } -local function wrap_titlebar(tb, width, height) - - local bg, fg - - if args.honor_titlebar_colors then - bg = tb.drawable.background_color or tb.args.bg_normal - fg = tb.drawable.foreground_color or tb.args.fg_normal - else - bg, fg = tb.args.bg_normal, tb.args.fg_normal - end - - return wibox.widget { - tb.drawable.widget, - bg = bg, - fg = fg, - forced_width = width, - forced_height = height, - widget = wibox.container.background - } -end - -local function client_widget(c, col, label) - local geo = c:geometry() - local bw = c.border_width or beautiful.border_width or 0 - local bc = c.border_color or beautiful.border_color - - local l = wibox.layout.align.vertical() - l.fill_space = true - - local tbs = c._private and c._private.titlebars or {} - - local map = { - top = "set_first", - left = "set_first", - bottom = "set_third", - right = "set_third", - } - - for _, position in ipairs{"top", "bottom"} do - local tb = tbs[position] - if tb then - l[map[position]](l, wrap_titlebar(tb, c:geometry().width, tb.args.height or 16)) - end - end - - for _, position in ipairs{"left", "right"} do - local tb = tbs[position] - if tb then - l[map[position]](l, wrap_titlebar(tb, tb.args.width or 16, c:geometry().height)) - end - end - - local l2 = wibox.layout.align.horizontal() - l2.fill_space = true - l:set_second(l2) - l.forced_width = c.width - l.forced_height = c.height - - return wibox.widget { - { - l, - { - text = label or "", - halign = "center", - valign = "center", - widget = wibox.widget.textbox - }, - layout = wibox.layout.stack - }, - border_width = bw, - border_color = bc, - shape_clip = true, - border_strategy = "inner", - opacity = c.opacity, - fg = beautiful.fg_normal or "#000000", - bg = col, - shape = function(cr2, w, h) - if c.shape then - c.shape(cr2, w, h) - else - return shape.rounded_rect(cr2, w, h, args.radius or 5) - end - end, - - forced_width = geo.width + 2*bw, - forced_height = geo.height + 2*bw, - widget = wibox.container.background, - } -end - --- Add all wiboxes - -- Fix the wibox geometries that have a dependency on their content for _, d in ipairs(drawin.get()) do local w = d.get_wibox and d:get_wibox() or nil @@ -228,7 +248,7 @@ for _, c in ipairs(client.get()) do for _, geo in ipairs(c._old_geo) do if not geo._hide then total_area:add_at( - client_widget(c, c.color or geo._color or beautiful.bg_normal, geo._label), + client_widget(c, c.color or geo._color or beautiful.bg_normal, geo._label, args), {x=geo.x, y=geo.y} ) end From 9207b78f815d9ba5df3dcc5dc616bce0ece03c60 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Tue, 6 Sep 2022 00:13:36 -0700 Subject: [PATCH 27/28] doc: Add `awful.screenshot` examples. --- lib/awful/screenshot.lua | 30 ++- .../awful/screenshot/keybindings1.lua | 197 ++++++++++++++++++ tests/examples/awful/screenshot/popup.lua | 158 ++++++++++++++ 3 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 tests/examples/awful/screenshot/keybindings1.lua create mode 100644 tests/examples/awful/screenshot/popup.lua diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua index fff381b1a..46210ef76 100644 --- a/lib/awful/screenshot.lua +++ b/lib/awful/screenshot.lua @@ -1,5 +1,23 @@ --------------------------------------------------------------------------- ---- Screenshots and related configuration settings +--- Take screenshots of clients, screens, geometry and export to files or widgets. +-- +-- Common keybindings +-- ================== +-- +-- This example setups keybinding for the "Print Screen" key. Shift means +-- interactive, Control means delayed and Mod4 (Super) means current client only. +-- This example also creates convinient notifications. +-- +-- @DOC_awful_screenshot_keybindings1_EXAMPLE@ +-- +-- Convert to widgets +-- ================== +-- +-- This example creates and `Alt+Tab` like popup with client images. Note that +-- it might display black rectangles if you are not using a compositing manager +-- such as `picom`. +-- +-- @DOC_awful_screenshot_popup_EXAMPLE@ -- -- @author Brian Sobulefsky <brian.sobulefsky@protonmail.com> -- @copyright 2021 Brian Sobulefsky @@ -437,10 +455,10 @@ end -- -- @property geometry -- @tparam table geometry --- @tparam table geometry.x --- @tparam table geometry.y --- @tparam table geometry.width --- @tparam table geometry.height +-- @tparam number geometry.x +-- @tparam number geometry.y +-- @tparam number geometry.width +-- @tparam number geometry.height -- @propemits true false --- Get screenshot surface. @@ -727,7 +745,6 @@ function module:set_auto_save_delay(value) self:emit_signal("timer::timeout") awesome.sync() self:refresh() - if not self.interactive then self:save() end @@ -786,7 +803,6 @@ function module:refresh() else self._private.surfaces[method] = {surface = surface, geometry = geo} end - end if self._private.output_imagebox then diff --git a/tests/examples/awful/screenshot/keybindings1.lua b/tests/examples/awful/screenshot/keybindings1.lua new file mode 100644 index 000000000..5e6de5972 --- /dev/null +++ b/tests/examples/awful/screenshot/keybindings1.lua @@ -0,0 +1,197 @@ +--DOC_NO_USAGE --DOC_GEN_IMAGE --DOC_HIDE_START +local awful = require("awful") +local wibox = require("wibox") +local beautiful = require("beautiful") +local naughty = require("naughty") +local s = screen[1] +screen[1]._resize {width = 640, height = 320} + +local wb = awful.wibar { position = "top" } +local modkey = "Mod4" + +-- Create the same number of tags as the default config +awful.tag({ "1", "2", "3", "4", "5", "6", "7", "8", "9" }, screen[1], awful.layout.layouts[1]) + +-- Only bother with widgets that are visible by default +local mykeyboardlayout = awful.widget.keyboardlayout() +local mytextclock = wibox.widget.textclock() +local mytaglist = awful.widget.taglist(screen[1], awful.widget.taglist.filter.all, {}) +local mytasklist = awful.widget.tasklist(screen[1], awful.widget.tasklist.filter.currenttags, {}) + +client.connect_signal("request::titlebars", function(c) + local top_titlebar = awful.titlebar(c, { + height = 20, + bg_normal = beautiful.bg_normal, + }) + + top_titlebar : setup { + { -- Left + awful.titlebar.widget.iconwidget(c), + layout = wibox.layout.fixed.horizontal + }, + { -- Middle + { -- Title + align = "center", + widget = awful.titlebar.widget.titlewidget(c) + }, + layout = wibox.layout.flex.horizontal + }, + { -- Right + awful.titlebar.widget.floatingbutton (c), + awful.titlebar.widget.maximizedbutton(c), + awful.titlebar.widget.stickybutton (c), + awful.titlebar.widget.ontopbutton (c), + awful.titlebar.widget.closebutton (c), + layout = wibox.layout.fixed.horizontal() + }, + layout = wibox.layout.align.horizontal + } +end) + +wb:setup { + layout = wibox.layout.align.horizontal, + { + mytaglist, + layout = wibox.layout.fixed.horizontal, + }, + mytasklist, + { + layout = wibox.layout.fixed.horizontal, + mykeyboardlayout, + mytextclock, + }, +} + +require("gears.timer").run_delayed_calls_now() +local counter = 0 + +local function gen_client(label) + local c = client.gen_fake {hide_first=true} + + c:geometry { + x = 45 + counter*1.75, + y = 30 + counter, + height = 60, + width = 230, + } + c._old_geo = {c:geometry()} + c:set_label(label) + c:emit_signal("request::titlebars") + c.border_color = beautiful.bg_highlight + counter = counter + 40 + + return c +end + +gen_client("C1") +gen_client("C2") + +--DOC_HIDE_END + + + local function saved_screenshot(args) + local ss = awful.screenshot(args) + + --DOC_NEWLINE + local function notify(self) + naughty.notification { + title = self.file_name, + message = "Screenshot saved", + icon = self.surface, + icon_size = 128, + } + end + --DOC_NEWLINE + + if args.auto_save_delay > 0 then + ss:connect_signal("file::saved", notify) + else + notify(ss) + end + --DOC_NEWLINE + + return ss + end + --DOC_NEWLINE + local function delayed_screenshot(args) + local ss = saved_screenshot(args) + local notif = naughty.notification { + title = "Screenshot in:", + message = tostring(args.auto_save_delay) .. " seconds" + } + --DOC_NEWLINE + + ss:connect_signal("timer::tick", function(_, remain) + notif.message = tostring(remain) .. " seconds" + end) + --DOC_NEWLINE + + ss:connect_signal("timer::timeout", function() + if notif then notif:destroy() end + end) + --DOC_NEWLINE + + return ss + end + --DOC_NEWLINE + client.connect_signal("request::default_keybindings", function() + awful.keyboard.append_client_keybindings({ + awful.key({modkey}, "Print", + function (c) saved_screenshot { auto_save_delay = 0, client = c } end , + {description = "take client screenshot", group = "client"}), + awful.key({modkey, "Shift"}, "Print", + function (c) saved_screenshot { auto_save_delay = 0, interactive = true, client = c } end , + {description = "take interactive client screenshot", group = "client"}), + awful.key({modkey, "Control"}, "Print", + function (c) delayed_screenshot { auto_save_delay = 5, client = c } end , + {description = "take screenshot in 5 seconds", group = "client"}), + awful.key({modkey, "Shift", "Control"}, "Print", + function (c) delayed_screenshot { auto_save_delay = 5, interactive = true, client = c } end , + {description = "take interactive screenshot in 5 seconds", group = "client"}), + }) + end) + --DOC_NEWLINE + + awful.keyboard.append_global_keybindings({ + awful.key({}, "Print", + function () saved_screenshot { auto_save_delay = 0 } end , + {description = "take screenshot", group = "client"}), + awful.key({"Shift"}, "Print", + function () saved_screenshot { auto_save_delay = 0, interactive = true } end , + {description = "take interactive screenshot", group = "client"}), + awful.key({"Control"}, "Print", + function () delayed_screenshot { auto_save_delay = 5 } end , + {description = "take screenshot in 5 seconds", group = "client"}), + awful.key({"Shift", "Control"}, "Print", + function () delayed_screenshot { auto_save_delay = 5, interactive = true } end , + {description = "take interactive screenshot in 5 seconds", group = "client"}), + }) + +--DOC_HIDE_START + +client.emit_signal("request::default_keybindings") + +-- A notification popup using the default widget_template. +naughty.connect_signal("request::display", function(n) + naughty.layout.box {notification = n} +end) + +awful.wallpaper { + screen = s, + widget = { + image = beautiful.wallpaper, + resize = true, + widget = wibox.widget.imagebox, + horizontal_fit_policy = "fit", + vertical_fit_policy = "fit", + } +} + +saved_screenshot { auto_save_delay = 0 } +delayed_screenshot { auto_save_delay = 5 } + +require("gears.timer").run_delayed_calls_now() --DOC_HIDE +--DOC_HIDE_END + + + diff --git a/tests/examples/awful/screenshot/popup.lua b/tests/examples/awful/screenshot/popup.lua new file mode 100644 index 000000000..3896c4085 --- /dev/null +++ b/tests/examples/awful/screenshot/popup.lua @@ -0,0 +1,158 @@ +--DOC_NO_USAGE --DOC_GEN_IMAGE --DOC_HIDE_START +local awful = require("awful") +local wibox = require("wibox") +local gears = require("gears") +local beautiful = require("beautiful") +local s = screen[1] +screen[1]._resize {width = 640, height = 320} + +local wb = awful.wibar { position = "top" } + +-- Create the same number of tags as the default config +awful.tag({ "1", "2", "3", "4", "5", "6", "7", "8", "9" }, screen[1], awful.layout.layouts[1]) + +-- Only bother with widgets that are visible by default +local mykeyboardlayout = awful.widget.keyboardlayout() +local mytextclock = wibox.widget.textclock() +local mytaglist = awful.widget.taglist(screen[1], awful.widget.taglist.filter.all, {}) +local mytasklist = awful.widget.tasklist(screen[1], awful.widget.tasklist.filter.currenttags, {}) + +client.connect_signal("request::titlebars", function(c) + local top_titlebar = awful.titlebar(c, { + height = 20, + bg_normal = beautiful.bg_normal, + }) + + top_titlebar : setup { + { -- Left + awful.titlebar.widget.iconwidget(c), + layout = wibox.layout.fixed.horizontal + }, + { -- Middle + { -- Title + align = "center", + widget = awful.titlebar.widget.titlewidget(c) + }, + layout = wibox.layout.flex.horizontal + }, + { -- Right + awful.titlebar.widget.floatingbutton (c), + awful.titlebar.widget.maximizedbutton(c), + awful.titlebar.widget.stickybutton (c), + awful.titlebar.widget.ontopbutton (c), + awful.titlebar.widget.closebutton (c), + layout = wibox.layout.fixed.horizontal() + }, + layout = wibox.layout.align.horizontal + } +end) + +wb:setup { + layout = wibox.layout.align.horizontal, + { + mytaglist, + layout = wibox.layout.fixed.horizontal, + }, + mytasklist, + { + layout = wibox.layout.fixed.horizontal, + mykeyboardlayout, + mytextclock, + }, +} + +require("gears.timer").run_delayed_calls_now() +local counter = 0 + +local function gen_client(label) + local c = client.gen_fake {hide_first=true} + + c:geometry { + x = 45 + counter*1.75, + y = 30 + counter, + height = 60, + width = 230, + } + c._old_geo = {c:geometry()} + c:set_label(label) + c:emit_signal("request::titlebars") + c.border_color = beautiful.bg_highlight + counter = counter + 40 + + return c +end + +local tasklist_buttons = {} +gen_client("C1") +gen_client("C2") + +--DOC_HIDE_END + + awful.popup { + widget = awful.widget.tasklist { + screen = screen[1], + filter = awful.widget.tasklist.filter.allscreen, + buttons = tasklist_buttons, + style = { + shape = gears.shape.rounded_rect, + align = "center" + }, + layout = { + spacing = 5, + forced_num_rows = 1, + layout = wibox.layout.grid.horizontal + }, + widget_template = { + { + { + id = "screenshot", + margins = 4, + forced_height = 128, + forced_width = 240, + widget = wibox.container.margin, + }, + { + id = "text_role", + forced_height = 20, + forced_width = 240, + widget = wibox.widget.textbox, + }, + widget = wibox.layout.fixed.vertical + }, + id = "background_role", + widget = wibox.container.background, + create_callback = function(self, c) --luacheck: no unused + assert(c) --DOC_HIDE + local ss = awful.screenshot { + client = c, + } + ss:refresh() + local ib = ss.content_widget + ib.valign = "center" + ib.halign = "center" + self:get_children_by_id("screenshot")[1].widget = ib + assert(ss.surface) --DOC_HIDE + end, + }, + }, + border_color = "#777777", + border_width = 2, + ontop = true, + placement = awful.placement.centered, + shape = gears.shape.rounded_rect + } + +--DOC_HIDE_START +awful.wallpaper { + screen = s, + widget = { + image = beautiful.wallpaper, + resize = true, + widget = wibox.widget.imagebox, + horizontal_fit_policy = "fit", + vertical_fit_policy = "fit", + } +} + +require("gears.timer").run_delayed_calls_now() --DOC_HIDE +--DOC_HIDE_END From e088fe9aed5c0500b21de7466930314ef15726c8 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Tue, 6 Sep 2022 00:43:26 -0700 Subject: [PATCH 28/28] tests: Improve the awful.screenshot test coverage. --- tests/test-screenshot.lua | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index af84fd2e9..2e60f46f3 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -5,6 +5,7 @@ local awful = require("awful") local wibox = require("wibox") local spawn = require("awful.spawn") local gsurface = require("gears.surface") +local gdebug = require("gears.debug") local lgi = require('lgi') local cairo = lgi.cairo local gdk = lgi.require('Gdk', '3.0') @@ -478,4 +479,38 @@ table.insert(steps, function() return true end) +table.insert(steps, function() + local ss = awful.screenshot { auto_save_delay = 10 } + + -- Reach into some `if`. + assert(not ss.selected_geometry) + assert(not ss.surface) + assert(#ss.surfaces == 0) + ss.auto_save_delay = 0 + ss.minimum_size = 2 + ss.minimum_size = {width = 1, height = 1} + ss.minimum_size = nil + assert(ss.surface) + assert(next(ss.surfaces) ~= nil) + + local count = 0 + + local err = gdebug.print_error + + function gdebug.print_error() + count = count + 1 + end + + -- Cause some validation failures. + ss.prefix = "//////" + assert(count == 1) + ss.directory = "/tmp/////" + ss.directory = "/tmp" + ss.directory = "/root/" + assert(count == 2) + + gdebug.print_error = err + return true +end) + require("_runner").run_steps(steps)