--------------------------------------------------------------------------- -- This container scrolls its inner widget inside of the available space. An -- example usage would be a text widget that displays information about the -- currently playing song without using too much space for long song titles. -- -- Please note that mouse events do not propagate to widgets inside of the -- scroll container. Also, if this widget is causing too high CPU usage, you can -- use @{set_fps} to make it update less often. -- @usage -- wibox.widget { -- layout = wibox.container.scroll.horizontal, -- max_size = 100, -- step_function = wibox.container.scroll.step_functions -- .waiting_nonlinear_back_and_forth, -- speed = 100, -- { -- widget = wibox.widget.textbox, -- text = "This is a " .. string.rep("very, ", 10) .. " very long text", -- }, -- } -- @author Uli Schlachter (based on ideas from Saleur Geoffrey) -- @copyright 2015 Uli Schlachter -- @containermod wibox.container.scroll -- @supermodule wibox.widget.base --------------------------------------------------------------------------- local cache = require("gears.cache") local timer = require("gears.timer") local hierarchy = require("wibox.hierarchy") local base = require("wibox.widget.base") local gtable = require("gears.table") local lgi = require("lgi") local GLib = lgi.GLib local scroll = {} local _need_scroll_redraw -- "Strip" a context so that we can use it for our own drawing local function cleanup_context(context) local skip = { wibox = true, drawable = true, client = true, position = true } local res = {} for k, v in pairs(context) do if not skip[k] then res[k] = v end end return res end -- Create a hierarchy (and some more stuff) for drawing the given widget. This -- allows "some stuff" to be re-used instead of re-created all the time. local hierarchy_cache = cache.new(function(context, widget, width, height) context = cleanup_context(context) local layouts = setmetatable({}, { __mode = "k" }) -- Create a widget hierarchy and update when needed local hier local function do_pending_updates(layout) layouts[layout] = true hier:update(context, widget, width, height, nil) end local function emit(signal) -- Make the scroll layouts redraw for w in pairs(layouts) do w:emit_signal(signal) end end local function redraw_callback() emit("widget::redraw_needed") end local function layout_callback() emit("widget::redraw_needed") emit("widget::layout_changed") end hier = hierarchy.new(context, widget, width, height, redraw_callback, layout_callback, nil) return hier, do_pending_updates, context end) --- Calculate all the information needed for scrolling. -- @param self The instance of the scrolling layout. -- @param context A widget context under which we are fit/drawn. -- @param width The available width -- @param height The available height -- @return A table with the following entries -- @field fit_width The width that should be returned from :fit -- @field fit_height The height that should be returned from :fit -- @field surface_width The width for showing the child widget -- @field surface_height The height for showing the child widget -- @field first_x The x offset for drawing the child the first time -- @field first_y The y offset for drawing the child the first time -- @field[opt] second_x The x offset for drawing the child the second time -- @field[opt] second_y The y offset for drawing the child the second time -- @field hierarchy The wibox.hierarchy instance representing "everything" -- @field context The widget context for drawing the hierarchy local function calculate_info(self, context, width, height) local result = {} assert(self._private.widget) -- First, get the size of the widget (and the size of extra space) local surface_width, surface_height = width, height local extra_width, extra_height, extra = 0, 0, self._private.expand and self._private.extra_space or 0 local w, h if self._private.dir == "h" then w, h = base.fit_widget(self, context, self._private.widget, self._private.space_for_scrolling, height) surface_width = w extra_width = extra else w, h = base.fit_widget(self, context, self._private.widget, width, self._private.space_for_scrolling) surface_height = h extra_height = extra end result.fit_width, result.fit_height = w, h if self._private.dir == "h" then if self._private.max_size then result.fit_width = math.min(w, self._private.max_size) end else if self._private.max_size then result.fit_height = math.min(h, self._private.max_size) end end if w > width or h > height then -- There is less space available than we need, we have to scroll _need_scroll_redraw(self) surface_width, surface_height = surface_width + extra_width, surface_height + extra_height local x, y = 0, 0 local function get_scroll_offset(size, visible_size) return self._private.step_function(self._private.timer:elapsed(), size, visible_size, self._private.speed, self._private.extra_space) end if self._private.dir == "h" then x = -get_scroll_offset(surface_width - extra, width) else y = -get_scroll_offset(surface_height - extra, height) end result.first_x, result.first_y = x, y -- Was the extra space already included elsewhere? local extra_spacer = self._private.expand and 0 or self._private.extra_space if self._private.dir == "h" then x = x + surface_width + extra_spacer else y = y + surface_height + extra_spacer end result.second_x, result.second_y = x, y else result.first_x, result.first_y = 0, 0 end result.surface_width, result.surface_height = surface_width, surface_height -- Get the hierarchy and subscribe ourselves to updates local hier, do_pending_updates, ctx = hierarchy_cache:get(context, self._private.widget, surface_width, surface_height) result.hierarchy = hier result.context = ctx do_pending_updates(self) return result end -- Draw this scrolling layout. -- @param context The context in which we are drawn. -- @param cr The cairo context to draw to. -- @param width The available width. -- @param height The available height. function scroll:draw(context, cr, width, height) if not self._private.widget then return end local info = calculate_info(self, context, width, height) -- Draw the first instance of the child cr:save() cr:translate(info.first_x, info.first_y) cr:rectangle(0, 0, info.surface_width, info.surface_height) cr:clip() info.hierarchy:draw(info.context, cr) cr:restore() -- If there is one, draw the second instance (same code as above, minus the -- clip) if info.second_x and info.second_y then cr:translate(info.second_x, info.second_y) cr:rectangle(0, 0, info.surface_width, info.surface_height) cr:clip() info.hierarchy:draw(info.context, cr) end end -- Fit the scroll layout into the given space. -- @param context The context in which we are fit. -- @param width The available width. -- @param height The available height. function scroll:fit(context, width, height) if not self._private.widget then return 0, 0 end local info = calculate_info(self, context, width, height) return info.fit_width, info.fit_height end -- Internal function used for triggering redraws for scrolling. -- The purpose is to start a timer for redrawing the widget for scrolling. -- Redrawing works by simply emitting the `widget::redraw_needed` signal. -- Pausing is implemented in this function: We just don't start a timer. -- This function must be idempotent (calling it multiple times right after -- another does not make a difference). _need_scroll_redraw = function(self) if not self._private.paused and not self._private.scroll_timer then self._private.scroll_timer = timer.start_new(1 / self._private.fps, function() self._private.scroll_timer = nil self:emit_signal("widget::redraw_needed") end) end end --- Pause the scrolling animation. -- @method pause -- @noreturn -- @see continue function scroll:pause() if self._private.paused then return end self._private.paused = true self._private.timer:stop() end --- Continue the scrolling animation. -- @method continue -- @noreturn -- @see pause function scroll:continue() if not self._private.paused then return end self._private.paused = false self._private.timer:continue() self:emit_signal("widget::redraw_needed") end --- Reset the scrolling state to its initial condition. -- For must scroll step functions, the effect of this function should be to -- display the widget without any scrolling applied. -- This function does not undo the effect of @{pause}. -- @method reset_scrolling -- @noreturn function scroll:reset_scrolling() self._private.timer:start() if self._private.paused then self._private.timer:stop() end end --- Set the direction in which this widget scroll. -- @method set_direction -- @param dir Either "h" for horizontal scrolling or "v" for vertical scrolling -- @noreturn function scroll:set_direction(dir) if dir == self._private.dir then return end if dir ~= "h" and dir ~= "v" then error("Invalid direction, can only be 'h' or 'v'") end self._private.dir = dir self:emit_signal("widget::layout_changed") self:emit_signal("widget::redraw_needed") end --- The widget to be scrolled. -- @property widget -- @tparam[opt=nil] widget|nil widget function scroll:set_widget(widget) if widget == self._private.widget then return end local w = base.make_widget_from_value(widget) if w then base.check_widget(w) end self._private.widget = w self:emit_signal("widget::layout_changed") self:emit_signal("widget::redraw_needed") end function scroll:get_widget() return self._private.widget end function scroll:get_children() return {self._private.widget} end function scroll:set_children(children) self:set_widget(children[1]) end --- Specify the expand mode that is used for extra space. -- @method set_expand -- @tparam boolean expand If true, the widget is expanded to include the extra -- space. If false, the extra space is simply left empty. -- @noreturn -- @see set_extra_space function scroll:set_expand(expand) if expand == self._private.expand then return end self._private.expand = expand self:emit_signal("widget::redraw_needed") end --- Set the number of frames per second that this widget should draw. -- @method set_fps -- @tparam number fps The number of frames per second -- @noreturn function scroll:set_fps(fps) if fps == self._private.fps then return end self._private.fps = fps -- No signal needed: If we are scrolling, the next redraw will apply the new -- FPS, else it obviously doesn't make a difference. end --- Set the amount of extra space that should be included in the scrolling. This -- extra space will likely be left empty between repetitions of the widgets. -- @method set_extra_space -- @tparam number extra_space The amount of extra space -- @noreturn -- @see set_expand function scroll:set_extra_space(extra_space) if extra_space == self._private.extra_space then return end self._private.extra_space = extra_space self:emit_signal("widget::redraw_needed") end --- Set the speed of the scrolling animation. The exact meaning depends on the -- step function that is used, but for the simplest step functions, this will be -- in pixels per second. -- @method set_speed -- @tparam number speed The speed for the animation -- @noreturn function scroll:set_speed(speed) if speed == self._private.speed then return end self._private.speed = speed self:emit_signal("widget::redraw_needed") end --- Set the maximum size of this widget in the direction set by -- @{set_direction}. If the child widget is smaller than this size, no scrolling -- is done. If the child widget is larger, then only this size will be visible -- and the rest is made visible via scrolling. -- @method set_max_size -- @tparam number max_size The maximum size of this widget or nil for unlimited. -- @noreturn function scroll:set_max_size(max_size) if max_size == self._private.max_size then return end self._private.max_size = max_size self:emit_signal("widget::layout_changed") end --- Set the step function that determines the exact behaviour of the scrolling -- animation. -- The step function is called with five arguments: -- -- * The time in seconds since the state of the animation -- * The size of the child widget -- * The size of the visible part of the widget -- * The speed of the animation. This should have a linear effect on this -- function's behaviour. -- * The extra space configured by @{set_extra_space}. This was not yet added to -- the size of the child widget, but should likely be added to it in most -- cases. -- -- The step function should return a single number. This number is the offset at -- which the widget is drawn and should be between 0 and `size+extra_space`. -- @method set_step_function -- @tparam function step_function A step function. -- @noreturn -- @see step_functions function scroll:set_step_function(step_function) -- Call the step functions once to see if it works step_function(0, 42, 10, 10, 5) if step_function == self._private.step_function then return end self._private.step_function = step_function self:emit_signal("widget::redraw_needed") end --- Set an upper limit for the space for scrolling. -- This restricts the child widget's maximal size. -- @method set_space_for_scrolling -- @tparam number space_for_scrolling The space for scrolling -- @noreturn function scroll:set_space_for_scrolling(space_for_scrolling) if space_for_scrolling == self._private.space_for_scrolling then return end self._private.space_for_scrolling = space_for_scrolling self:emit_signal("widget::layout_changed") end local function get_layout(dir, widget, fps, speed, extra_space, expand, max_size, step_function, space_for_scrolling) local ret = base.make_widget(nil, nil, {enable_properties = true}) ret._private.paused = false ret._private.timer = GLib.Timer() ret._private.scroll_timer = nil gtable.crush(ret, scroll, true) ret:set_direction(dir) ret:set_widget(widget) ret:set_fps(fps or 20) ret:set_speed(speed or 10) ret:set_extra_space(extra_space or 0) ret:set_expand(expand) ret:set_max_size(max_size) ret:set_step_function(step_function or scroll.step_functions.linear_increase) ret:set_space_for_scrolling(space_for_scrolling or 2^1024) return ret end --- Get a new horizontal scrolling container. -- @constructorfct wibox.container.scroll.horizontal -- @param[opt] widget The widget that should be scrolled -- @param[opt=20] fps The number of frames per second -- @param[opt=10] speed The speed of the animation -- @param[opt=0] extra_space The amount of extra space to include -- @tparam[opt=false] boolean expand Should the widget be expanded to include the -- extra space? -- @param[opt] max_size The maximum size of the child widget -- @param[opt=step_functions.linear_increase] step_function The step function to be used -- @param[opt=2^1024] space_for_scrolling The space for scrolling function scroll.horizontal(widget, fps, speed, extra_space, expand, max_size, step_function, space_for_scrolling) return get_layout("h", widget, fps, speed, extra_space, expand, max_size, step_function, space_for_scrolling) end --- Get a new vertical scrolling container. -- @constructorfct wibox.container.scroll.vertical -- @param[opt] widget The widget that should be scrolled -- @param[opt=20] fps The number of frames per second -- @param[opt=10] speed The speed of the animation -- @param[opt=0] extra_space The amount of extra space to include -- @tparam[opt=false] boolean expand Should the widget be expanded to include the -- extra space? -- @param[opt] max_size The maximum size of the child widget -- @param[opt=step_functions.linear_increase] step_function The step function to be used -- @param[opt=2^1024] space_for_scrolling The space for scrolling function scroll.vertical(widget, fps, speed, extra_space, expand, max_size, step_function, space_for_scrolling) return get_layout("v", widget, fps, speed, extra_space, expand, max_size, step_function, space_for_scrolling) end --- A selection of step functions -- @see set_step_function scroll.step_functions = {} --- A step function that scrolls the widget in an increasing direction with -- constant speed. -- @callback scroll.step_functions.linear_increase function scroll.step_functions.linear_increase(elapsed, size, _, speed, extra_space) return (elapsed * speed) % (size + extra_space) end --- A step function that scrolls the widget in an decreasing direction with -- constant speed. -- @callback scroll.step_functions.linear_decrease function scroll.step_functions.linear_decrease(elapsed, size, _, speed, extra_space) return (-elapsed * speed) % (size + extra_space) end --- A step function that scrolls the widget to its end and back to its -- beginning, then back to its end, etc. The speed is constant. -- @callback scroll.step_functions.linear_back_and_forth function scroll.step_functions.linear_back_and_forth(elapsed, size, visible_size, speed) local state = ((elapsed * speed) % (2 * size)) / size state = state <= 1 and state or 2 - state return (size - visible_size) * state end --- A step function that scrolls the widget to its end and back to its -- beginning, then back to its end, etc. The speed is null at the ends and -- maximal in the middle. -- @callback scroll.step_functions.nonlinear_back_and_forth function scroll.step_functions.nonlinear_back_and_forth(elapsed, size, visible_size, speed) local state = ((elapsed * speed) % (2 * size)) / size local negate = false if state > 1 then negate = true state = state - 1 end if state < 1/3 then -- In the first 1/3rd of time, do a quadratic increase in speed state = 2 * state * state elseif state < 2/3 then -- In the center, do a linear increase. That means we need: -- If state is 1/3, result is 2/9 = 2 * 1/3 * 1/3 -- If state is 2/3, result is 7/9 = 1 - 2 * (1 - 2/3) * (1 - 2/3) state = 5/3*state - 3/9 else -- In the last 1/3rd of time, do a quadratic decrease in speed state = 1 - 2 * (1 - state) * (1 - state) end if negate then state = 1 - state end return (size - visible_size) * state end --- A step function that scrolls the widget to its end and back to its -- beginning, then back to its end, etc. The speed is null at the ends and -- maximal in the middle. At both ends the widget stands still for a moment. -- @callback scroll.step_functions.waiting_nonlinear_back_and_forth function scroll.step_functions.waiting_nonlinear_back_and_forth(elapsed, size, visible_size, speed) local state = ((elapsed * speed) % (2 * size)) / size local negate = false if state > 1 then negate = true state = state - 1 end if state < 1/5 or state > 4/5 then -- One fifth of time, nothing moves state = state < 1/5 and 0 or 1 else state = (state - 1/5) * 5/3 if state < 1/3 then -- In the first 1/3rd of time, do a quadratic increase in speed state = 2 * state * state elseif state < 2/3 then -- In the center, do a linear increase. That means we need: -- If state is 1/3, result is 2/9 = 2 * 1/3 * 1/3 -- If state is 2/3, result is 7/9 = 1 - 2 * (1 - 2/3) * (1 - 2/3) state = 5/3*state - 3/9 else -- In the last 1/3rd of time, do a quadratic decrease in speed state = 1 - 2 * (1 - state) * (1 - state) end end if negate then state = 1 - state end return (size - visible_size) * state end --- A step function that scrolls the widget to its end and back to its -- beginning, then back to its end, etc. The speed is null at the ends and -- maximal in the middle. It’s smoothed by the use of a cosine function. -- @callback scroll.step_functions.smooth_back_and_forth function scroll.step_functions.smooth_back_and_forth(elapsed, size, visible_size, speed) local state = ((elapsed * speed) % (size)) / size -- The cosine function is scaled to map [0,1] to [0,2π] then it’s output is -- scaled from [1 -- -1 -- 1] to [0 -- 1 -- 0] return (size - visible_size) * (0.5 - 0.5*math.cos(state*6.2832)) end return scroll -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80