--------------------------------------------------------------------------- --- Utility module for menubar -- -- @author Antonio Terceiro -- @copyright 2009, 2011-2012 Antonio Terceiro, Alexander Yakushev -- @module menubar.utils --------------------------------------------------------------------------- -- Grab environment local table = table local ipairs = ipairs local string = string local screen = screen local gfs = require("gears.filesystem") local theme = require("beautiful") local lgi = require("lgi") local gio = lgi.Gio local glib = lgi.GLib local w_textbox = require("wibox.widget.textbox") local gdebug = require("gears.debug") local protected_call = require("gears.protected_call") local gstring = require("gears.string") local unpack = unpack or table.unpack -- luacheck: globals unpack (compatibility with Lua 5.1) local utils = {} -- NOTE: This icons/desktop files module was written according to the -- following freedesktop.org specifications: -- Icons: http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-0.11.html -- Desktop files: http://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-1.0.html -- Options section --- Terminal which applications that need terminal would open in. utils.terminal = 'xterm' --- The default icon for applications that don't provide any icon in -- their .desktop files. local default_icon = nil --- Name of the WM for the OnlyShowIn entry in the .desktop file. utils.wm_name = "awesome" --- Possible escape characters (not including the preceding backslash) in --.desktop files and their equates. local escape_sequences = { [ [[\\]] ] = [[\]], [ [[\n]] ] = '\n', [ [[\r]] ] = '\r', [ [[\s]] ] = ' ', [ [[\t]] ] = '\t', } -- Maps keys in desktop entries to suitable getter function. -- The order of entries is as in the spec. -- https://standards.freedesktop.org/desktop-entry-spec/latest/ar01s05.html local keys_getters do local function get_string(kf, key) return kf:get_string("Desktop Entry", key) end local function get_strings(kf, key) return kf:get_string_list("Desktop Entry", key, nil) end local function get_localestring(kf, key) return kf:get_locale_string("Desktop Entry", key, nil) end local function get_localestrings(kf, key) return kf:get_locale_string_list("Desktop Entry", key, nil, nil) end local function get_boolean(kf, key) return kf:get_boolean("Desktop Entry", key) end keys_getters = { Type = get_string, Version = get_string, Name = get_localestring, GenericName = get_localestring, NoDisplay = get_boolean, Comment = get_localestring, Icon = get_localestring, Hidden = get_boolean, OnlyShowIn = get_strings, NotShowIn = get_strings, DBusActivatable = get_boolean, TryExec = get_string, Exec = get_string, Path = get_string, Terminal = get_boolean, Actions = get_strings, MimeType = get_strings, Categories = get_strings, Implements = get_strings, Keywords = get_localestrings, StartupNotify = get_boolean, StartupWMClass = get_string, URL = get_string, } end -- Private section local do_protected_call, call_callback do -- Lua 5.1 cannot yield across a protected call. Instead of hardcoding a -- check, we check for this problem: The following coroutine yields true on -- success (so resume() returns true, true). On failure, pcall returns -- false and a message, so resume() returns true, false, message. local _, has_yieldable_pcall = coroutine.resume(coroutine.create(function() return pcall(coroutine.yield, true) end)) if has_yieldable_pcall then do_protected_call = protected_call.call call_callback = function(callback, ...) return callback(...) end else do_protected_call = function(f, ...) return f(...) end call_callback = protected_call.call end end local all_icon_sizes = { 'scalable', '128x128', '96x96', '72x72', '64x64', '48x48', '36x36', '32x32', '24x24', '22x22', '16x16' } --- List of supported icon formats. local icon_formats = { "png", "xpm", "svg" } --- Check whether the icon format is supported. -- @param icon_file Filename of the icon. -- @return true if format is supported, false otherwise. local function is_format_supported(icon_file) for _, f in ipairs(icon_formats) do if icon_file:match('%.' .. f) then return true end end return false end local icon_lookup_path = nil --- Get a list of icon lookup paths. -- @treturn table A list of directories, without trailing slash. local function get_icon_lookup_path() if icon_lookup_path then return icon_lookup_path end local function ensure_args(t, paths) if type(paths) == 'string' then paths = { paths } end return t or {}, paths end local function add_if_readable(t, paths) t, paths = ensure_args(t, paths) for _, path in ipairs(paths) do if gfs.dir_readable(path) then table.insert(t, path) end end return t end local function add_with_dir(t, paths, dir) t, paths = ensure_args(t, paths) dir = { nil, dir } for _, path in ipairs(paths) do dir[1] = path table.insert(t, glib.build_filenamev(dir)) end return t end icon_lookup_path = {} local theme_priority = { 'hicolor' } if theme.icon_theme then table.insert(theme_priority, 1, theme.icon_theme) end local paths = add_with_dir({}, glib.get_home_dir(), '.icons') add_with_dir(paths, { glib.get_user_data_dir(), -- $XDG_DATA_HOME, typically $HOME/.local/share unpack(glib.get_system_data_dirs()) -- $XDG_DATA_DIRS, typically /usr/{,local/}share }, 'icons') add_with_dir(paths, glib.get_system_data_dirs(), 'pixmaps') local icon_theme_paths = {} for _, theme_dir in ipairs(theme_priority) do add_if_readable(icon_theme_paths, add_with_dir({}, paths, theme_dir)) end local app_in_theme_paths = {} for _, icon_theme_directory in ipairs(icon_theme_paths) do for _, size in ipairs(all_icon_sizes) do table.insert(app_in_theme_paths, glib.build_filenamev({ icon_theme_directory, size, 'apps' })) table.insert(app_in_theme_paths, glib.build_filenamev({ icon_theme_directory, size, 'categories' })) end end add_if_readable(icon_lookup_path, app_in_theme_paths) return add_if_readable(icon_lookup_path, paths) end --- Remove CR newline from the end of the string. -- @param s string to trim function utils.rtrim(s) if not s then return end if string.byte(s, #s) == 13 then return string.sub(s, 1, #s - 1) end return s end --- Unescape strings read from desktop files. -- Possible sequences are \n, \r, \s, \t, \\, which have the usual meanings. -- @tparam string s String to unescape -- @treturn string The unescaped string function utils.unescape(s) if not s then return end -- Ignore the second return value of string.gsub() (number replaced) s = string.gsub(s, "\\.", function(sequence) return escape_sequences[sequence] or sequence end) return s end --- Separate semi-colon separated lists. -- Semi-colons in lists are escaped as '\;'. -- @tparam string s String to parse -- @treturn table A table containing the separated strings. Each element is -- unescaped by utils.unescape(). function utils.parse_list(s) if not s then return end -- Append terminating semi-colon if not already there. if string.sub(s, -1) ~= ';' then s = s .. ';' end local segments = {} local part = "" -- Iterate over sub-strings between semicolons for word, backslashes in string.gmatch(s, "([^;]-(\\*));") do if #backslashes % 2 ~= 0 then -- The semicolon was escaped, remember this part for later part = part .. word:sub(1, -2) .. ";" else table.insert(segments, utils.unescape(part .. word)) part = "" end end return segments end --- Lookup an icon in different folders of the filesystem. -- @tparam string icon_file Short or full name of the icon. -- @treturn string|boolean Full name of the icon, or false on failure. function utils.lookup_icon_uncached(icon_file) if not icon_file or icon_file == "" then return false end if icon_file:sub(1, 1) == '/' and is_format_supported(icon_file) then -- If the path to the icon is absolute and its format is -- supported, do not perform a lookup. return gfs.file_readable(icon_file) and icon_file or nil else for _, directory in ipairs(get_icon_lookup_path()) do if is_format_supported(icon_file) and gfs.file_readable(directory .. "/" .. icon_file) then return directory .. "/" .. icon_file else -- Icon is probably specified without path and format, -- like 'firefox'. Try to add supported extensions to -- it and see if such file exists. for _, format in ipairs(icon_formats) do local possible_file = directory .. "/" .. icon_file .. "." .. format if gfs.file_readable(possible_file) then return possible_file end end end end return false end end local lookup_icon_cache = {} --- Lookup an icon in different folders of the filesystem (cached). -- @param icon Short or full name of the icon. -- @return full name of the icon. function utils.lookup_icon(icon) if not lookup_icon_cache[icon] and lookup_icon_cache[icon] ~= false then lookup_icon_cache[icon] = utils.lookup_icon_uncached(icon) end return lookup_icon_cache[icon] or default_icon end --- Parse a .desktop file. -- @param file The .desktop file. -- @return A table with file entries. function utils.parse_desktop_file(file) local program = { show = true, file = file } -- Parse the .desktop file. -- We are interested in [Desktop Entry] group only. local keyfile = glib.KeyFile() if not keyfile:load_from_file(file, glib.KeyFileFlags.NONE) then return nil end -- In case [Desktop Entry] was not found if not keyfile:has_group("Desktop Entry") then return nil end for _, key in pairs(keyfile:get_keys("Desktop Entry")) do local getter = keys_getters[key] or function(kf, k) return kf:get_string("Desktop Entry", k) end program[key] = getter(keyfile, key) end -- In case the (required) 'Name' entry was not found if not program.Name or program.Name == '' then return nil end -- Don't show program if NoDisplay attribute is true if program.NoDisplay then program.show = false else -- Only check these values is NoDisplay is true (or non-existent) -- Only show the program if there is no OnlyShowIn attribute -- or if it contains wm_name if program.OnlyShowIn then program.show = false -- Assume false until found for _, wm in ipairs(program.OnlyShowIn) do if wm == utils.wm_name then program.show = true break end end else program.show = true end -- Only need to check NotShowIn if the program is being shown if program.show and program.NotShowIn then for _, wm in ipairs(program.NotShowIn) do if wm == utils.wm_name then program.show = false break end end end end -- Look up for a icon. if program.Icon then program.icon_path = utils.lookup_icon(program.Icon) end -- Make the variable lower-case like the rest of them if program.Categories then program.categories = program.Categories end if program.Exec then -- Substitute Exec special codes as specified in -- http://standards.freedesktop.org/desktop-entry-spec/1.1/ar01s06.html if program.Name == nil then program.Name = '['.. file:match("([^/]+)%.desktop$") ..']' end local cmdline = program.Exec:gsub('%%c', program.Name) cmdline = cmdline:gsub('%%[fuFU]', '') cmdline = cmdline:gsub('%%k', program.file) if program.icon_path then cmdline = cmdline:gsub('%%i', '--icon ' .. program.icon_path) else cmdline = cmdline:gsub('%%i', '') end if program.Terminal == true then cmdline = utils.terminal .. ' -e ' .. cmdline end program.cmdline = cmdline end return program end --- Parse a directory with .desktop files recursively. -- @tparam string dir_path The directory path. -- @tparam function callback Will be fired when all the files were parsed -- with the resulting list of menu entries as argument. -- @tparam table callback.programs Paths of found .desktop files. function utils.parse_dir(dir_path, callback) local function get_readable_path(file) return file:get_path() or file:get_uri() end local function parser(file, programs) -- Except for "NONE" there is also NOFOLLOW_SYMLINKS local query = gio.FILE_ATTRIBUTE_STANDARD_NAME .. "," .. gio.FILE_ATTRIBUTE_STANDARD_TYPE local enum, err = file:async_enumerate_children(query, gio.FileQueryInfoFlags.NONE) if not enum then gdebug.print_warning(get_readable_path(file) .. ": " .. tostring(err)) return end local files_per_call = 100 -- Actual value is not that important while true do local list, enum_err = enum:async_next_files(files_per_call) if enum_err then gdebug.print_error(get_readable_path(file) .. ": " .. tostring(enum_err)) return end for _, info in ipairs(list) do local file_type = info:get_file_type() local file_child = enum:get_child(info) if file_type == 'REGULAR' then local path = file_child:get_path() if path then local success, program = pcall(utils.parse_desktop_file, path) if not success then gdebug.print_error("Error while reading '" .. path .. "': " .. program) elseif program then table.insert(programs, program) end end elseif file_type == 'DIRECTORY' then parser(file_child, programs) end end if #list == 0 then break end end enum:async_close() end gio.Async.start(do_protected_call)(function() local result = {} parser(gio.File.new_for_path(dir_path), result) call_callback(callback, result) end) end function utils.compute_textbox_width(textbox, s) gdebug.deprecate("Use 'width, _ = textbox:get_preferred_size(s)' directly.", {deprecated_in=4}) s = screen[s or mouse.screen] local w, _ = textbox:get_preferred_size(s) return w end --- Compute text width. -- @tparam str text Text. -- @tparam number|screen s Screen -- @treturn int Text width. function utils.compute_text_width(text, s) local w, _ = w_textbox(gstring.xml_escape(text)):get_preferred_size(s) return w end return utils -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80