--------------------------------------------------------------------------- --- 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 -- @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 tmp = check_directory(directory) 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 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(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 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 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 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 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) -- 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 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 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 end return screenshot