From e4fd438e3ed75c4dc57241d546c3c27779eb4e1d Mon Sep 17 00:00:00 2001 From: Kasper Date: Fri, 5 Nov 2021 05:38:54 +0200 Subject: [PATCH] More app launcher improvements (#132) * Make sure there is always a default icon * Use get_example_icon_path to get the default icon * Refactor scrolling to add support for left and right scrolling * fixup! Make sure there is always a default icon * Fix calculation happening at the wrong time * Fix default icons again * Where did that come from? * Fix calculation happening at the wrong time for scroll_up as well * Fix error on scroll right when scrolling to page where amount of rows is smaller than the selected row * Sort search results by string similarity * Don't sort by similarity when the search string is empty * Add hover effects * try_to_keep_index_after_searching should be false by default * This should only trigger for lmb * Add an option to hide the app launcher when clicked with lmb/rmb outside of it * Use gtk-launch so terminal apps spawn correctly * Use get_executable instead of get_commandline * Add an option to set the hover colors * Further improvements for the spawn function * Fix scrolling/searching errors when there app list is empty * This should never be nil anyway * whitespace * Refactor show, hide and toggle method + remove support for manually setting x an y (use placement) * Add arguements for custom icon_theme (defaults to the selected system gtk theme) and icon_size (defaults to 48) * Pass the app table instead of individual keys to create_app_widget * Add an arguement to pass the default terminal for terminal apps as gtk-launch only uses xterm * Reformating * Rename 'mark_app' and 'unmark_app' to 'select_app' and 'unselect_app' * Call :hide() from app.spawn() to avoid calling hide() twice on some cases * Fix escape not closing the launcher after b7e44ec4 * Reduce code duplication and only reset the launcher when the animation is over (if not nil) * Set active_widget to nil when the grid is empty to prevent from spawning the wrong app * Override the default exe_callback * Override the default behaviour for 'Return' via hooks instead because overriding only exe_callback still doesn't stop it from pausing the prompt keygrabber * Set active_widget to nil on unselect * Unselect previous app on search to avoid from spawning it when the grid is empty * Use double quotes for everything --- helpers/color.lua | 106 ++++++ helpers/icon_theme.lua | 13 + widget/app_launcher.lua | 805 +++++++++++++++++++++++++++------------- 3 files changed, 672 insertions(+), 252 deletions(-) diff --git a/helpers/color.lua b/helpers/color.lua index 22d6501..4042360 100644 --- a/helpers/color.lua +++ b/helpers/color.lua @@ -1,3 +1,12 @@ +local tonumber = tonumber +local string = string +local math = math +local floor = math.floor +local max = math.max +local min = math.min +local abs = math.abs +local format = string.format + local _color = {} --- Try to guess if a color is dark or light. @@ -13,6 +22,14 @@ function _color.is_dark(color) return (numeric_value < 383) end +function _color.is_opaque(color) + if type(color) == "string" then + color = _color.hex_to_rgba(color) + end + + return color.a < 0.01 +end + --- Lighten a color. -- -- @string color The color to lighten with hexadecimal HTML format `"#RRGGBB"`. @@ -49,4 +66,93 @@ function _color.darken(color, amount) return _color.lighten(color, -amount) end +-- Returns a value that is clipped to interval edges if it falls outside the interval +function _color.clip(num, min_num, max_num) + return max(min(num, max_num), min_num) +end + +-- Converts the given hex color to rgba +function _color.hex_to_rgba(color) + color = color:gsub("#", "") + return { r = tonumber("0x" .. color:sub(1, 2)), + g = tonumber("0x" .. color:sub(3, 4)), + b = tonumber("0x" .. color:sub(5, 6)), + a = #color == 8 and tonumber("0x" .. color:sub(7, 8)) or 255 } +end + +-- Converts the given rgba color to hex +function _color.rgba_to_hex(color) + local r = _color.clip(color.r or color[1], 0, 255) + local g = _color.clip(color.g or color[2], 0, 255) + local b = _color.clip(color.b or color[3], 0, 255) + local a = _color.clip(color.a or color[4] or 255, 0, 255) + return "#" .. format("%02x%02x%02x%02x", + floor(r), + floor(g), + floor(b), + floor(a)) +end + +-- Converts the given hex color to hsv +function _color.hex_to_hsv(color) + local color = _color.hex2rgb(color) + local C_max = max(color.r, color.g, color.b) + local C_min = min(color.r, color.g, color.b) + local delta = C_max - C_min + local H, S, V + if delta == 0 then + H = 0 + elseif C_max == color.r then + H = 60 * (((color.g - color.b) / delta) % 6) + elseif C_max == color.g then + H = 60 * (((color.b - color.r) / delta) + 2) + elseif C_max == color.b then + H = 60 * (((color.r - color.g) / delta) + 4) + end + if C_max == 0 then + S = 0 + else + S = delta / C_max + end + V = C_max + + return { h = H, + s = S * 100, + v = V * 100 } +end + +-- Converts the given hsv color to hex +function _color.hsv_to_hex(H, S, V) + S = S / 100 + V = V / 100 + if H > 360 then H = 360 end + if H < 0 then H = 0 end + local C = V * S + local X = C * (1 - abs(((H / 60) % 2) - 1)) + local m = V - C + local r_, g_, b_ = 0, 0, 0 + if H >= 0 and H < 60 then + r_, g_, b_ = C, X, 0 + elseif H >= 60 and H < 120 then + r_, g_, b_ = X, C, 0 + elseif H >= 120 and H < 180 then + r_, g_, b_ = 0, C, X + elseif H >= 180 and H < 240 then + r_, g_, b_ = 0, X, C + elseif H >= 240 and H < 300 then + r_, g_, b_ = X, 0, C + elseif H >= 300 and H < 360 then + r_, g_, b_ = C, 0, X + end + local r, g, b = (r_ + m) * 255, (g_ + m) * 255, (b_ + m) * 255 + return ("#%02x%02x%02x"):format(floor(r), floor(g), floor(b)) +end + +function _color.multiply(color, amount) + return { _color.clip(color.r * amount, 0, 255), + _color.clip(color.g * amount, 0, 255), + _color.clip(color.b * amount, 0, 255), + 255 } +end + return _color diff --git a/helpers/icon_theme.lua b/helpers/icon_theme.lua index 492d81e..4a1db92 100644 --- a/helpers/icon_theme.lua +++ b/helpers/icon_theme.lua @@ -55,6 +55,19 @@ function icon_theme:get_client_icon_path(client) return icon end +function icon_theme:choose_icon(icons_names) + local icon_info = Gtk.IconTheme.choose_icon(self.gtk_theme, icons_names, self.icon_size, 0); + if icon_info then + local icon_path = Gtk.IconInfo.get_filename(icon_info) + if icon_path then + return icon_path + end + end + + return "" +end + + function icon_theme:get_gicon_path(gicon) if gicon == nil then return "" diff --git a/widget/app_launcher.lua b/widget/app_launcher.lua index 8ef7d92..6f3f335 100644 --- a/widget/app_launcher.lua +++ b/widget/app_launcher.lua @@ -5,105 +5,66 @@ local gtable = require("gears.table") local gtimer = require("gears.timer") local wibox = require("wibox") local beautiful = require("beautiful") -local icon_theme = require(tostring(...):match(".*bling") .. ".helpers.icon_theme")() +local color = require(tostring(...):match(".*bling") .. ".helpers.color") local dpi = beautiful.xresources.apply_dpi - local string = string local table = table local math = math +local ipairs = ipairs local pairs = pairs local root = root +local capi = { screen = screen, mouse = mouse } +local path = ... local app_launcher = { mt = {} } -local function mark_app(self, index) - local app = self._private.grid.children[index] - if app ~= nil then - app:get_children_by_id("background")[1].bg = self.app_selected_color - local text_widget = app:get_children_by_id("text")[1] - if text_widget ~= nil then - text_widget.markup = "" .. text_widget.text .. "" - end - end -end +local terminal_commands_lookup = +{ + alacritty = "alacritty -e", + termite = "termite -e", + rxvt = "rxvt -e", + terminator = "terminator -e" +} -local function unmark_app(self, index) - local app = self._private.grid.children[index] - if app ~= nil then - app:get_children_by_id("background")[1].bg = self.app_normal_color - local text_widget = app:get_children_by_id("text")[1] - if text_widget ~= nil then - text_widget.markup = "" .. text_widget.text .. "" - end - end -end +local function string_levenshtein(str1, str2) + local len1 = string.len(str1) + local len2 = string.len(str2) + local matrix = {} + local cost = 0 -local function create_app_widget(self, name, cmdline, icon, index) - local icon = self.app_show_icon == true - and - { - widget = wibox.container.place, - halign = self.app_name_halign, - { - widget = wibox.widget.imagebox, - forced_width = self.app_icon_width, - forced_height = self.app_icon_height, - image = icon - } - } - or nil - local name = self.app_show_name == true - and - { - widget = wibox.container.place, - halign = self.app_icon_halign, - { - widget = wibox.widget.textbox, - id = "text", - align = "center", - font = self.app_name_font, - markup = name - } - } - or nil + -- quick cut-offs to save time + if (len1 == 0) then + return len2 + elseif (len2 == 0) then + return len1 + elseif (str1 == str2) then + return 0 + end - return wibox.widget - { - widget = wibox.container.background, - id = "background", - forced_width = self.app_width, - forced_height = self.app_height, - shape = self.app_shape, - bg = self.app_normal_color, - spawn = function() awful.spawn(cmdline) end, - buttons = - { - awful.button({}, 1, function() - if index == self._private.current_index or not self.select_before_spawn then - awful.spawn(cmdline) - self:hide() - else - -- Unmark the previous app - unmark_app(self, self._private.current_index) + -- initialise the base matrix values + for i = 0, len1, 1 do + matrix[i] = {} + matrix[i][0] = i + end + for j = 0, len2, 1 do + matrix[0][j] = j + end - self._private.current_index = index + -- actual Levenshtein algorithm + for i = 1, len1, 1 do + for j = 1, len2, 1 do + if (str1:byte(i) == str2:byte(j)) then + cost = 0 + else + cost = 1 + end - -- Mark this app - mark_app(self, self._private.current_index) - end - end), - }, - { - widget = wibox.container.place, - valign = self.app_content_valign, - { - layout = wibox.layout.fixed.vertical, - spacing = self.app_content_spacing, - icon, - name - } - } - } + matrix[i][j] = math.min(matrix[i-1][j] + 1, matrix[i][j-1] + 1, matrix[i-1][j-1] + cost) + end + end + + -- return the last value - this is the Levenshtein distance + return matrix[len1][len2] end local function has_value(tab, val) @@ -130,26 +91,184 @@ local function case_insensitive_pattern(pattern) return p end +local function select_app(self, x, y) + local widgets = self._private.grid:get_widgets_at(x, y) + if widgets then + self._private.active_widget = widgets[1] + if self._private.active_widget ~= nil then + self._private.active_widget.selected = true + self._private.active_widget:get_children_by_id("background")[1].bg = self.app_selected_color + local text_widget = self._private.active_widget:get_children_by_id("text")[1] + text_widget.markup = "" .. text_widget.text .. "" + end + end +end + +local function unselect_app(self) + if self._private.active_widget ~= nil then + self._private.active_widget.selected = false + self._private.active_widget:get_children_by_id("background")[1].bg = self.app_normal_color + local text_widget = self._private.active_widget:get_children_by_id("text")[1] + text_widget.markup = "" .. text_widget.text .. "" + self._private.active_widget = nil + end +end + +local function create_app_widget(self, entry) + local icon = self.app_show_icon == true + and + { + widget = wibox.container.place, + halign = self.app_name_halign, + { + widget = wibox.widget.imagebox, + forced_width = self.app_icon_width, + forced_height = self.app_icon_height, + image = entry.icon + } + } + or nil + local name = self.app_show_name == true + and + { + widget = wibox.container.place, + halign = self.app_icon_halign, + { + widget = wibox.widget.textbox, + id = "text", + align = "center", + font = self.app_name_font, + markup = entry.name + } + } + or nil + + local app = wibox.widget + { + widget = wibox.container.background, + id = "background", + forced_width = self.app_width, + forced_height = self.app_height, + shape = self.app_shape, + bg = self.app_normal_color, + { + widget = wibox.container.place, + valign = self.app_content_valign, + { + layout = wibox.layout.fixed.vertical, + spacing = self.app_content_spacing, + icon, + name + } + } + } + + function app.spawn() + if entry.terminal == true then + if self.terminal ~= nil then + local terminal_command = terminal_commands_lookup[self.terminal] or self.terminal + awful.spawn(terminal_command .. " " .. entry.executable) + else + awful.spawn.easy_async("gtk-launch " .. entry.executable, function(stdout, stderr) + if stderr then + awful.spawn(entry.executable) + end + end) + end + else + awful.spawn(entry.executable) + end + + self:hide() + end + + app:connect_signal("mouse::enter", function(_self) + local widget = capi.mouse.current_wibox + if widget then + widget.cursor = "hand2" + end + + local app = _self + if app.selected then + app:get_children_by_id("background")[1].bg = self.app_selected_hover_color + else + local is_opaque = color.is_opaque(self.app_normal_color) + local is_dark = color.is_dark(self.app_normal_color) + local app_normal_color = color.hex_to_rgba(self.app_normal_color) + local hover_color = (is_dark or is_opaque) and + color.rgba_to_hex(color.multiply(app_normal_color, 2.5)) or + color.rgba_to_hex(color.multiply(app_normal_color, 0.5)) + app:get_children_by_id("background")[1].bg = self.app_normal_hover_color + end + end) + + app:connect_signal("mouse::leave", function(_self) + local widget = capi.mouse.current_wibox + if widget then + widget.cursor = "left_ptr" + end + + local app = _self + if app.selected then + app:get_children_by_id("background")[1].bg = self.app_selected_color + else + app:get_children_by_id("background")[1].bg = self.app_normal_color + end + end) + + app:connect_signal("button::press", function(_self, lx, ly, button, mods, find_widgets_result) + if button == 1 then + local app = _self + if self._private.active_widget == app or not self.select_before_spawn then + app.spawn() + else + -- Unmark the previous app + unselect_app(self) + + -- Mark this app + local pos = self._private.grid:get_widget_position(app) + select_app(self, pos.row, pos.col) + end + end + end) + + return app +end + local function search(self, text) + unselect_app(self) + + local pos = self._private.grid:get_widget_position(self._private.active_widget) + -- Reset all the matched entries self._private.matched_entries = {} -- Remove all the grid widgets self._private.grid:reset() - for index, entry in pairs(self._private.all_entries) do - text = text:gsub( "%W", "" ) + if text == "" then + self._private.matched_entries = self._private.all_entries + else + for index, entry in pairs(self._private.all_entries) do + text = text:gsub( "%W", "" ) - -- Check if there's a match by the app name or app command - if string.find(entry.name, case_insensitive_pattern(text)) ~= nil or - self.search_commands and string.find(entry.cmdline, case_insensitive_pattern(text)) ~= nil - then - table.insert(self._private.matched_entries, { name = entry.name, cmdline = entry.cmdline, icon = entry.icon }) - - -- Only add the widgets for apps that are part of the first page - if #self._private.grid.children + 1 <= self._private.max_apps_per_page then - self._private.grid:add(create_app_widget(self, entry.name, entry.cmdline, entry.icon, #self._private.grid.children + 1)) + -- Check if there's a match by the app name or app command + if string.find(entry.name, case_insensitive_pattern(text)) ~= nil or + self.search_commands and string.find(entry.commandline, case_insensitive_pattern(text)) ~= nil + then + table.insert(self._private.matched_entries, { name = entry.name, commandline = entry.commandline, executable = entry.executable, terminal = entry.terminal, icon = entry.icon }) end end + + -- Sort by string similarity + table.sort(self._private.matched_entries, function(a, b) + return string_levenshtein(text, a.name) < string_levenshtein(text, b.name) + end) + end + for index, entry in pairs(self._private.matched_entries) do + -- Only add the widgets for apps that are part of the first page + if #self._private.grid.children + 1 <= self._private.max_apps_per_page then + self._private.grid:add(create_app_widget(self, entry)) + end end -- Recalculate the apps per page based on the current matched entries @@ -158,190 +277,315 @@ local function search(self, text) -- Recalculate the pages count based on the current apps per page self._private.pages_count = math.ceil(math.max(1, #self._private.matched_entries) / math.max(1, self._private.apps_per_page)) + -- Page should be 1 after a search + self._private.current_page = 1 + -- This is an option to mimic rofi behaviour where after a search -- it will reselect the app whose index is the same as the app index that was previously selected -- and if matched_entries.length < current_index it will instead select the app with the greatest index if self.try_to_keep_index_after_searching then - self._private.current_index = math.max(math.min(self._private.current_index, #self._private.matched_entries), 1) - + if self._private.grid:get_widgets_at(pos.row, pos.col) == nil then + local app = self._private.grid.children[#self._private.grid.children] + pos = self._private.grid:get_widget_position(app) + end + select_app(self, pos.row, pos.col) -- Otherwise select the first app on the list else - self._private.current_index = 1 + select_app(self, 1, 1) end - self._private.current_page = 1 - - mark_app(self, self._private.current_index) end local function scroll_up(self) + if #self._private.grid.children < 1 then + self._private.active_widget = nil + return + end + + local rows, columns = self._private.grid:get_dimension() + local pos = self._private.grid:get_widget_position(self._private.active_widget) + local is_bigger_than_first_app = pos.col > 1 or pos.row > 1 + -- Check if the current marked app is not the first - if self._private.current_index > 1 then - unmark_app(self, self._private.current_index) - - -- Current index should be decremented - self._private.current_index = self._private.current_index - 1 - - -- Mark the new app - mark_app(self, self._private.current_index) - + if is_bigger_than_first_app then + unselect_app(self) + if pos.row == 1 then + select_app(self, rows, pos.col - 1) + else + select_app(self, pos.row - 1, pos.col) + end -- Check if the current page is not the first elseif self._private.current_page > 1 then - -- Remove the current page apps from the grid - self._private.grid:reset() + -- Remove the current page apps from the grid + self._private.grid:reset() - local max_app_index_to_include = (self._private.current_page - 1) * self._private.apps_per_page - local min_app_index_to_include = max_app_index_to_include - self._private.apps_per_page + local max_app_index_to_include = (self._private.current_page - 1) * self._private.apps_per_page + local min_app_index_to_include = max_app_index_to_include - self._private.apps_per_page + for index, entry in pairs(self._private.matched_entries) do + -- Only add widgets that are between this range (part of the current page) + if index > min_app_index_to_include and index <= max_app_index_to_include then + self._private.grid:add(create_app_widget(self, entry)) + end + end - for index, entry in pairs(self._private.matched_entries) do - -- Only add widgets that are between this range (part of the current page) - if index > min_app_index_to_include and index <= max_app_index_to_include then - self._private.grid:add(create_app_widget(self, entry.name, entry.cmdline, entry.icon, #self._private.grid.children + 1)) - end - end + -- If we scrolled up a page, selected app should be the last one + rows, columns = self._private.grid:get_dimension() + select_app(self, rows, columns) - -- If we scrolled up a page, selected app should be the last one - self._private.current_index = self._private.apps_per_page - mark_app(self, self._private.current_index) - - -- Current page should be decremented - self._private.current_page = self._private.current_page - 1 + -- Current page should be decremented + self._private.current_page = self._private.current_page - 1 end end local function scroll_down(self) - local is_less_than_max_app = self._private.current_index < #self._private.grid.children + if #self._private.grid.children < 1 then + self._private.active_widget = nil + return + end + + local rows, columns = self._private.grid:get_dimension() + local pos = self._private.grid:get_widget_position(self._private.active_widget) + local is_less_than_max_app = self._private.grid:index(self._private.active_widget) < #self._private.grid.children local is_less_than_max_page = self._private.current_page < self._private.pages_count -- Check if we can scroll down the app list if is_less_than_max_app then -- Unmark the previous app - unmark_app(self, self._private.current_index) - - -- Current index should be incremented - self._private.current_index = self._private.current_index + 1 - - -- Mark the new app - mark_app(self, self._private.current_index) - + unselect_app(self) + if pos.row == rows then + select_app(self, 1, pos.col + 1) + else + select_app(self, pos.row + 1, pos.col) + end -- If we can't scroll down the app list, check if we can scroll down a page elseif is_less_than_max_page then -- Remove the current page apps from the grid self._private.grid:reset() - local min_app_index_to_include = self._private.current_index * self._private.current_page + local min_app_index_to_include = self._private.apps_per_page * self._private.current_page local max_app_index_to_include = min_app_index_to_include + self._private.apps_per_page for index, entry in pairs(self._private.matched_entries) do -- Only add widgets that are between this range (part of the current page) if index > min_app_index_to_include and index <= max_app_index_to_include then - self._private.grid:add(create_app_widget(self, entry.name, entry.cmdline, entry.icon, #self._private.grid.children + 1)) + self._private.grid:add(create_app_widget(self, entry)) end end - -- Current app is 1 if we scroll to the next page - self._private.current_index = 1 - mark_app(self, self._private.current_index) + -- Select app 1 when scrolling to the next page + select_app(self, 1, 1) -- Current page should be incremented self._private.current_page = self._private.current_page + 1 end end ---- Shows the app launcher -function app_launcher:show(args) - local args = args or {} +local function scroll_left(self) + if #self._private.grid.children < 1 then + self._private.active_widget = nil + return + end - self.screen = args.screen or self.screen - self.screen.app_launcher = self._private.widget - self.screen.app_launcher.screen = self.screen - self.screen.app_launcher.visible = true + local pos = self._private.grid:get_widget_position(self._private.active_widget) + local is_bigger_than_first_column = pos.col > 1 + local is_not_first_page = self._private.current_page > 1 + + -- Check if the current marked app is not the first + if is_bigger_than_first_column then + unselect_app(self) + select_app(self, pos.row, pos.col - 1) + -- Check if the current page is not the first + elseif is_not_first_page then + -- Remove the current page apps from the grid + self._private.grid:reset() + + local max_app_index_to_include = (self._private.current_page - 1) * self._private.apps_per_page + local min_app_index_to_include = max_app_index_to_include - self._private.apps_per_page + + for index, entry in pairs(self._private.matched_entries) do + -- Only add widgets that are between this range (part of the current page) + if index > min_app_index_to_include and index <= max_app_index_to_include then + self._private.grid:add(create_app_widget(self, entry)) + end + end + + -- Keep the same row from last page + local rows, columns = self._private.grid:get_dimension() + select_app(self, pos.row, columns) + + -- Current page should be decremented + self._private.current_page = self._private.current_page - 1 + end +end + +local function scroll_right(self) + if #self._private.grid.children < 1 then + self._private.active_widget = nil + return + end + + local rows, columns = self._private.grid:get_dimension() + local pos = self._private.grid:get_widget_position(self._private.active_widget) + local is_less_than_max_column = pos.col < columns + local is_less_than_max_page = self._private.current_page < self._private.pages_count + + -- Check if we can scroll down the app list + if is_less_than_max_column then + -- Unmark the previous app + unselect_app(self) + + -- Scroll up to the max app if there are directly to the right of previous app + if self._private.grid:get_widgets_at(pos.row, pos.col + 1) == nil then + local app = self._private.grid.children[#self._private.grid.children] + pos = self._private.grid:get_widget_position(app) + select_app(self, pos.row, pos.col) + else + select_app(self, pos.row, pos.col + 1) + end + + -- If we can't scroll down the app list, check if we can scroll down a page + elseif is_less_than_max_page then + -- Remove the current page apps from the grid + self._private.grid:reset() + + local min_app_index_to_include = self._private.apps_per_page * self._private.current_page + local max_app_index_to_include = min_app_index_to_include + self._private.apps_per_page + + for index, entry in pairs(self._private.matched_entries) do + -- Only add widgets that are between this range (part of the current page) + if index > min_app_index_to_include and index <= max_app_index_to_include then + self._private.grid:add(create_app_widget(self, entry)) + end + end + + -- Keep the last row + select_app(self, math.min(pos.row, #self._private.grid.children), 1) + + -- Current page should be incremented + self._private.current_page = self._private.current_page + 1 + end +end + +local function init(self) + self._private.grid:reset() + self._private.matched_entries = self._private.all_entries + self._private.apps_per_page = self._private.max_apps_per_page + self._private.pages_count = math.ceil(#self._private.all_entries / self._private.apps_per_page) + self._private.current_page = 1 + + for index, entry in pairs(self._private.all_entries) do + -- Only add the apps that are part of the first page + if index <= self._private.apps_per_page then + self._private.grid:add(create_app_widget(self, entry)) + else + break + end + end + + select_app(self, 1, 1) +end + +--- Shows the app launcher +function app_launcher:show() + local screen = self.screen + if self.show_on_focused_screen then + screen = awful.screen.focused() + end + + screen.app_launcher = self._private.widget + screen.app_launcher.screen = screen + screen.app_launcher.visible = true self._private.prompt:run() - local x = args.x or self.x or nil - if self.rubato and self.rubato.x and x then - self.rubato.x:set(x) - elseif x then - self.screen.app_launcher.x = x - end - - local y = args.y or self.y or nil - if self.rubato and self.rubato.y and y then - self.rubato.y:set(y) - elseif y then - self.screen.app_launcher.y = y - end - - local placement = args.placement or self.placement or nil + local placement = self.placement if placement then - self.screen.app_launcher.placement = placement + local pos = placement(self.screen.app_launcher, {pretend = true}) + local animation = self.rubato + if animation ~= nil then + if animation.x then + animation.x.ended:unsubscribe() + animation.x:set(pos.x) + else + self._private.widget.x = pos.x + end + if animation.y then + animation.y.ended:unsubscribe() + animation.y:set(pos.y) + else + self._private.widget.y = pos.y + end + else + self._private.widget.x = pos.x + self._private.widget.y = pos.y + end end self:emit_signal("bling::app_launcher::visibility", true) end --- Hides the app launcher -function app_launcher:hide(args) - local args = args or {} +function app_launcher:hide() + local screen = self.screen + if self.show_on_focused_screen then + screen = awful.screen.focused() + end + + if screen.app_launcher == nil or screen.app_launcher.visible == false then + return + end -- There's no other way to stop the prompt? - root.fake_input('key_press', "Escape") - root.fake_input('key_release', "Escape") + root.fake_input("key_press", "Escape") + root.fake_input("key_release", "Escape") - if self.rubato and self.rubato.x then - self.rubato.x:set(self.rubato.x:initial()) - self.rubato.x.ended:subscribe(function() - self.screen.app_launcher.visible = false - end) - end - - if self.rubato and self.rubato.y then - self.rubato.y:set(self.rubato.y:initial()) - self.rubato.y.ended:subscribe(function() - self.screen.app_launcher.visible = false - end) - end - - if not self.rubato then - self.screen.app_launcher.visible = false - end - - self.screen = args.screen or self.screen - self.screen.app_launcher = {} - - -- Reset back to initial values - self._private.apps_per_page = self._private.max_apps_per_page - self._private.pages_count = math.ceil(#self._private.all_entries / self._private.apps_per_page) - self._private.matched_entries = self._private.all_entries - self._private.current_index = 1 - self._private.current_page = 1 - self._private.grid:reset() - - -- Add the app widgets for the next time - for index, entry in pairs(self._private.all_entries) do - -- Only add the apps that are part of the first page - if index <= self._private.apps_per_page then - self._private.grid:add(create_app_widget(self, entry.name, entry.cmdline, entry.icon, index)) - else - break + local animation = self.rubato + if animation ~= nil then + if animation.x then + animation.x:set(animation.x:initial()) + end + if animation.y then + animation.y:set(animation.y:initial()) end - end - -- Select the first app for the next time - mark_app(self, self._private.current_index) + local anim_x_duration = (animation.x and animation.x.duration) or 0 + local anim_y_duration = (animation.y and animation.y.duration) or 0 + local turn_off_on_anim_x_end = (anim_x_duration >= anim_y_duration) and true or false + + if turn_off_on_anim_x_end then + animation.x.ended:subscribe(function() + init(self) + screen.app_launcher.visible = false + screen.app_launcher = nil + animation.x.ended:unsubscribe() + end) + else + animation.y.ended:subscribe(function() + init(self) + screen.app_launcher.visible = false + screen.app_launcher = nil + animation.y.ended:unsubscribe() + end) + end + else + init(self) + screen.app_launcher.visible = false + screen.app_launcher = nil + end self:emit_signal("bling::app_launcher::visibility", false) end --- Toggles the app launcher -function app_launcher:toggle(args) - local args = args or {} +function app_launcher:toggle() + local screen = self.screen + if self.show_on_focused_screen then + screen = awful.screen.focused() + end - self.screen = args.screen or self.screen - if self.screen.app_launcher and self.screen.app_launcher.visible then - self:hide(self.screen) + if screen.app_launcher and screen.app_launcher.visible then + self:hide() else - self:show(self.screen) + self:show() end end @@ -349,24 +593,28 @@ end local function new(args) args = args or {} + args.terminal = args.terminal or nil args.search_commands = args.search_commands or true args.skip_names = args.skip_names or {} args.skip_commands = args.skip_commands or {} args.skip_empty_icons = args.skip_empty_icons or false args.sort_alphabetically = args.sort_alphabetically or true args.select_before_spawn = args.select_before_spawn or true + args.hide_on_clicked_outside = args.hide_on_clicked_outside or true args.try_to_keep_index_after_searching = args.try_to_keep_index_after_searching or false + args.default_app_icon_name = args.default_app_icon_name or nil args.default_app_icon_path = args.default_app_icon_path or nil + args.icon_theme = args.icon_theme or nil + args.icons_size = args.icons_size or nil + args.show_on_focused_screen = args.show_on_focused_screen or true + args.screen = args.screen or capi.screen.primary + args.placement = args.placement or awful.placement.centered args.rubato = args.rubato or nil args.shirnk_width = args.shirnk_width or false args.shrink_height = args.shrink_height or false args.background = args.background or "#000000" - args.screen = args.screen or screen.primary - args.x = args.x or nil - args.y = args.y or nil - args.placement = args.placement or (args.x == nil and args.y == nil) and awful.placement.centered or nil args.shape = args.shape or nil args.prompt_height = args.prompt_height or dpi(100) @@ -400,7 +648,13 @@ local function new(args) args.app_height = args.app_height or dpi(100) args.app_shape = args.app_shape or nil args.app_normal_color = args.app_normal_color or beautiful.bg_normal or "#000000" + args.app_normal_hover_color = args.app_normal_hover_color or (color.is_dark(args.app_normal_color) or color.is_opaque(args.app_normal_color)) and + color.rgba_to_hex(color.multiply(color.hex_to_rgba(args.app_normal_color), 2.5)) or + color.rgba_to_hex(color.multiply(color.hex_to_rgba(args.app_normal_color), 0.5)) args.app_selected_color = args.app_selected_color or beautiful.fg_normal or "#FFFFFF" + args.app_selected_hover_color = args.app_selected_hover_color or (color.is_dark(args.app_normal_color) or color.is_opaque(args.app_normal_color)) and + color.rgba_to_hex(color.multiply(color.hex_to_rgba(args.app_selected_color), 2.5)) or + color.rgba_to_hex(color.multiply(color.hex_to_rgba(args.app_selected_color), 0.5)) args.app_content_valign = args.app_content_valign or "center" args.app_content_spacing = args.app_content_spacing or dpi(10) args.app_show_icon = args.app_show_icon == nil and true or args.app_show_icon @@ -415,12 +669,13 @@ local function new(args) local ret = gobject({}) ret._private = {} + ret._private.text = "" gtable.crush(ret, app_launcher) gtable.crush(ret, args) -- Determines the grid width - local grid_width = ret.shirnk_width == false + local grid_width = ret.shirnk_width == false and dpi((ret.app_width * ret.apps_per_column) + ((ret.apps_per_column - 1) * ret.apps_spacing)) or nil local grid_height = ret.shrink_height == false @@ -436,7 +691,19 @@ local function new(args) bg = ret.prompt_color, fg = ret.prompt_text_color, bg_cursor = ret.prompt_cursor_color, + hooks = + { + -- Disable historyu scrolling with arrow keys + -- TODO: implement this as other keybind? tab? + {{}, "Up", function(command) return true, false end}, + {{}, "Down", function(command) return true, false end}, + {{}, "Return", function(command) return true, false end}, + }, changed_callback = function(text) + if text == ret._private.text then + return + end + if ret._private.search_timer ~= nil and ret._private.search_timer.started then ret._private.search_timer:stop() end @@ -449,17 +716,30 @@ local function new(args) search(ret, text) end } + + ret._private.text = text end, keypressed_callback = function(mod, key, cmd) + if key == "Escape" then + ret:hide() + end if key == "Return" then - if ret._private.grid.children[ret._private.current_index] ~= nil then - ret._private.grid.children[ret._private.current_index].spawn() + if ret._private.active_widget ~= nil then + ret._private.active_widget.spawn() end end - print(key) - end, - done_callback = function() - ret:hide() + if key == "Up" then + scroll_up(ret) + end + if key == "Down" then + scroll_down(ret) + end + if key == "Left" then + scroll_left(ret) + end + if key == "Right" then + scroll_right(ret) + end end } ret._private.grid = wibox.widget @@ -530,55 +810,50 @@ local function new(args) -- Private variables to be used to be used by the scrolling and searching functions ret._private.all_entries = {} ret._private.matched_entries = {} - ret._private.apps_per_page = ret.apps_per_column * ret.apps_per_row - ret._private.max_apps_per_page = ret._private.apps_per_page + ret._private.max_apps_per_page = ret.apps_per_column * ret.apps_per_row + ret._private.apps_per_page = ret._private.max_apps_per_page ret._private.pages_count = 0 - ret._private.current_index = 1 ret._private.current_page = 1 local app_info = Gio.AppInfo local apps = app_info.get_all() if ret.sort_alphabetically then - table.sort(apps, function(a, b) return app_info.get_name(a):lower() < app_info.get_name(b):lower() end) - end + table.sort(apps, function(a, b) return app_info.get_name(a):lower() < app_info.get_name(b):lower() end) + end + + local icon_theme = require(tostring(path):match(".*bling") .. ".helpers.icon_theme")(ret.icon_theme, ret.icon_size) for _, app in ipairs(apps) do if app.should_show(app) then - -- Check if this app should be skipped, depanding on the skip_names / skip_commands table local name = app_info.get_name(app) local commandline = app_info.get_commandline(app) + local executable = app_info.get_executable(app) local icon = icon_theme:get_gicon_path(app_info.get_icon(app)) + -- Check if this app should be skipped, depanding on the skip_names / skip_commands table if not has_value(ret.skip_names, name) and not has_value(ret.skip_commands, commandline) then -- Check if this app should be skipped becuase it's iconless depanding on skip_empty_icons - if icon ~= "" or ret.skip_empty_icons == false then - if icon == "" then - if ret.default_app_icon_name ~= nil then - icon = icon_theme:get_icon_path("app") - elseif ret.default_app_icon_path ~= nil then - icon = ret.default_app_icon_path - end - end - - -- Insert a table containing the name, command and icon of the app into the all_entries table - table.insert(ret._private.all_entries, { name = name, cmdline = commandline, icon = icon }) - - -- Only add the app widgets that are part of the first page - if #ret._private.all_entries <= ret._private.apps_per_page then - ret._private.grid:add(create_app_widget(ret, name, commandline, icon, #ret._private.all_entries)) + if icon ~= "" or ret.skip_empty_icons == false then + if icon == "" then + if ret.default_app_icon_name ~= nil then + icon = icon_theme:get_icon_path(ret.default_app_icon_name) + elseif ret.default_app_icon_path ~= nil then + icon = ret.default_app_icon_path + else + icon = icon_theme:choose_icon({ "application-all", "application", "application-default-icon", "app" }) end end + + local desktop_app_info = Gio.DesktopAppInfo.new(app_info.get_id(app)) + local terminal = Gio.DesktopAppInfo.get_string(desktop_app_info, "Terminal") == "true" and true or false + table.insert(ret._private.all_entries, { name = name, commandline = commandline, executable = executable, terminal = terminal, icon = icon }) + end end - - -- Matched entries contains all the apps initially - ret._private.matched_entries = ret._private.all_entries - ret._private.pages_count = math.ceil(#ret._private.all_entries / ret._private.apps_per_page) - - -- Mark the first app on startup - mark_app(ret, 1) end end + init(ret) + if ret.rubato and ret.rubato.x then ret.rubato.x:subscribe(function(pos) ret._private.widget.x = pos @@ -590,6 +865,32 @@ local function new(args) end) end + if ret.hide_on_clicked_outside then + awful.mouse.append_client_mousebinding( + awful.button({ }, 1, function (c) + ret:hide() + end) + ) + + awful.mouse.append_global_mousebinding( + awful.button({ }, 1, function (c) + ret:hide() + end) + ) + + awful.mouse.append_client_mousebinding( + awful.button({ }, 3, function (c) + ret:hide() + end) + ) + + awful.mouse.append_global_mousebinding( + awful.button({ }, 3, function (c) + ret:hide() + end) + ) + end + return ret end