From 9ae945c931c12597920b081bee409e1e0674c761 Mon Sep 17 00:00:00 2001 From: Kazunobu Kuriyama Date: Mon, 21 Sep 2015 17:17:20 +0900 Subject: [PATCH] Enhance menubar icon path lookup The way of icon path lookup for `menubar` is enhanced so that it is based on a theme-oriented way as described in the specification: Icon Theme Specification, Ver. 0.12 http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html To accomplish this: * Add the two new files `icon_theme.lua` and `index_theme.lua`. The former implements an icon lookup algorithm suggested in the URL above. The latter implements a helper object to parse the cache file `index.theme` of which data is used by the former. * Modify `menu_gen.lua` to use the new algorithm. - The implementation of `lookup_category_icons` is changed accordingly. - The values of the field `all_categories.icon_name` are changed file names to icon names, i.e., file extensions which are used to indicate image file formats are removed. * Add the new file `icon_theme_spec.lua` for a unit test for checking if `icon_theme.lua` together with `index_theme.lua` works as expected. --- lib/menubar/icon_theme.lua | 262 ++++++++++++++++++ lib/menubar/index_theme.lua | 164 +++++++++++ lib/menubar/menu_gen.lua | 23 +- spec/menubar/icon_theme_spec.lua | 86 ++++++ .../icons/awesome/16x16/apps/awesome.png | 1 + .../icons/awesome/32x32/apps/awesome.png | 1 + .../icons/awesome/48x48/apps/awesome.png | 1 + .../icons/awesome/64x64/apps/awesome.png | 1 + spec/menubar/icons/awesome/index.theme | 16 ++ spec/menubar/icons/fallback.png | 1 + 10 files changed, 545 insertions(+), 11 deletions(-) create mode 100644 lib/menubar/icon_theme.lua create mode 100644 lib/menubar/index_theme.lua create mode 100644 spec/menubar/icon_theme_spec.lua create mode 120000 spec/menubar/icons/awesome/16x16/apps/awesome.png create mode 120000 spec/menubar/icons/awesome/32x32/apps/awesome.png create mode 120000 spec/menubar/icons/awesome/48x48/apps/awesome.png create mode 120000 spec/menubar/icons/awesome/64x64/apps/awesome.png create mode 100644 spec/menubar/icons/awesome/index.theme create mode 120000 spec/menubar/icons/fallback.png diff --git a/lib/menubar/icon_theme.lua b/lib/menubar/icon_theme.lua new file mode 100644 index 00000000..db779480 --- /dev/null +++ b/lib/menubar/icon_theme.lua @@ -0,0 +1,262 @@ +--------------------------------------------------------------------------- +--- Class module for icon lookup for menubar +-- +-- @author Kazunobu Kuriyama +-- @copyright 2015 Kazunobu Kuriyama +-- @release @AWESOME_VERSION@ +-- @classmod menubar.icon_theme +--------------------------------------------------------------------------- + +-- This implementation is based on the specifications: +-- Icon Theme Specification 0.12 +-- http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-0.12.html + +local beautiful = require("beautiful") +local awful = require("awful") +local GLib = require("lgi").GLib +local Gio = require("lgi").Gio +local index_theme = require("menubar.index_theme") + +local ipairs = ipairs +local setmetatable = setmetatable +local string = string +local table = table +local math = math +local io = io + +-- Returns a table whose element is a path used for icon lookup. +-- The names of the directories and the order of them are based on the spec. +local get_default_base_directories = function() + local dirs = {} + + table.insert(dirs, GLib.get_home_dir() .. "/.icons") + for _, dir in ipairs(GLib.get_system_data_dirs()) do + table.insert(dirs, dir .. "/icons") + end + table.insert(dirs, "/usr/share/pixmaps") + + return dirs +end + +local is_readable_directory = function(path) + local gfile = Gio.File.new_for_path(path) + local gfileinfo = gfile:query_info("standard::type,access::can-read", + Gio.FileQueryInfoFlags.NONE) + if not gfileinfo then return false end -- practically ENOENT + local is_dir = (gfileinfo:get_file_type() == "DIRECTORY") + local is_readable = gfileinfo:get_attribute_boolean("access::can-read") + return is_dir and is_readable +end + +local get_pragmatic_base_directories = function() + local dirs = {} + + local dir = GLib.get_home_dir() .. "/.icons" + if is_readable_directory(dir) then + table.insert(dirs, dir) + end + + dir = GLib.get_user_data_dir() .. "/icons" + if is_readable_directory(dir) then + table.insert(dirs, dir) + end + + for _, v in ipairs(GLib.get_system_data_dirs()) do + dir = v .. "/icons" + if is_readable_directory(dir) then + table.insert(dirs, dir) + end + end + + local need_usr_share_pixmaps = true + for _, v in ipairs(GLib.get_system_data_dirs()) do + dir = v .. "/pixmaps" + if is_readable_directory(dir) then + table.insert(dirs, dir) + end + if dir == "/usr/share/pixmaps" then + need_usr_share_pixmaps = false + end + end + + dir = "/usr/share/pixmaps" + if need_usr_share_pixmaps and is_readable_directory(dir) then + table.insert(dirs, dir) + end + + return dirs +end + +local get_default_icon_theme_name = function() + local icon_theme_names = { "Adwaita", "gnome", "hicolor" } + for _, dir in ipairs(get_pragmatic_base_directories()) do + for _, icon_theme_name in ipairs(icon_theme_names) do + local filename = string.format("%s/%s/index.theme", dir, icon_theme_name) + if awful.util.file_readable(filename) then + return icon_theme_name + end + end + end + return nil +end + +local icon_theme = { mt = {} } + +--- Class constructor of `icon_theme` +-- @tparam string icon_theme_name Internal name of icon theme +-- @tparam table base_directories Paths used for lookup +-- @treturn table An instance of the class `icon_theme` +icon_theme.new = function(cls, icon_theme_name, base_directories) + local icon_theme_name = icon_theme_name or beautiful.icon_theme or get_default_icon_theme_name() + local base_directories = base_directories or get_pragmatic_base_directories() + + local self = {} + self.icon_theme_name = icon_theme_name + self.base_directories = base_directories + self.extensions = { "png", "svg", "xpm" } + self.index_theme = index_theme(self.icon_theme_name, self.base_directories) + + return setmetatable(self, { __index = cls }) +end + +local directory_matches_size = function(self, subdirectory, icon_size) + local kind, size, min_size, max_size, threshold = self.index_theme:get_per_directory_keys(subdirectory) + + if kind == "Fixed" then + return icon_size == size + elseif kind == "Scaled" then + return icon_size >= min_size and icon_size <= max_size + elseif kind == "Threshold" then + return icon_size >= size - threshold and icon_size <= size + threshold + end + + return false +end + +local directory_size_distance = function(self, subdirectory, icon_size) + local kind, size, min_size, max_size, threshold = self.index_theme:get_per_directory_keys(subdirectory) + + if kind == "Fixed" then + return math.abs(icon_size - size) + elseif kind == "Scaled" then + if icon_size < min_size then + return min_size - icon_size + elseif icon_size > max_size then + return icon_size - max_size + end + return 0 + elseif kind == "Threshold" then + if icon_size < size - threshold then + return min_size - icon_size + elseif icon_size > size + threshold then + return icon_size - max_size + end + return 0 + end + + return 0xffffffff -- Any large number will do. +end + +local lookup_icon = function(self, icon_name, icon_size) + for _, subdir in ipairs(self.index_theme:get_subdirectories()) do + for _, basedir in ipairs(self.base_directories) do + for _, ext in ipairs(self.extensions) do + if directory_matches_size(self, subdir, icon_size) then + local filename = string.format("%s/%s/%s/%s.%s", + basedir, self.icon_theme_name, subdir, + icon_name, ext) + if awful.util.file_readable(filename) then + return filename + end + end + end + end + end + + local minimal_size = 0xffffffff -- Any large number will do. + local closest_filename = nil + for _, subdir in ipairs(self.index_theme:get_subdirectories()) do + for _, basedir in ipairs(self.base_directories) do + for _, ext in ipairs(self.extensions) do + local filename = string.format("%s/%s/%s/%s.%s", + basedir, self.icon_theme_name, subdir, + icon_name, ext) + local dist = directory_size_distance(self, subdir, icon_size) + if awful.util.file_readable(filename) and dist < minimal_size then + closest_filename = filename + minimal_size = dist + end + end + end + end + if closest_filename then + return closest_filename + end + + return nil +end + +local find_icon_path_helper +find_icon_path_helper = function(self, icon_name, icon_size) + local filename = lookup_icon(self, icon_name, icon_size) + if filename then + return filename + end + + for _, parent in ipairs(self.index_theme:get_inherits()) do + local parent_icon_theme = icon_theme(parent, self.base_directories) + filename = find_icon_path_helper(parent_icon_theme, icon_name, icon_size) + if filename then + return filename + end + end + + return nil +end + +local lookup_fallback_icon = function(self, icon_name) + for _, dir in ipairs(self.base_directories) do + for _, ext in ipairs(self.extensions) do + local filename = string.format("%s/%s.%s", + dir, + icon_name, ext) + if awful.util.file_readable(filename) then + return filename + end + end + end + return nil +end + +--- Look up an image file based on a given icon name and/or a preferable size. +-- @tparam string icon_name Icon name to be looked up +-- @tparam number icon_size Prefereable icon size +-- @treturn string Absolute path to the icon file, or nil if not found +icon_theme.find_icon_path = function(self, icon_name, icon_size) + if not icon_name or icon_name == "" then + return nil + end + local icon_size = icon_size or 16 + + local filename = find_icon_path_helper(self, icon_name, icon_size) + if filename then + return filename + end + + if self.icon_theme_name ~= "hicolor" then + filaname = find_icon_path_helper(icon_theme("hicolor", self.base_directories), icon_name, icon_size) + if filename then + return filename + end + end + + return lookup_fallback_icon(self, icon_name) +end + +icon_theme.mt.__call = function(cls, icon_theme_name, base_directories) + return icon_theme.new(cls, icon_theme_name, base_directories) +end + +return setmetatable(icon_theme, icon_theme.mt) + +-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80 diff --git a/lib/menubar/index_theme.lua b/lib/menubar/index_theme.lua new file mode 100644 index 00000000..41b12e19 --- /dev/null +++ b/lib/menubar/index_theme.lua @@ -0,0 +1,164 @@ +--------------------------------------------------------------------------- +--- Class module for parsing an index.theme file +-- +-- @author Kazunobu Kuriyama +-- @copyright 2015 Kazunobu Kuriyama +-- @release @AWESOME_VERSION@ +-- @classmod menubar.index_theme +--------------------------------------------------------------------------- + +-- This implementation is based on the specifications: +-- Icon Theme Specification 0.12 +-- http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-0.12.html + +local ipairs = ipairs +local setmetatable = setmetatable +local string = string +local table = table +local io = io + +-- index.theme groups +local ICON_THEME = "Icon Theme" +-- index.theme keys +local DIRECTORIES = "Directories" +local INHERITS = "Inherits" +-- per-directory subkeys +local TYPE = "Type" +local SIZE = "Size" +local MINSIZE = "MinSize" +local MAXSIZE = "MaxSize" +local THRESHOLD = "Threshold" + +local index_theme = { mt = {} } + +--- Class constructor of `index_theme` +-- @tparam string icon_theme_name Internal name of icon theme +-- @tparam table base_directories Paths used for lookup +-- @treturn table An instance of the class `index_theme` +index_theme.new = function(cls, icon_theme_name, base_directories) + local self = {} + setmetatable(self, { __index = cls }) + + -- Initialize the fields + self.icon_theme_name = icon_theme_name + self.base_directory = nil + self[DIRECTORIES] = {} + self[INHERITS] = {} + self.per_directory_keys = {} + + -- base_directory + local basedir = nil + local handler = nil + for _, dir in ipairs(base_directories) do + basedir = dir .. "/" .. self.icon_theme_name + handler = io.open(basedir .. "/index.theme", "r") + if handler then + -- Use the index.theme which is found first. + break + end + end + if not handler then + return self + end + self.base_directory = basedir + + -- Parse index.theme. + while true do + local line = handler:read() + if not line then + break + end + + local group_header = "^%[(.+)%]$" + local group = line:match(group_header) + if group then + if group == ICON_THEME then + while true do + local item = handler:read() + if not item then + break + end + if item:match(group_header) then + handler:seek("cur", -string.len(item) - 1) + break + end + + local k, v = item:match("^(%w+)=(.*)$") + if k == DIRECTORIES or k == INHERITS then + string.gsub(v, "([^,]+),?", function(match) + table.insert(self[k], match) + end) + end + end + else + -- This must be a 'per-directory keys' group + local keys = {} + + while true do + local item = handler:read() + if not item then + break + end + if item:match(group_header) then + handler:seek("cur", -string.len(item) - 1) + break + end + + local k, v = item:match("^(%w+)=(%w+)$") + if k == SIZE or k == MINSIZE or k == MAXSIZE or k == THRESHOLD then + keys[k] = tonumber(v) + elseif k == TYPE then + keys[k] = v + end + end + + -- Size is a must. Other keys are optional. + if keys[SIZE] then + -- Set unset keys to the default values. + if not keys[TYPE] then keys[TYPE] = THRESHOLD end + if not keys[MINSIZE] then keys[MINSIZE] = keys[SIZE] end + if not keys[MAXSIZE] then keys[MAXSIZE] = keys[SIZE] end + if not keys[THRESHOLD] then keys[THRESHOLD] = 2 end + + self.per_directory_keys[group] = keys + end + end + end + end + + handler:close() + + return self +end + +--- Table of the values of the `Directories` key +-- @treturn table Values of the `Directories` key +index_theme.get_subdirectories = function(self) + return self[DIRECTORIES] +end + +--- Table of the values of the `Inherits` key +-- @treturn table Values of the `Inherits` key +index_theme.get_inherits = function(self) + return self[INHERITS] +end + +--- Query (part of) per-directory keys of a given subdirectory name. +-- @tparam table subdirectory Icon theme's subdirectory +-- @treturn[1] string Value of the `Type` key +-- @treturn[2] number Value of the `Size` key +-- @treturn[3] number VAlue of the `MinSize` key +-- @treturn[4] number Value of the `MaxSize` key +-- @treturn[5] number Value of the `Threshold` key +index_theme.get_per_directory_keys = function(self, subdirectory) + local keys = self.per_directory_keys[subdirectory] + return keys[TYPE], keys[SIZE], keys[MINSIZE], keys[MAXSIZE], keys[THRESHOLD] +end + +index_theme.mt.__call = function(cls, icon_theme_name, base_directories) + return index_theme.new(cls, icon_theme_name, base_directories) +end + +return setmetatable(index_theme, index_theme.mt) + +-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80 diff --git a/lib/menubar/menu_gen.lua b/lib/menubar/menu_gen.lua index e0c944df..630cfa11 100644 --- a/lib/menubar/menu_gen.lua +++ b/lib/menubar/menu_gen.lua @@ -9,6 +9,7 @@ -- Grab environment local utils = require("menubar.utils") +local icon_theme = require("menubar.icon_theme") local pairs = pairs local ipairs = ipairs local string = string @@ -32,31 +33,31 @@ menu_gen.all_menu_dirs = { data_dir .. 'applications/', '/usr/share/applications -- the applications that fall only to this category will not be shown. menu_gen.all_categories = { multimedia = { app_type = "AudioVideo", name = "Multimedia", - icon_name = "applications-multimedia.png", use = true }, + icon_name = "applications-multimedia", use = true }, development = { app_type = "Development", name = "Development", - icon_name = "applications-development.png", use = true }, + icon_name = "applications-development", use = true }, education = { app_type = "Education", name = "Education", - icon_name = "applications-science.png", use = true }, + icon_name = "applications-science", use = true }, games = { app_type = "Game", name = "Games", - icon_name = "applications-games.png", use = true }, + icon_name = "applications-games", use = true }, graphics = { app_type = "Graphics", name = "Graphics", - icon_name = "applications-graphics.png", use = true }, + icon_name = "applications-graphics", use = true }, office = { app_type = "Office", name = "Office", - icon_name = "applications-office.png", use = true }, + icon_name = "applications-office", use = true }, internet = { app_type = "Network", name = "Internet", - icon_name = "applications-internet.png", use = true }, + icon_name = "applications-internet", use = true }, settings = { app_type = "Settings", name = "Settings", - icon_name = "applications-utilities.png", use = true }, + icon_name = "applications-utilities", use = true }, tools = { app_type = "System", name = "System Tools", - icon_name = "applications-system.png", use = true }, + icon_name = "applications-system", use = true }, utility = { app_type = "Utility", name = "Accessories", - icon_name = "applications-accessories.png", use = true } + icon_name = "applications-accessories", use = true } } --- Find icons for category entries. function menu_gen.lookup_category_icons() for _, v in pairs(menu_gen.all_categories) do - v.icon = utils.lookup_icon(v.icon_name) + v.icon = icon_theme():find_icon_path(v.icon_name) end end diff --git a/spec/menubar/icon_theme_spec.lua b/spec/menubar/icon_theme_spec.lua new file mode 100644 index 00000000..f02fd3a8 --- /dev/null +++ b/spec/menubar/icon_theme_spec.lua @@ -0,0 +1,86 @@ +--------------------------------------------------------------------------- +-- @author Kazunobu Kuriyama +-- @copyright 2015 Kazunobu Kuriyama +--------------------------------------------------------------------------- + +-- Hack so that beautiful can be loaded +_G.awesome = { + xrdb_get_value = function() end, + connect_signal = function(...) end, + register_xproperty = function(...) end +} +-- Additional hacks to load menubar +_G.screen = { + add_signal = function(...) end, + count = function() return 0 end +} +_G.client = { + connect_signal = function(...) end, + add_signal = function(...) end +} +_G.tag = { + connect_signal = function(...) end, + add_signal = function(...) end +} +_G.root = { + cursor = function(...) end +} + +local os = os +local string = string +local icon_theme = require("menubar.icon_theme") + +local base_directories = { + os.getenv("PWD") .. "/spec/menubar/icons", + os.getenv("PWD") .. "/icons" +} + +describe("menubar.icon_theme find_icon_path", function() + local obj + before_each(function() + obj = icon_theme("awesome", base_directories) + end) + + -- Invalid arguments for `icon_name` + it("nil", function() + assert.is_nil(obj:find_icon_path(nil)) + end) + it ('""', function() + assert.is.equal(obj:find_icon_path(""), nil) + end) + + -- hierarchical folder (icon theme) cases + for _, v in ipairs({16, 32, 48, 64}) do + it(v, function() + local expected = string.format("%s/awesome/%dx%d/apps/awesome.png", + base_directories[1], v, v) + assert.is.same(expected, obj:find_icon_path("awesome", v)) + end) + end + + -- hierarchical folder (icon theme) cases w/ fallback + for _, v in ipairs({16, 32, 48, 64}) do + it(v, function() + local expected = string.format("%s/fallback.png", + base_directories[1]) + assert.is.same(expected, obj:find_icon_path("fallback", v)) + end) + end + + -- flat folder/fallback cases + local fallback_cases = { + "awesome16", + "awesome32", + "awesome48", + "awesome64" + } + for _, v in ipairs(fallback_cases) do + it(v, function() + local expected = string.format("%s/%s.png", + base_directories[2], v) + assert.is.same(expected, obj:find_icon_path(v)) + end) + end +end) + +-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80 diff --git a/spec/menubar/icons/awesome/16x16/apps/awesome.png b/spec/menubar/icons/awesome/16x16/apps/awesome.png new file mode 120000 index 00000000..101d373d --- /dev/null +++ b/spec/menubar/icons/awesome/16x16/apps/awesome.png @@ -0,0 +1 @@ +../../../../../../icons/awesome16.png \ No newline at end of file diff --git a/spec/menubar/icons/awesome/32x32/apps/awesome.png b/spec/menubar/icons/awesome/32x32/apps/awesome.png new file mode 120000 index 00000000..a7448a4e --- /dev/null +++ b/spec/menubar/icons/awesome/32x32/apps/awesome.png @@ -0,0 +1 @@ +../../../../../../icons/awesome32.png \ No newline at end of file diff --git a/spec/menubar/icons/awesome/48x48/apps/awesome.png b/spec/menubar/icons/awesome/48x48/apps/awesome.png new file mode 120000 index 00000000..470b8135 --- /dev/null +++ b/spec/menubar/icons/awesome/48x48/apps/awesome.png @@ -0,0 +1 @@ +../../../../../../icons/awesome48.png \ No newline at end of file diff --git a/spec/menubar/icons/awesome/64x64/apps/awesome.png b/spec/menubar/icons/awesome/64x64/apps/awesome.png new file mode 120000 index 00000000..d2822a7b --- /dev/null +++ b/spec/menubar/icons/awesome/64x64/apps/awesome.png @@ -0,0 +1 @@ +../../../../../../icons/awesome64.png \ No newline at end of file diff --git a/spec/menubar/icons/awesome/index.theme b/spec/menubar/icons/awesome/index.theme new file mode 100644 index 00000000..ec64235b --- /dev/null +++ b/spec/menubar/icons/awesome/index.theme @@ -0,0 +1,16 @@ +[Icon Theme] +Name=awesome + +Directories=16x16/apps,32x32/apps,48x48/apps,64x64/apps, + +[16x16/apps] +Size=16 + +[32x32/apps] +Size=32 + +[48x48/apps] +Size=48 + +[64x64/apps] +Size=64 diff --git a/spec/menubar/icons/fallback.png b/spec/menubar/icons/fallback.png new file mode 120000 index 00000000..d013c344 --- /dev/null +++ b/spec/menubar/icons/fallback.png @@ -0,0 +1 @@ +../../../icons/awesome16.png \ No newline at end of file