From 031c773658f4852efae6495d79368b4e72e90660 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Sun, 21 Feb 2016 02:31:31 -0500 Subject: [PATCH] popup: Add a popup module to awful This module is half way between the tooltip and the raw wibox. It supports the following features: * Auto resize to its widget size * Support parent objects and placement Fix #1683 --- lib/awful/init.lua | 1 + lib/awful/popup.lua | 467 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 lib/awful/popup.lua diff --git a/lib/awful/init.lua b/lib/awful/init.lua index ed2fb535..2a416879 100644 --- a/lib/awful/init.lua +++ b/lib/awful/init.lua @@ -59,6 +59,7 @@ return ewmh = require("awful.ewmh"); titlebar = require("awful.titlebar"); rules = require("awful.rules"); + popup = require("awful.popup"); spawn = spawn; } diff --git a/lib/awful/popup.lua b/lib/awful/popup.lua new file mode 100644 index 00000000..7dc4e59f --- /dev/null +++ b/lib/awful/popup.lua @@ -0,0 +1,467 @@ +--------------------------------------------------------------------------- +--- An auto-resized, free floating or modal wibox built around a widget. +-- +-- This type of widget box (wibox) is auto closed when being clicked on and is +-- automatically resized to the size of its main widget. +-- +-- Note that the widget itself should have a finite size. If something like a +-- `wibox.layout.flex` is used, then the size would be unlimited and an error +-- will be printed. The `wibox.layout.fixed`, `wibox.container.constraint`, +-- `forced_width` and `forced_height` are recommended. +-- +--@DOC_awful_popup_simple_EXAMPLE@ +-- +-- Here is an example of how to create an alt-tab like dialog by leveraging +-- the `awful.widget.tasklist`. +-- +--@DOC_awful_popup_alttab_EXAMPLE@ +-- +-- @author Emmanuel Lepage Vallee +-- @copyright 2016 Emmanuel Lepage Vallee +-- @classmod awful.popup +--------------------------------------------------------------------------- +local wibox = require( "wibox" ) +local util = require( "awful.util" ) +local placement = require( "awful.placement" ) +local xresources= require("beautiful.xresources") +local timer = require( "gears.timer" ) +local capi = {mouse = mouse} + + +local module = {} + +local main_widget = {} + +-- Get the optimal direction for the wibox +-- This (try to) avoid going offscreen +local function set_position(self) + -- First, if there is size to be applied, do it + if self._private.next_width then + self.width = self._private.next_width + self._private.next_width = nil + end + + if self._private.next_height then + self.height = self._private.next_height + self._private.next_height = nil + end + + local pf = self._private.placement + + if pf == false then return end + + if pf then + pf(self, {bounding_rect = self.screen.geometry}) + return + end + + local geo = self._private.widget_geo + + if not geo then return end + + local _, pos_name, anchor_name = placement.next_to(self, { + preferred_positions = self._private.preferred_directions, + geometry = geo, + preferred_anchors = self._private.preferred_anchors, + offset = self._private.offset or { x = 0, y = 0}, + }) + + if pos_name ~= self._private.current_position then + local old = self._private.current_position + self._private.current_position = pos_name + self:emit_signal("property::current_position", pos_name, old) + end + + if anchor_name ~= self._private.current_anchor then + local old = self._private.current_anchor + self._private.current_anchor = anchor_name + self:emit_signal("property::current_anchor", anchor_name, old) + end +end + +-- Set the wibox size taking into consideration the limits +local function apply_size(self, width, height, set_pos) + local prev_geo = self:geometry() + + width = math.max(self._private.minimum_width or 1, math.ceil(width or 1)) + height = math.max(self._private.minimum_height or 1, math.ceil(height or 1)) + + if self._private.maximum_width then + width = math.min(self._private.maximum_width, width) + end + + if self._private.maximum_height then + height = math.min(self._private.maximum_height, height) + end + + self._private.next_width, self._private.next_height = width, height + + if set_pos or width ~= prev_geo.width or height ~= prev_geo.height then + set_position(self) + end +end + +-- Layout this widget +function main_widget:layout(context, width, height) + if self._private.widget then + local w, h = wibox.widget.base.fit_widget( + self, + context, + self._private.widget, + self._wb._private.maximum_width or 9999, + self._wb._private.maximum_height or 9999 + ) + timer.delayed_call(function() + apply_size(self._wb, w, h, true) + end) + return { wibox.widget.base.place_widget_at(self._private.widget, 0, 0, width, height) } + end +end + +-- Set the widget that is drawn on top of the background +function main_widget:set_widget(widget) + if widget then + wibox.widget.base.check_widget(widget) + end + self._private.widget = widget + self:emit_signal("widget::layout_changed") +end + +function main_widget:get_widget() + return self._private.widget +end + +function main_widget:get_children_by_id(name) + return self._wb:get_children_by_id(name) +end + +local popup = {} + +--- Set the preferred popup position relative to its parent. +-- +-- This allows, for example, to have a submenu that goes on the right of the +-- parent menu. If there is no space on the right, it tries on the left and so +-- on. +-- +-- Valid directions are: +-- +-- * left +-- * right +-- * top +-- * bottom +-- +-- The basic use case for this method is to give it a parent wibox: +-- +-- @DOC_awful_popup_position1_EXAMPLE@ +-- +-- As demonstrated by this second example, it is also possible to use a widget +-- as a parent object: +-- +-- @DOC_awful_popup_position2_EXAMPLE@ +-- +-- @property preferred_positions +-- @tparam table|string preferred_positions A position name or an ordered +-- table of positions +-- @see awful.placement.next_to +-- @see awful.popup.preferred_anchors + +function popup:set_preferred_positions(pref_pos) + self._private.preferred_directions = pref_pos + set_position(self) +end + +--- Set the preferred popup anchors relative to the parent. +-- +-- The possible values are: +-- +-- * front +-- * middle +-- * back +-- +-- For details information, see the `awful.placement.next_to` documentation. +-- +-- In this example, it is possible to see the effect of having a fallback +-- preferred anchors when the popup would otherwise not fit: +-- +-- @DOC_awful_popup_anchors_EXAMPLE@ +-- +-- @property preferred_anchors +-- @tparam table|string preferred_anchors Either a single anchor name or a table +-- ordered by priority. +-- @see awful.placement.next_to +-- @see awful.popup.preferred_positions + +function popup:set_preferred_anchors(pref_anchors) + self._private.preferred_anchors = pref_anchors + set_position(self) +end + +--- The current position relative to the parent object. +-- +-- If there is a parent object (widget, wibox, wibar, client or the mouse), then +-- this property returns the current position. This is determined using +-- `preferred_positions`. It is usually the preferred position, but when there +-- isn't enough space, it can also be one of the fallback. +-- +-- @property current_position +-- @tparam string current_position Either "left", "right", "top" or "bottom" + +function popup:get_current_position() + return self._private.current_position +end + +--- Get the current anchor relative to the parent object. +-- +-- If there is a parent object (widget, wibox, wibar, client or the mouse), then +-- this property returns the current anchor. The anchor is the "side" of the +-- parent object on which the popup is based on. It will "grow" in the +-- opposite direction from the anchor. +-- +-- @property current_anchor +-- @tparam string current_anchor Either "front", "middle", "back" + +function popup:get_current_anchor() + return self._private.current_anchor +end + +--- Move the wibox to a position relative to `geo`. +-- This will try to avoid overlapping the source wibox and auto-detect the right +-- direction to avoid going off-screen. +-- +-- @param[opt=mouse] obj An object such as a wibox, client or a table entry +-- returned by `wibox:find_widgets()`. +-- @see awful.placement.next_to +-- @see awful.popup.preferred_positions +-- @see awful.popup.preferred_anchors +-- @treturn table The new geometry +function popup:move_next_to(obj) + if self._private.is_relative == false then return end + + self._private.widget_geo = obj + + obj = obj or capi.mouse + + if obj._apply_size_now then + obj:_apply_size_now(false) + end + + self.visible = true + + self:_apply_size_now(true) + + self._private.widget_geo = nil +end + +--- Bind the popup to a widget button press. +-- +-- @tparam widget widget The widget +-- @tparam[opt=1] number button The button index +function popup:bind_to_widget(widget, button) + if not self._private.button_for_widget then + self._private.button_for_widget = {} + end + + self._private.button_for_widget[widget] = button or 1 + widget:connect_signal("button::press", self._private.show_fct) +end + +--- Unbind the popup from a widget button. +-- @tparam widget widget The widget +function popup:unbind_to_widget(widget) + widget:disconnect_signal("button::press", self._private.show_fct) +end + +--- Hide the popup when right clicked. +-- +-- @property hide_on_right_click +-- @tparam[opt=false] boolean hide_on_right_click + +function popup:set_hide_on_right_click(value) + self[value and "connect_signal" or "disconnect_signal"]( + self, "button::press", self._private.hide_fct + ) +end + +--- The popup minimum width. +-- @property minimum_width +-- @tparam[opt=1] number The minimum width + +--- The popup minimum height. +-- @property minimum_height +-- @tparam[opt=1] number The minimum height + +--- The popup minimum width. +-- @property maxmimum_width +-- @tparam[opt=1] number The maxmimum width + +--- The popup maximum height. +-- @property maximum_height +-- @tparam[opt=1] number The maximum height + +for _, orientation in ipairs {"_width", "_height"} do + for _, limit in ipairs {"minimum", "maximum"} do + popup["set_"..limit..orientation] = function(self, value) + self._private[limit..orientation] = value + self._private.container:emit_signal("widget::layout_changed") + end + end +end + +--- The distance between the popup and its parent (if any). +-- +-- Here is an example of 5 popups stacked one below the other with an y axis +-- offset (spacing). +-- +-- @DOC_awful_popup_position3_EXAMPLE@ +-- @property offset +-- @tparam table|number offset An integer value or a `{x=, y=}` table. +-- @tparam[opt=offset] number offset.x The horizontal distance. +-- @tparam[opt=offset] number offset.y The vertical distance. + +function popup:set_offset(offset) + + if type(offset) == "number" then + offset = { + x = offset or 0, + y = offset or 0, + } + end + + local oldoff = self._private.offset or {x=0, y=0} + + if oldoff.x == offset.x and oldoff.y == offset.y then return end + + offset.x, offset.y = offset.x or oldoff.x or 0, offset.y or oldoff.y or 0 + + self._private.offset = offset + + self:_apply_size_now(false) +end + +--- Set the placement function. +-- @tparam[opt=next_to] function|string|boolean The placement function or name +-- (or false to disable placement) +-- @property placement +-- @param function + +function popup:set_placement(f) + if type(f) == "string" then + f = placement[f] + end + + self._private.placement = f + self:_apply_size_now(false) +end + +-- For the tests and the race condition when 2 popups are placed next to each +-- other. +function popup:_apply_size_now(skip_set) + if not self.widget then return end + + local w, h = wibox.widget.base.fit_widget( + self.widget, + {dpi= self.screen.dpi or xresources.get_dpi()}, + self.widget, + self._private.maximum_width or 9999, + self._private.maximum_height or 9999 + ) + + -- It is important to do it for the obscure reason that calling `w:geometry()` + -- is actually mutating the state due to quantum determinism thanks to XCB + -- async nature... It is only true the very first time `w:geometry()` is + -- called + self.width = math.max(1, math.ceil(w or 1)) + self.height = math.max(1, math.ceil(h or 1)) + + apply_size(self, w, h, skip_set ~= false) +end + +function popup:set_widget(wid) + self._private.widget = wid + self._private.container:set_widget(wid) +end + +function popup:get_widget() + return self._private.widget +end + +--- Create a new popup build around a passed in widget. +-- @tparam[opt=nil] table args +--@DOC_wibox_constructor_COMMON@ +-- @tparam function args.placement The `awful.placement` function +-- @tparam string|table args.preferred_positions +-- @tparam string|table args.preferred_anchors +-- @tparam table|number args.offset The X and Y offset compared to the parent object +-- @tparam boolean args.hide_on_right_click Whether or not to hide the popup on +-- right clicks. +-- @function awful.popup +local function create_popup(_, args) + assert(args) + + -- Temporarily remove the widget + local original_widget = args.widget + args.widget = nil + + assert(original_widget, "The `awful.popup` requires a `widget` constructor argument") + + local child_widget = wibox.widget.base.make_widget_from_value(original_widget) + + local ii = wibox.widget.base.make_widget(child_widget, "awful.popup", { + enable_properties = true + }) + + util.table.crush(ii, main_widget, true) + + -- Create a wibox to host the widget + local w = wibox(args or {}) + + rawset(w, "_private", { + container = ii, + preferred_directions = { "right", "left", "top", "bottom" }, + preferred_anchors = { "back", "front", "middle" }, + widget = child_widget + }) + + util.table.crush(w, popup) + + ii:set_widget(child_widget) + + -- Create the signal handlers + function w._private.show_fct(wdg, _, _, button, _, geo) + if button == w._private.button_for_widget[wdg] then + w:move_next_to(geo) + end + end + function w._private.hide_fct() + w.visible = false + end + + -- Restore + args.widget = original_widget + + -- Cross-link the wibox and widget + ii._wb = w + wibox.set_widget(w, ii) + + --WARNING The order is important + -- First, apply the limits to avoid a flicker with large width or height + -- when set_position is called before the limits + for _,v in ipairs{"minimum_width", "minimum_height", "maximum_height", + "maximum_width", "offset", "placement","preferred_positions", + "preferred_anchors", "hide_on_right_click"} do + if args[v] ~= nil then + w["set_"..v](w, args[v]) + end + end + + -- Default to visible + if args.visible ~= false then + w.visible = true + end + + return w +end + +--@DOC_wibox_COMMON@ + +return setmetatable(module, {__call = create_popup})