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/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 diff --git a/lib/awful/keygrabber.lua b/lib/awful/keygrabber.lua index 444f4db4b..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 @@ -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 @@ -542,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 @@ -574,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 = {} @@ -630,6 +659,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. diff --git a/lib/awful/screenshot.lua b/lib/awful/screenshot.lua new file mode 100644 index 000000000..46210ef76 --- /dev/null +++ b/lib/awful/screenshot.lua @@ -0,0 +1,943 @@ +--------------------------------------------------------------------------- +--- 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 +-- @classmod awful.screenshot +--------------------------------------------------------------------------- + +-- Grab environment we need +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 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 + +-- The module to be returned +local module = { mt = {}, _screenshot_methods = {} } +local screenshot_validation = {} + +local datetime_obj = datetime.new_now + +-- 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 + +-- 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 + +-- 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() + + 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, "^~/", + string.gsub(os.getenv("HOME"), "/*$", "/", 1)) + elseif string.find(directory, "^[^/]") then + directory = string.gsub(os.getenv("HOME"), "/*$", "/", 1) .. directory + end + + -- Assure that we return exactly one trailing slash + directory = string.gsub(directory, '/*$', '/', 1) + + -- Add a trailing "/" if none is present. + if directory:sub(-1) ~= "/" then + directory = directory .. "/" + 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. +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 + +-- 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 + + return file_path +end + +-- 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 + + return geo +end + +function screenshot_validation.screen(scr) + return capi.screen[scr] +end + +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 +-- +-- 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(source, geo) + local target = source:create_similar(cairo.Content.COLOR, geo.width, geo.height) + + 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 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(self, surface, geo) + local col = self._private.frame_color + or beautiful.screenshot_frame_color + or "#ff0000" + + local shape = self.frame_shape + or beautiful.screenshot_frame_shape + or gears.shape.rectangle + + local w, h = root.size() + + 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 + + self._private.canvas_widget = wibox.widget { + widget = wibox.layout.manual + } + + self._private.imagebox = wibox.widget { + image = surface, + widget = wibox.widget.imagebox + } + + 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 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 snipping is cancelled. +-- @signal snipping::cancelled +-- @tparam awful.screenshot self +-- @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. +-- +-- 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 = {} + + local accept_buttons, reject_buttons = {}, {} + + --TODO support modifiers. + for _, btn in ipairs(self.accept_buttons) do + accept_buttons[btn.button] = true + end + for _, btn in ipairs(self.reject_buttons) do + reject_buttons[btn.button] = true + end + + local pressed = false + + show_frame(self, surface, geometry) + + local function ret_mg_callback(mouse_data, accept, reject) + 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 + self:reject("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) + + 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 + 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 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") + 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 + + 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. +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 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 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() + + local c_geo = c:geometry() + + 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 module._screenshot_methods.geometry(self) + local root_w, root_h = root.size() + + local root_intrsct = gears.geometry.rectangle.get_intersection(self.geometry, { + x = 0, + y = 0, + width = root_w, + height = root_h + }) + + return crop_shot(module._screenshot_methods.root(self), root_intrsct), root_intrsct +end + +-- Various accessors for the screenshot object returned by any public +-- module method. + +--- Get screenshot directory property. +-- +-- @property directory +-- @tparam[opt=os.getenv("HOME")] string directory +-- @propemits true false + +--- Get screenshot prefix property. +-- +-- @property prefix +-- @tparam[opt="Screenshot-"] string prefix +-- @propemits true false + +--- Get screenshot file path. +-- +-- @property file_path +-- @tparam[opt=self.directory..self.prefix..os.date()..".png"] string file_path +-- @propemits true false +-- @see file_name + +--- Get screenshot file name. +-- +-- @property file_name +-- @tparam[opt=self.prefix..os.date()..".png"] string file_name +-- @propemits true false +-- @see file_path + +--- 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. +-- +-- @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 +-- @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 + +--- Get screenshot client. +-- +-- @property client +-- @tparam[opt=nil] client|nil client +-- @propemits true false +-- @see mouse.client +-- @see client.focus + +--- Get screenshot geometry. +-- +-- @property geometry +-- @tparam table geometry +-- @tparam number geometry.x +-- @tparam number geometry.y +-- @tparam number geometry.width +-- @tparam number geometry.height +-- @propemits true false + +--- Get screenshot surface. +-- +-- 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. +-- +-- 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 + +--- 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 + +--- 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 +-- @propbeautiful +-- @propemits true false + +--- The interactive frame shape. +-- @property frame_shape +-- @tparam shape|nil frame_shape +-- @propbeautiful +-- @propemits true false + +--- 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 + +--- 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 + +--- 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 + +--- 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 + +--- 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")), + 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", "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 + self:emit_signal("property::"..prop, value) + end + + module["get_"..prop] = function(self) + return self._private[prop] or defaults[prop] + 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 + +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_surface() + local pair = select(2, next(self._private.surfaces or {})) + return pair and pair.surface +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 + +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 + +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 + +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 + + if self._private.output_imagebox then + self._private.output_imagebox.image = self.surface + 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 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 + + 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 + +--- 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 +-- +-- @constructorfct awful.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 + }) + + 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/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. 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 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 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 diff --git a/tests/test-screenshot.lua b/tests/test-screenshot.lua index 954a37d9f..2e60f46f3 100644 --- a/tests/test-screenshot.lua +++ b/tests/test-screenshot.lua @@ -1,16 +1,17 @@ -- 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") +local gdebug = require("gears.debug") local lgi = require('lgi') local cairo = lgi.cairo 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") @@ -33,6 +34,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() @@ -104,9 +109,15 @@ 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 + snipper_success = img and get_pixel(img, 10, 10) == "#00ff00" +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, @@ -127,17 +138,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 @@ -148,7 +166,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] @@ -167,4 +185,332 @@ table.insert(steps, function() return true end) +table.insert(steps, function() + --Make sure client from last test is gone + if #client.get() ~= 0 then return end + + local fake_screenshot_dir2 = string.gsub(fake_screenshot_dir, "/*$", "/", 1) + + local ss = awful.screenshot { directory = "/tmp" } + local name_prfx = fake_screenshot_dir2 .. "Screenshot-" + + local f = string.find(ss.file_path, name_prfx) + if f ~= 1 then + error("Failed autogenerate filename: " .. ss.file_path .. " : " .. name_prfx) + return false + end + + name_prfx = fake_screenshot_dir2 .. "MyShot.png" + ss.file_path = name_prfx + + if ss.file_path ~= name_prfx then + error("Failed assign filename: " .. ss.file_path .. " : " .. name_prfx) + 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 { directory = "/tmp" } + ss:refresh() + + local img = ss.surface + assert(img) + + 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") + + if ss.screen ~= nil or ss.client ~= nil then + error("Returned non nil screen or client for root screenshot") + 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 + local ss = awful.screenshot {screen = s, directory = "/tmp" } + ss:refresh() + + 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 + assert(#client.get() == 0) + spawn(tiny_client) + + return true + +end) + +-- Check the awful.screenshot.client() method +table.insert(steps, function() + + if #client.get() ~= 1 then return end + + local c = client.get()[1] + local geo = c:geometry() + 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 + 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) + +--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) + awesome.sync() + return true +end) + +table.insert(steps, function() + local ss = awful.screenshot { interactive = true, directory = "/tmp" } + ss:refresh() + + ss:connect_signal("snipping::success", 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() + mouse.coords {x = 190, y = 190} + awesome.sync() + return true +end) + +table.insert(steps, function() + root.fake_input("button_release",1) + return true +end) + +table.insert(steps, function() + if snipper_success == nil then return end + + return snipper_success +end) + + +--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) + + awesome.sync() + return true +end) + +table.insert(steps, function() + local ss = awful.screenshot { interactive = true, directory = "/tmp" } + ss:connect_signal("snipping::success", 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} + awesome.sync() + return true +end) + +table.insert(steps, function() + --Cause a rectangle collapse + mouse.coords {x = 150, y = 110} + awesome.sync() + 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) + awesome.sync() + return true +end) + +table.insert(steps, function() + local ss = awful.screenshot { + geometry = {x = 100, y = 100, width = 100, height = 100} + } + ss:refresh() + + local img = ss.surface + 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) + +-- Test auto-save +table.insert(steps, function() + if not escape_works then + root.fake_input("key_press","Escape") + root.fake_input("key_release","Escape") + 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) + +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)