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.
This commit is contained in:
Kazunobu Kuriyama 2015-09-21 17:17:20 +09:00
parent bf630de74e
commit 9ae945c931
10 changed files with 545 additions and 11 deletions

262
lib/menubar/icon_theme.lua Normal file
View File

@ -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

164
lib/menubar/index_theme.lua Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
../../../../../../icons/awesome16.png

View File

@ -0,0 +1 @@
../../../../../../icons/awesome32.png

View File

@ -0,0 +1 @@
../../../../../../icons/awesome48.png

View File

@ -0,0 +1 @@
../../../../../../icons/awesome64.png

View File

@ -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

View File

@ -0,0 +1 @@
../../../icons/awesome16.png