--[[-- Widget class. @classmod Widget --]]-- local ROOT = (...):gsub('[^.]*$', '') local Backend = require(ROOT .. 'backend') local Event = require(ROOT .. 'event') local Font = require(ROOT .. 'font') local Widget = {} Event.injectBinders(Widget) Widget.isWidget = true Widget.typeDecorators = { button = require(ROOT .. 'widget.button'), menu = require(ROOT .. 'widget.menu'), ['menu.item'] = require(ROOT .. 'widget.menu.item'), progress = require(ROOT .. 'widget.progress'), sash = require(ROOT .. 'widget.sash'), slider = require(ROOT .. 'widget.slider'), stepper = require(ROOT .. 'widget.stepper'), text = require(ROOT .. 'widget.text'), } function Widget.register (name, decorator) Widget.typeDecorators[name] = decorator end -- look for properties in shadow props, Widget, style, and theme local function metaIndex (self, property) local value = self.shadowProperties[property] if value ~= nil then return value end local value = Widget[property] if value ~= nil then return value end local layout = self.layout local style = layout:getStyle() value = style and style:getProperty(self, property) if value ~= nil and value ~= 'defer' then return value end local theme = layout:getTheme() return theme and theme:getProperty(self, property) end -- setting shadow properties causes special behavior local function metaNewIndex (self, property, value) if property == 'font' or property == 'fontSize' or property == 'textColor' then self.shadowProperties[property] = value self.fontData = Font(self.font, self.fontSize, self.textColor) return end if property == 'width' then value = value and math.max(value, self.minwidth or 0) self.shadowProperties[property] = value Widget.reshape(self.parent or self) return end if property == 'height' then value = value and math.max(value, self.minheight or 0) self.shadowProperties[property] = value Widget.reshape(self.parent or self) return end if property == 'value' then local oldValue = self.value self.shadowProperties[property] = value self:bubbleEvent('Change', { value = value, oldValue = oldValue, }) return end if property == 'key' then self.shadowProperties[property] = value if not value then return end local mainKey = (value):match '[^%-]+$' local alt = (value):match 'alt%-' and 1 or 0 local ctrl = (value):match 'ctrl%-' and 2 or 0 local shift = (value):match 'shift%-' and 4 or 0 local modifierFlags = alt + ctrl + shift local layout = self.layout.master or self.layout layout.accelerators[modifierFlags][mainKey] = self return end if property == 'id' then self.shadowProperties[property] = value if not value then return end local layout = self.layout.master or self.layout layout[value] = self return end rawset(self, property, value) end local shadowKeys = { 'font', 'fontSize', 'textColor', 'width', 'height', 'value', 'key', 'id' } --[[-- Widget pseudo-constructor. @function Luigi.Widget @tparam Layout layout The layout this widget belongs to. @tparam[opt] table data The data definition table for this widget. This table is identical to the constructed widget. @treturn Widget A Widget instance. --]]-- local function metaCall (Widget, layout, self) self = self or {} self.layout = layout self.position = { x = nil, y = nil } self.dimensions = { width = nil, height = nil } self.shadowProperties = {} setmetatable(self, { __index = metaIndex, __newindex = metaNewIndex }) for _, property in ipairs(shadowKeys) do local value = rawget(self, property) rawset(self, property, nil) self[property] = value end self.type = self.type or 'generic' self.fontData = Font(self.font, self.fontSize, self.textColor) -- layout:addWidget(self) local decorate = Widget.typeDecorators[self.type] if decorate then decorate(self) end for k, v in ipairs(self) do self[k] = v.isWidget and v or metaCall(Widget, self.layout, v) self[k].parent = self end return self end --[[-- Fire an event on this widget and each ancestor. If any event handler returns non-nil, stop the event from propagating. @tparam string eventName The name of the Event. @tparam[opt] table data Information about the event to send to handlers. @treturn mixed The first value returned by an event handler. --]]-- function Widget:bubbleEvent (eventName, data) local event = Event[eventName] data = data or {} data.target = self for ancestor in self:eachAncestor(true) do local result = event:emit(ancestor, data) if result ~= nil then return result end end return event:emit(self.layout, data) end --[[-- Get widget's previous sibling. @treturn Widget|nil The widget's previous sibling, if any. --]]-- function Widget:getPreviousSibling () local parent = self.parent if not parent then return end for i, widget in ipairs(parent) do if widget == self then return parent[i - 1] end end end --[[-- Get widget's next sibling. @treturn Widget|nil The widget's next sibling, if any. --]]-- function Widget:getNextSibling () local parent = self.parent if not parent then return end for i, widget in ipairs(parent) do if widget == self then return parent[i + 1] end end end --[[-- Attempt to focus the widget. Unfocus currently focused widget, and focus this widget if it's focusable. @treturn boolean true if this widget was focused, else false. --]]-- function Widget:focus () local layout = self.layout if layout.focusedWidget then layout.focusedWidget.focused = nil layout.focusedWidget = nil end if self.canFocus then self.focused = true layout.focusedWidget = self return true end return false end --[[-- Get the next widget, depth-first. If the widget has children, returns the first child. Otherwise, returns the next sibling of the nearest possible ancestor. Cycles back around to the layout root from the last widget in the tree. @treturn Widget The next widget in the tree. --]]-- function Widget:getNextNeighbor () if #self > 0 then return self[1] end for ancestor in self:eachAncestor(true) do local nextWidget = ancestor:getNextSibling() if nextWidget then return nextWidget end end return self.layout.root end -- get the last child of the last child of the last child of the... local function getGreatestDescendant (widget) while #widget > 0 do widget = widget[#widget] end return widget end --[[-- Get the previous widget, depth-first. Uses the reverse of the traversal order used by `getNextNeighbor`. Cycles back around to the last widget in the tree from the layout root. @treturn Widget The previous widget in the tree. --]]-- function Widget:getPreviousNeighbor () local layout = self.layout if self == layout.root then return getGreatestDescendant(self) end for ancestor in self:eachAncestor(true) do local previousWidget = ancestor:getPreviousSibling() if previousWidget then return getGreatestDescendant(previousWidget) end if ancestor ~= self then return ancestor end end return layout.root end --[[-- Add a child to this widget. @tparam Widget|table data A widget or definition table representing a widget. @treturn Widget The newly added child widget. --]]-- function Widget:addChild (data) local layout = self.layout local child = data and data.isWidget and data or Widget(layout, data or {}) self[#self + 1] = child child.parent = self child.layout = self.layout return child end local function clamp (value, min, max) return value < min and min or value > max and max or value end local function checkReshape (widget) if widget.needsReshape then widget.position = {} widget.dimensions = {} widget.needsReshape = false end end function Widget:calculateDimension (name) checkReshape(self) if self.dimensions[name] then return self.dimensions[name] end local min = (name == 'width') and (self.minwidth or 0) or (self.minheight or 0) local windowWidth, windowHeight = Backend.getWindowSize() local max = name == 'width' and windowWidth or windowHeight if self[name] then self.dimensions[name] = clamp(self[name], min, max) return self.dimensions[name] end local parent = self.parent if not parent then self.dimensions[name] = max return self.dimensions[name] end local parentDimension = parent:calculateDimension(name) parentDimension = parentDimension - (parent.margin or 0) * 2 parentDimension = parentDimension - (parent.padding or 0) * 2 local parentFlow = parent.flow or 'y' if (parentFlow == 'y' and name == 'width') or (parentFlow == 'x' and name == 'height') then self.dimensions[name] = clamp(parentDimension, min, max) return self.dimensions[name] end local claimed = 0 local unsized = 1 for i, widget in ipairs(self.parent) do if widget ~= self then if widget[name] then claimed = claimed + widget:calculateDimension(name) if claimed > parentDimension then claimed = parentDimension end else unsized = unsized + 1 end end end local size = (parentDimension - claimed) / unsized size = clamp(size, min, max) self.dimensions[name] = size return size end function Widget:calculatePosition (axis) checkReshape(self) if self.position[axis] then return self.position[axis] end local parent = self.parent if not parent then self.position[axis] = axis == 'x' and (self.left or 0) or axis == 'y' and (self.top or 0) return self.position[axis] end local parentPos = parent:calculatePosition(axis) local p = parentPos p = p + (parent.margin or 0) p = p + (parent.padding or 0) local parentFlow = parent.flow or 'y' for i, widget in ipairs(parent) do if widget == self then self.position[axis] = p return p end if parentFlow == axis then local dimension = (axis == 'x') and 'width' or 'height' p = p + widget:calculateDimension(dimension) end end self.position[axis] = 0 return 0 end --[[-- Get the widget's X coordinate. @treturn number The widget's X coordinate. --]]-- function Widget:getX () return self:calculatePosition('x') end --[[-- Get the widget's Y coordinate. @treturn number The widget's Y coordinate. --]]-- function Widget:getY () return self:calculatePosition('y') end --[[-- Get the widget's calculated width. @treturn number The widget's calculated width. --]]-- function Widget:getWidth () return self:calculateDimension('width') end --[[-- Get the widget's calculated height. @treturn number The widget's calculated height. --]]-- function Widget:getHeight () return self:calculateDimension('height') end function Widget:setDimension (name, size) if not self.parent then self[name] = size return end local parentDimension = self.parent:calculateDimension(name) local claimed = 0 for i, widget in ipairs(self.parent) do if widget ~= self and widget[name] then claimed = claimed + widget[name] end end if claimed + size > parentDimension then size = parentDimension - claimed end local min = (name == 'width') and (self.minwidth or 0) or (self.minheight or 0) self[name] = math.max(size, min) return self[name] end --[[-- Set the widget's width. Limited to space not occupied by siblings. @tparam number width The desired width. Actual width may differ. @treturn number The actual width of the widget. --]]-- function Widget:setWidth (width) return self:setDimension('width', width) end --[[-- Set the widget's height. Limited to space not occupied by siblings. @tparam number height The desired height. Actual height may differ. @treturn number The actual height of the widget. --]]-- function Widget:setHeight (height) return self:setDimension('height', height) end function Widget:getOrigin () return self:getX(), self:getY() end function Widget:getExtent () local x, y = self:getX(), self:getY() return x + self:getWidth(), y + self:getHeight() end --[[-- Get two points describing a rectangle within the widget. @tparam boolean useMargin Whether to adjust the rectangle based on the widget's margin. @tparam boolean usePadding Whether to adjust the rectangle based on the widget's padding. @treturn number The upper left corner's X position. @treturn number The upper left corner's Y position. @treturn number The lower right corner's X position. @treturn number The lower right corner's Y position. --]]-- function Widget:getRectangle (useMargin, usePadding) local x1, y1 = self:getOrigin() local x2, y2 = self:getExtent() local function shrink(amount) x1 = x1 + amount y1 = y1 + amount x2 = x2 - amount y2 = y2 - amount end if useMargin then shrink(self.margin or 0) end if usePadding then shrink(self.padding or 0) end return math.floor(x1), math.floor(y1), math.floor(x2), math.floor(y2) end --[[-- Determine whether a point is within a widget. @tparam number x The point's X coordinate. @tparam number y The point's Y coordinate. @treturn boolean true if the point is within the widget, else false. --]]-- function Widget:isAt (x, y) checkReshape(self) local x1, y1, x2, y2 = self:getRectangle() return (x1 <= x) and (x2 >= x) and (y1 <= y) and (y2 >= y) end function Widget:eachAncestor (includeSelf) local instance = includeSelf and self or self.parent return function() local widget = instance if not widget then return end instance = widget.parent return widget end end --[[-- Reshape the widget. Clears calculated widget dimensions, allowing them to be recalculated, and fires a Reshape event (does not bubble). Called recursively for each child. When setting a widget's width or height, this function is automatically called on the parent widget. --]]-- function Widget:reshape () if self.isReshaping then return end self.isReshaping = true self.needsReshape = true Event.Reshape:emit(self, { target = self }) for i, widget in ipairs(self) do if widget.reshape then widget:reshape() end end self.isReshaping = nil end return setmetatable(Widget, { __call = metaCall })