diff --git a/lib/wibox/container/border.lua b/lib/wibox/container/border.lua new file mode 100644 index 000000000..b9ee6b5fe --- /dev/null +++ b/lib/wibox/container/border.lua @@ -0,0 +1,1050 @@ +--------------------------------------------------------------------------- +-- Place widgets or images on the sides, corner and back of another widget. +-- +-- This widget main use case is to surround another widget with an uniform +-- border. It can also be used to create soft shadows. It support CSS style +-- image slicing or widgets within the border. The `awful.layout.align` layout +-- an also be used to achieve border, but make it very hard to make them +-- symmetric on both axis. +-- +-- Note that because of legacy reasons, `wibox.container.background` also has +-- good support for borders. If you only need simple shaped strokes, the, +-- the `background` container is a much better choice. This module is better +-- suited for background images and border widgets. +-- +-- Advanced usage +-- ============== +-- +-- In this first example, 2 `wibox.container.borders` are combined in a +-- `wibox.layout.stack`. The bottom one is the "real" border and the top +-- layer is used to place some widgets. This is useful to built titlebars. +-- +-- @DOC_wibox_container_border_dual1_EXAMPLE@ +-- +-- This example demonstrates how to use this module to create a client +-- border with a top titlebar and borders. It does so by embedding a +-- `wibox.container.border` into another `wibox.container.border. The outer +-- container acts as the border around the central area while the inner one +-- spans the top area and contains the titlebar itself. The outer border +-- widgets area can be used to implement border resize. +-- +-- @DOC_wibox_container_border_titlebar1_EXAMPLE@ +-- +-- This container is like every other widgets. It is possible to override the +-- Cairo drawing functions. This is very useful here to create custom borders +-- using cairo while still re-using the internal geometry handling code. +-- +-- @DOC_wibox_container_border_custom_draw1_EXAMPLE@ +-- +--@DOC_wibox_container_defaults_border_EXAMPLE@ +-- +-- @author Emmanuel Lepage-Vallee +-- @copyright 2021 Emmanuel Lepage-Vallee +-- @containermod wibox.container.border +-- @supermodule wibox.widget.base +local gtable = require("gears.table") +local imagebox = require("wibox.widget.imagebox") +local base = require("wibox.widget.base") +local gsurface = require("gears.surface") +local cairo = require("lgi").cairo + +local components = { + "top_left", "top", "top_right", "right", "bottom_right", "bottom", + "bottom_left", "left", "fill" +} + +local slice_modes = { + top_left = "corners", + top = "sides", + top_right = "corners", + right = "sides", + bottom_right = "corners", + bottom = "sides", + bottom_left = "corners", + left = "sides", + fill = "filling" +} + +local fit_types = { "corners", "sides", "filling" } + +local module = {} + +local function imagebox_fit(self, _, w, h) + return math.min(w, self.source_width or 0), math.min(h, self.source_height or 0) +end + +local function fit_common(widget, ctx, max_w, max_h) + if not widget then return 0, 0 end + + local w, h = widget:fit(ctx, max_w or math.huge, max_h or math.huge) + + return w, h +end + +local function uses_slice(self) + return (self._private.border_widgets or self._private.border_image_widgets) == nil +end + +local function get_widget(self, ctx, component) + local wids, imgs = self._private.border_widgets or {}, self._private.border_image_widgets or {} + local slices = self._private.slice_cache and self._private.slice_cache[ctx.dpi] or {} + + return wids [component] + or imgs [component] + or slices[component] +end + +local function get_all_widgets(self, partial, ctx) + for _, position in ipairs(components) do + partial[position] = partial[position] or get_widget(self, ctx, position) + end + + return partial +end + +--- Common code to load the `border_image`. +local function setup_origin_common(self, ctx) + if self._private.original_md then return self._private.original_md end + + local origin_w, origin_h, origin + + local img = self._private.border_image + + -- Try SVG first. + local style = imagebox._get_stylesheet(self, self._private.border_image_stylesheet) + local handle = type(img) == "string" and imagebox._load_rsvg_handle(img, style) or nil + + if handle then + if style then + handle:set_stylesheet(style) + end + handle:set_dpi(self.border_image_dpi or ctx.dpi) + local dim = handle:get_dimensions() + + if dim.width > 0 and dim.height > 0 then + origin_w, origin_h = dim.width, dim.height + + origin = cairo.ImageSurface(cairo.Format.ARGB32, origin_w, origin_h) + local cr = cairo.Context(origin) + handle:render_cairo(cr) + end + end + + if not origin then + origin = gsurface(self._private.border_image) + + local err1, _origin_w = pcall(function() return origin:get_width() end) + local err2, _origin_h = pcall(function() return origin:get_height() end) + + if err1 and err2 then + origin_w, origin_h = _origin_w, _origin_h + end + end + + return { + width = origin_w, + height = origin_h, + surface = origin, + handle = handle + } +end + +local function compute_side_borders(self, ctx, max_w, max_h) + local override = self._private.borders or {} + local mer = self._private.border_merging or {} + + self._private.original_md = setup_origin_common(self, ctx) + + local m = { + left = override.left, + right = override.right, + top = override.top, + bottom = override.bottom, + } + + -- Limit the border to what the surface can provide. + if uses_slice(self) and (m.left or 0) + (m.right or 0) > self._private.original_md.width then + local max = math.floor((self._private.original_md.width-1)/2) + m.left = math.min(m.left, max) + m.right = math.min(m.right, max) + end + + -- Limit the border to what the surface can provide. + if uses_slice(self) and (m.top or 0) + (m.bottom or 0) > self._private.original_md.height then + local max = math.floor((self._private.original_md.height-1)/2) + m.top = math.min(m.top, max) + m.bottom = math.min(m.bottom, max) + end + + local wdgs = { + left = get_widget(self, ctx, "left" ) or false, + right = get_widget(self, ctx, "right" ) or false, + top = get_widget(self, ctx, "top" ) or false, + bottom = get_widget(self, ctx, "bottom") or false, + } + + -- Call `:fit()` on the sides. + local l_w, l_h = fit_common(wdgs.left , ctx, max_w, max_h) + local r_w, r_h = fit_common(wdgs.right , ctx, max_w, max_h) + local t_w, t_h = fit_common(wdgs.top , ctx, max_w, max_h) + local b_w, b_h = fit_common(wdgs.bottom, ctx, max_w, max_h) + + -- Either use the provided borders of the `:fit()` results. + m.left = m.left or l_w + m.right = m.right or r_w + m.top = m.top or t_h + m.bottom = m.bottom or b_h + + -- Allow the corner widgets to affect the border size. + if self._private.expand_corners then + wdgs.top_left = get_widget(self, ctx, "top_left" ) or false + wdgs.top_right = get_widget(self, ctx, "top_right" ) or false + wdgs.bottom_left = get_widget(self, ctx, "bottom_left" ) or false + wdgs.bottom_right = get_widget(self, ctx, "bottom_right") or false + + local tl_w, tl_h = fit_common(wdgs.top_left , ctx, max_w, max_h) + local tr_w, tr_h = fit_common(wdgs.top_right , ctx, max_w, max_h) + local bl_w, bl_h = fit_common(wdgs.bottom_left , ctx, max_w, max_h) + local br_w, br_h = fit_common(wdgs.bottom_right, ctx, max_w, max_h) + + m.left = math.max(m.left , tl_w, bl_w) + m.right = math.max(m.right , tr_w, br_w) + m.top = math.max(m.top , tl_h, tr_h) + m.bottom = math.max(m.bottom, bl_h, br_h) + end + + -- The sides should have matching size unless merging is enabled. + local minimums = { + left = mer.right and l_h or math.max(l_h, r_h), + right = mer.left and r_h or math.max(l_h, r_h), + top = mer.bottom and t_w or math.max(t_w, b_w), + bottom = mer.top and b_w or math.max(t_w, b_w), + } + + return m, wdgs, minimums +end + +local function compute_borders(self, ctx, width, height, fit_width, fit_height) + local m, widgets, mins = compute_side_borders(self, ctx, width, height) + local ret = {} + + -- Add some fallback values. + m.left = m.left or 0 + m.right = m.right or 0 + m.top = m.top or 0 + m.bottom = m.bottom or 0 + + -- Take the center widget size into account. + width = math.min(width , (fit_width or 9999) + m.left + m.right) + height = math.min(height, (fit_height or 9999) + m.top + m.right) + + -- Corners (w,h,x,y). + ret.top_left = {m.left , m.top , 0 , 0 } + ret.top_right = {m.right, m.top , width - m.right, 0 } + ret.bottom_left = {m.left , m.bottom, 0 , height - m.bottom} + ret.bottom_right = {m.right, m.bottom, width - m.right, height - m.bottom} + + -- Sides (w,h,x,y). + ret.left = {m.left , height - m.top - m.bottom, 0 , m.top } + ret.right = {m.right , height - m.top - m.bottom, width-m.right, m.top } + ret.top = {width - m.left - m.right, m.top , m.left , 0 } + ret.bottom = {width - m.left - m.right, m.bottom , m.left , height - m.bottom} + + -- Honor the border_widgets `:fit()` + ret.left [2] = math.max(mins.left , ret.left [2]) + ret.right [2] = math.max(mins.right , ret.right [2]) + ret.top [1] = math.max(mins.top , ret.top [1]) + ret.bottom[1] = math.max(mins.bottom, ret.bottom[1]) + + -- Center / fill + ret.fill = {width - m.left - m.right, height - m.top - m.bottom, m.left, m.top} + + -- Undo what we just did to merge the widgets. + if self._private.border_merging then + local to_del = {} + + for _, side in ipairs { "top", "bottom" } do + if self._private.border_merging[side] then + ret[side][1] = width + ret[side][3] = 0 + + to_del[side.."_left" ] = true + to_del[side.."_right"] = true + end + end + + for _, side in ipairs { "left", "right" } do + if self._private.border_merging[side] then + ret[side][2] = height + ret[side][4] = 0 + + to_del["top_" ..side] = true + to_del["bottom_"..side] = true + end + end + + for corner in pairs(to_del) do + ret[corner], widgets[corner] = nil, nil + end + end + + return ret, widgets +end + +local function init_imagebox(self, ib, border_type) + ib.horizontal_fit_policy = border_type + ib.vertical_fit_policy = border_type + ib.upscale = true + ib.valign = "center" + ib.halign = "center" + ib.resize = true + ib.scaling_quality = self._private.image_scaling_quality or "nearest" +end + +local function slice(self, ctx, borders) + if not self._private.border_image then + self._private.sliced_surface = nil + return + end + + if not self._private.slice then return end + + -- key: dpi, value: widgets + self._private.slice_cache = self._private.slice_cache or {} + + if self._private.slice_cache[ctx.dpi] then + return self._private.slice_cache[ctx.dpi] + end + + self._private.slice_cache[ctx.dpi] = {} + + local ibs, crs = {}, {} + + local md = setup_origin_common(self, ctx) + self._private.original_md = md + + if not md.surface then return end + + local scale_w, scale_h = 1, 1 + + -- This feature allows to provide very large texture while still sharing + -- the same assets between standard DPI and HiDPI computers. + if self._private.border_image_dpi and not md.handle then + local scale = ctx.dpi/self._private.border_image_dpi + scale_w, scale_h = scale, scale + end + + local ARGB32 = cairo.Format.ARGB32 + + ibs.top_left = cairo.ImageSurface(ARGB32, borders.left[1], borders.top[2]) + ibs.top = cairo.ImageSurface(ARGB32, md.width - borders.left[1] - borders.right[1], borders.top[2]) + ibs.top_right = cairo.ImageSurface(ARGB32, borders.right[1], borders.top[2]) + ibs.right = cairo.ImageSurface(ARGB32, borders.right[1], md.height - borders.top[2] - borders.bottom[2]) + ibs.bottom_right = cairo.ImageSurface(ARGB32, borders.right[1], borders.bottom[2]) + ibs.bottom_left = cairo.ImageSurface(ARGB32, borders.left[1], borders.bottom[2]) + ibs.left = cairo.ImageSurface(ARGB32, borders.left[1], md.height - borders.top[2] - borders.bottom[2]) + ibs.bottom = cairo.ImageSurface( + ARGB32, + md.width - borders.left[1] - borders.right[1], + borders.bottom[2] + ) + ibs.fill = cairo.ImageSurface( + ARGB32, + md.width - borders.left[1] - borders.right[1], + md.height - borders.top[2] - borders.bottom[2] + ) + + for _, position in ipairs(components) do + crs[position] = cairo.Context(ibs[position]) + end + + if scale_w > 1 or scale_h > 1 then + md.width, md.height = math.ceil(md.width * scale_w), math.ceil(md.height * scale_h) + + local new_origin = cairo.ImageSurface(ARGB32, md.width, md.height) + local slice_cr = cairo.Context(new_origin) + + slice_cr:set_source_surface(md.surface) + slice_cr:scale(scale_w, scale_h) + slice_cr:paint() + + md.surface = new_origin + end + + -- Top + crs.top:translate(-borders.left[1], 0) + + -- Top right + crs.top_right:translate(-md.width + borders.right[1], 0) + + -- Right + crs.right:translate(-md.width + borders.right[1], - borders.right[4]) + + -- Bottom right + crs.bottom_right:translate(-md.width + borders.right[1], -md.height + borders.bottom[2]) + + -- Bottom + crs.bottom:translate(-borders.left[1], -md.height + borders.bottom[2]) + + -- Bottom left + crs.bottom_left:translate(0, -md.height + borders.bottom[2]) + + -- Left + crs.left:translate(0, - borders.top_left[2]) + + -- Center + crs.fill:translate(-borders.left[1], -borders.top_left[2]) + + for _, position in ipairs(components) do + crs[position]:set_source_surface(md.surface) + crs[position]:paint() + + -- Scaling was attempted, but there is still some corner cases like the + -- `fill` :fit()` causing a negative texture size. + local ib = imagebox(ibs[position]) + init_imagebox(self, ib, self._private[slice_modes[position].."_fit_policy"]) + self._private.slice_cache[ctx.dpi][position] = ib + end +end + +local function setup_background(self, ctx) + if not self._private.border_image then + self._private.sliced_surface = nil + return + end + + if self._private.background_widget then + return self._private.background_widget + end + + self._private.original_md = setup_origin_common(self, ctx) + + if not self._private.original_md.surface then return end + + local ib = imagebox(self._private.original_md.surface) + self._private.background_widget = ib + self._private.background_widget.resize = true + ib.horizontal_fit_policy = self._private.filling_fit_policy + ib.vertical_fit_policy = self._private.filling_fit_policy + + return self._private.background_widget +end + +local function children_layout(self, borders, width, height, wdg_w, wdg_h) + assert(wdg_w > 0 and wdg_h > 0) + + -- This need to be after the other because it has to be on top. + if self._private.widget then + local p = self._private.paddings or {} + if not self._private.honor_borders then + wdg_w, wdg_h = width - (p.right or 0) - (p.left or 0), height - (p.top or 0) - (p.bottom or 0) + wdg_w, wdg_h = math.max(0, wdg_w), math.max(0, wdg_h) + + return base.place_widget_at(self._private.widget, 0 + (p.left or 0), 0 + (p.top or 0), wdg_w, wdg_h) + else + wdg_w, wdg_h = wdg_w - (p.right or 0) - (p.left or 0), wdg_h - (p.top or 0) - (p.bottom or 0) + wdg_w, wdg_h = math.max(0, wdg_w), math.max(0, wdg_h) + + return base.place_widget_at( + self._private.widget, borders.left[1] + (p.left or 0), borders.top[2] + (p.top or 0), wdg_w, wdg_h + ) + end + end +end + +-- Delayed initialization of the border_images. +local function init_border_images(self) + self._private.pending_border_images = false + local value = self._private.border_images + + if not value then return end + + if type(value) ~= "table" then + local ibs = {} + + for _, t in ipairs(fit_types) do + local ib = imagebox(value) + rawset(ib, "fit", imagebox_fit) + init_imagebox(self, ib, self._private[t.."_fit_policy"]) + ibs[t] = ib + end + + self._private.border_image_widgets = {} + + for component, fit_type in pairs(slice_modes) do + self._private.border_image_widgets[component] = ibs[fit_type] + end + else + -- Salvage the old objects. + local old_widgets = self._private.border_image_widgets + + self._private.border_image_widgets = {} + + for component, img in pairs(value) do + local k, v = next(old_widgets) + + if v then + old_widgets[k] = nil + end + + local ib = v or imagebox() + rawset(ib, "fit", imagebox_fit) + local mode = slice_modes[component].."_fit_policy" + init_imagebox(self, ib, self._private[mode]) + ib.image = img + self._private.border_image_widgets[component] = ib + end + end +end + +function module:fit(ctx, width, height) + if self._private.pending_border_images then + init_border_images(self) + end + + local w, h, borders = 0, 0 + + if self._private.widget then + w, h = base.fit_widget(self, ctx, self._private.widget, width, height) + + -- Add padding. + if w > 0 and h > 0 and self._private.paddings then + w = w + (self._private.paddings.left or 0) + (self._private.paddings.right or 0) + h = h + (self._private.paddings.top or 0) + (self._private.paddings.bottom or 0) + end + + assert(w >= 0 and h>=0) + + borders = compute_borders(self, ctx, width, height, w, h) + else + borders = compute_borders(self, ctx, width, height, width, height) + end + + -- Make sure the border `fit` are taken into account. + w = math.max(borders.top [1], w) + h = math.max(borders.left[2], h) + + -- Add the borders around the central widget. + w, h = w + borders.left[1] + borders.right[1], h + borders.top[2] + borders.bottom[2] + + assert(w >= 0 and h >= 0) + + return math.min(width, w), math.min(height, h) +end + +function module:layout(ctx, width, height) + local positioned = {} + local borders, widgets = compute_borders(self, ctx, width, height) + + local wdg_w = width - borders.left[1] - borders.right[1] + local wdg_h = height - borders.top[2] - borders.bottom[2] + + if self._private.ontop == false and self._private.widget then + table.insert(positioned, children_layout(self, borders, width, height, wdg_w, wdg_h)) + end + + if self._private.slice then + slice(self, ctx, borders) + get_all_widgets(self, widgets, ctx) + + -- Use `ipairs` rather than `pairs` on the widget to keep the order stable. + for _, position in ipairs(components) do + local geo = borders[position] + --TODO use unpack + if geo and widgets[position] and not (position == "fill" and not self._private.fill) then + local place = base.place_widget_at(widgets[position], geo[3], geo[4], geo[1], geo[2]) + table.insert(positioned, place) + end + end + else + local bg = setup_background(self, ctx) + + if bg then + table.insert( + positioned, + base.place_widget_at(bg, 0, 0, width, height) + ) + end + end + + if self._private.ontop ~= false and self._private.widget then + table.insert(positioned, children_layout(self, borders, width, height, wdg_w, wdg_h)) + end + + return positioned +end + +--- The widget to display inside of the border. +-- +-- @property widget +-- @tparam widget widget + +module.set_widget = base.set_widget_common + +function module:get_widget() + return self._private.widget +end + +function module:get_children() + return {self._private.widget} +end + +function module:set_children(children) + self:set_widget(children[1]) +end + +--- Reset this layout. The widget will be removed and the rotation reset. +-- @method reset +-- @interface container +function module:reset() + self:set_widget(nil) +end + +--- A single border image for the border. +-- +-- When using this property, the `borders` also **needs** to be specified. +-- +-- @property border_image +-- @tparam string|gears.surface border_image +-- @see borders +-- @see border_images + +function module:set_border_image(value) + if self._private.border_image == value then return end + + self._private.slice_cache = nil + self._private.original_md = nil + self._private.background_widget = nil + self._private.border_image = value + self:emit_signal("widget::redraw_needed") +end + +--- Sice the `border_image` to fit the content. +-- +-- This applies a CSS-like modifier to the image. It will use the +-- values of the `borders` property to split the original image into +-- multiple smaller images and use the value of `filling_fit_policy`, +-- `sides_fit_policy` and `corners_fit_policy` to resize the content. +-- +-- @DOC_wibox_container_border_slice1_EXAMPLE@ +-- +-- @property slice +-- @tparam[opt=true] boolean slice +-- @propemits true false + +function module:set_slice(value) + if self._private.slice == value then return end + + self._private.slice = value + self:emit_signal("property::slice", value) + self:emit_signal("widget::layout_changed") + self:emit_signal("widget::redraw_needed") +end + +--FIXME DPI support remains undocumented because it has unfixed corner +-- cases. +-- Set the DPI of the slice surface. +-- +-- If this is set, the slicer will enable HiDPI mode. It will +-- resize the surface using the ratio created by the effective +-- DPI and the source DPI. +-- +-- If the `border_image` is a `.svg` file, this will override the +-- SVG default DPI. +-- +-- @DOC_wibox_container_border_dpi1_EXAMPLE@ +-- +-- @property border_image_dpi +-- @tparam[opt=nil] number border_image_dpi + +function module:set_border_image_dpi(value) + self._private.original_md = nil + self._private.border_image_dpi = value + self:emit_signal("widget::redraw_needed") +end + +--- Set a stylesheet for the slice surface. +-- +-- This only affect `.svg` based assets. It does nothing for `.png`/`.jpg` +-- and already loaded `gears.surfaces` based assets. +-- +-- @DOC_wibox_container_border_stylesheet1_EXAMPLE@ +-- +-- @property border_image_stylesheet +-- @tparam string border_image_stylesheet CSS data or file path. +-- @see wibox.widget.imagebox.stylesheet + +function module:set_border_image_stylesheet(value) + self._private.original_md = nil + self._private.border_image_stylesheet = value + self:emit_signal("widget::redraw_needed") +end + +--- How the border_image(s) are scaled. +-- +-- Note that `nearest` and `best` are the most sensible choices. Other +-- modes may introduce anti-aliasing artifacts at the junction of the various images. +-- +--
Value | +--Description | +--
---|---|
fast | A high-performance filter |
good | A reasonable-performance filter |
best | The highest-quality available |
nearest | Nearest-neighbor filtering (blocky) |
bilinear | Linear interpolation in two dimensions |