local text_input = require "ui.fresnel.text_input" local awful = require "awful" local qdebug = require "quarrel.debug" local cfg = require "misc.cfg" local gshape = require "gears.shape" local gtable = require "gears.table" local qanim = require "quarrel.animation" local qcolor = require "quarrel.color" local qnative = require "quarrel.native" local qtable = require "quarrel.table" local qui = require "quarrel.ui" local qvars = require "quarrel.vars" local rubato = require "lib.rubato" local wibox = require "wibox" local btn = awful.button.names local MAX_VISIBLE_ENTRIES = 10 local DEFAULT_SCROLL_AMOUNT = 0 local DEFAULT_SELECTED_IDX = 1 ---@class (exact) Fresnel ---@field private _toggled boolean Whether fresnel is open ---@field private _entries (Entry|string)[] ---@field private _scroll_amount integer How many entries down are we ---@field private _prev_scroll_amount integer The _scroll_amount of the previous cycle ---@field private _visible_entries Entry[] The currently visible entries ---@field private _selected_idx integer The index of the currently selected entry. Is in the range 1.. ---@field private _prev_selected_idx integer The _selected_idx of the previous cycle ---@field private _active_lenses integer The number of lense titles in the _entries table ---@field private _lense_header_positions integer[] The positions of all the lense titles ---@field private _prev_text string ---@field private _first_query boolean ---@field private _make_header fun(string): wibox.container.background ---@field private _w_popup wibox.widget.base ---@field private _w_prompt wibox.widget.base ---@field private _w_status wibox.widget.base ---@field private _l_entries wibox.widget.base ---@field private _t_opacity table ---@field private _t_height table local fresnel = { _toggled = false, _entries = {}, _scroll_amount = DEFAULT_SCROLL_AMOUNT, _prev_scroll_amount = DEFAULT_SCROLL_AMOUNT, _visible_entries = {}, _selected_idx = DEFAULT_SELECTED_IDX, _prev_selected_idx = DEFAULT_SELECTED_IDX, _lense_header_positions = {} } ---@private ---@param lense_name string function fresnel._make_header(lense_name) return wibox.widget { widget = wibox.container.background, fg = qcolor.palette.fg.low, { widget = wibox.container.margin, margins = qui.PADDING, { { widget = wibox.container.place, { widget = wibox.container.background, bg = qcolor.palette.border(), forced_height = qui.BORDER_WIDTH, forced_width = qui.BIG_PADDING * 2, }, }, { widget = wibox.container.constraint, strategy = "max", height = qui.CHAR_HEIGHT, { widget = wibox.widget.textbox, text = lense_name, }, }, { widget = wibox.container.place, fill_horizontal = true, content_fill_horizontal = true, { widget = wibox.container.background, bg = qcolor.palette.border(), forced_height = qui.BORDER_WIDTH, }, }, layout = wibox.layout.fixed.horizontal, spacing = qui.BIG_PADDING, }, }, } end ---@private ---@param entry Entry function fresnel:_make_entry(entry) ---@class FresnelEntry : wibox.widget.base ---@field _selected boolean local entry_widget = wibox.widget { widget = wibox.container.background, shape = qui.shape, { widget = wibox.container.margin, margins = qui.PADDING, { widget = wibox.container.constraint, strategy = "max", height = qui.CHAR_HEIGHT, { widget = wibox.widget.textbox, text = entry.message, }, }, }, buttons = { awful.button { modifiers = {}, button = btn.LEFT, on_press = function() fresnel:_exec_entry(entry) end, }, }, _selected = false, } entry_widget:connect_signal("mouse::enter", function() if entry_widget._selected == true then return end entry_widget.bg = qcolor.palette.bg.high end) entry_widget:connect_signal("mouse::leave", function() if entry_widget._selected == true then return end entry_widget.bg = qcolor.palette.bg() end) return entry_widget end ---@private ---@param entry integer|Entry function fresnel:_exec_entry(entry) if type(entry) == "number" then entry = self._visible_entries[entry] end local exec = entry.exec if type(exec) ~= "userdata" and type(exec) ~= "nil" then if exec[2] then awful.spawn(cfg.terminal .. " -e /bin/sh -c " .. exec[1] .. " 1>/dev/null 2>&1") else awful.spawn.with_shell(exec[1] .. " 1>/dev/null 2>&1") end end end ---@private ---@param query string? function fresnel:_update(query) query = query or "" self._entries = {} self._lense_header_positions = {} self._active_lenses = 0 for _, lense in ipairs(qnative.lenses) do local entries = lense:query(query) if type(entries) == "table" then table.insert(self._entries, lense.name) table.insert(self._lense_header_positions, #self._entries) if #entries > 0 then -- Entry[] self._entries = gtable.join(self._entries, entries) elseif entries.message then -- Entry table.insert(self._entries, entries) end self._active_lenses = self._active_lenses + 1 end -- either empty Entry[] or nil, in which case we shouldn't display them end -- reset the scroll if type(self._entries[1]) == "string" then self._selected_idx = 2 -- to avoid selecting the header first else self._selected_idx = DEFAULT_SELECTED_IDX end self._scroll_amount = DEFAULT_SCROLL_AMOUNT fresnel:_render(true) self._first_query = false end ---@private ---@param update boolean function fresnel:_render(update) local visible_entries_end = math.min(self._scroll_amount + MAX_VISIBLE_ENTRIES, #self._entries) self._visible_entries = qtable.slice(self._entries, self._scroll_amount + 1, visible_entries_end) local layout = fresnel._l_entries if self._scroll_amount ~= self._prev_scroll_amount or update or self._first_query then layout:reset() for i, entry in ipairs(self._visible_entries) do if type(entry) == "string" then layout:add(self._make_header(entry)) else local entry_widget = self:_make_entry(entry) if self._selected_idx == i then entry_widget._selected = true entry_widget.bg = qcolor.palette.bg.high end layout:add(entry_widget) end end elseif self._selected_idx ~= self._prev_selected_idx then local entry_widget = layout.children[self._selected_idx] --[[@as FresnelEntry ]] entry_widget._selected = true entry_widget.bg = qcolor.palette.bg.high local prev_entry_widget = layout.children[self._prev_selected_idx] --[[@as FresnelEntry ]] prev_entry_widget._selected = false prev_entry_widget.bg = qcolor.palette.bg() end local headers_passed = 0 local current_position = self._scroll_amount + self._selected_idx for _, position in ipairs(self._lense_header_positions) do if current_position > position then headers_passed = headers_passed + 1 end end self._w_status.text = (self._scroll_amount + self._selected_idx - headers_passed) .. "/" .. (#self._entries - self._active_lenses) self._prev_scroll_amount = self._scroll_amount self._prev_selected_idx = self._selected_idx end ---@param up boolean Whether to scroll up or down function fresnel:scroll(up) local direction = up and -1 or 1 local offset = direction if type(self._entries[self._scroll_amount + self._selected_idx + offset]) == "string" then offset = offset + direction end local new_selected_idx = self._selected_idx + offset local new_position = self._scroll_amount + new_selected_idx local new_scroll_amount = self._scroll_amount + offset if new_position < 1 then if self._scroll_amount > 0 then self._scroll_amount = self._scroll_amount + direction self._selected_idx = self._selected_idx - direction self:_render(false) end return elseif new_position > #self._entries then return end if up and new_selected_idx <= 0 then self._scroll_amount = new_scroll_amount elseif not up and new_selected_idx > #self._visible_entries then self._scroll_amount = new_scroll_amount else self._selected_idx = new_selected_idx end self:_render(false) end fresnel._prev_text = "" fresnel._w_prompt = wibox.widget { widget = text_input, reset_on_unfocus = true, unfocus_keys = {}, } fresnel._w_prompt:connect_signal("unfocus", function () fresnel:_hide() end) fresnel._w_prompt:connect_signal("property::text", function(_, text) fresnel:_update(text) end) fresnel._w_prompt:connect_signal("key::press", function(_, _mods, key) -- Convert index array to hash table local mods = {} for _, v in ipairs(_mods) do mods[v] = true end if key == "Escape" or key == " " and mods.Mod4 then fresnel:hide() elseif key == "Return" then fresnel:_exec_entry(fresnel._selected_idx) fresnel:hide() elseif key == "k" and mods.Mod1 then fresnel:scroll(true) elseif key == "j" and mods.Mod1 then fresnel:scroll(false) end end) local max_height = qui.BIG_PADDING * 2 + (qui.BIG_PADDING * 2 + qui.CHAR_HEIGHT) * 10 local width = awful.screen.focused().geometry.width / 2 fresnel._l_entries = wibox.widget { spacing = qui.PADDING, layout = wibox.layout.fixed.vertical, } fresnel._w_status = wibox.widget { widget = wibox.widget.textbox, text = "0/0", } fresnel._w_popup = qui.popup { -- visible = false, ontop = true, placement = false, shape = function(cr, w) gshape.partially_rounded_rect(cr, w, 0, false, false, true, true, qui.BORDER_RADIUS) end, x = width / 2, minimum_width = width, maximum_width = width, -- maximum_height = max_height, widget = { qui.styled { widget = wibox.container.background, fg = qcolor.palette.fg.low, bg = qcolor.palette.bg.high, { widget = wibox.container.margin, margins = qui.BIG_PADDING, { { widget = wibox.widget.textbox, markup = [[>]], }, { widget = wibox.container.margin, margins = { left = qui.PADDING, right = qui.PADDING, }, { widget = wibox.container.constraint, strategy = "max", height = qui.CHAR_HEIGHT, { widget = wibox.container.background, fg = qcolor.palette.fg(), fresnel._w_prompt, }, }, }, fresnel._w_status, layout = wibox.layout.align.horizontal, }, }, }, { widget = wibox.container.margin, margins = { top = qui.PADDING, }, fresnel._l_entries, }, layout = wibox.layout.align.vertical, }, } function fresnel:show() self._prev_scroll_amount = DEFAULT_SCROLL_AMOUNT self._prev_selected_idx = DEFAULT_SELECTED_IDX self._first_query = true self._toggled = true self._t_opacity.target = 1 self._t_height:set(max_height) self:_update() self._w_prompt:focus() end ---@private function fresnel:_hide() self._toggled = false self._t_opacity.target = 0 self._t_height:set(0) for _, lense in ipairs(qnative.lenses) do lense:interrupt() end end function fresnel:hide() self._w_prompt:unfocus() self:_hide() end fresnel._t_height = qanim:new { duration = qvars.anim_duration, pos = 0, easing = qvars.easing, subscribed = function(pos) fresnel._w_popup.shape = function(cr, w) gshape.partially_rounded_rect(cr, w, pos, false, false, true, true, qui.BORDER_RADIUS) end end, } -- TODO: optimize the search algo to be more efficient and not require making fresnel invisible fresnel._t_opacity = rubato.timed { duration = qvars.anim_duration, pos = 0, subscribed = function(pos) fresnel._w_popup.opacity = pos if pos == 0 then fresnel._w_popup.visible = false else fresnel._w_popup.visible = true end end, } return fresnel