diff --git a/lovely/ui_overflow.toml b/lovely/ui_overflow.toml new file mode 100644 index 000000000..884771ad5 --- /dev/null +++ b/lovely/ui_overflow.toml @@ -0,0 +1,87 @@ +[manifest] +version = "1.0.0" +dump_lua = true +priority = -10 + +# Allow use stencil in global canvas +# Game:draw() +[[patches]] +[patches.pattern] +target = "game.lua" +pattern = '''love.graphics.setCanvas{self.CANVAS}''' +position = "at" +payload = ''' +love.graphics.setCanvas{self.CANVAS, stencil = true} +''' +match_indent = true + +# Limit overflow container size +# UIBox:calculate_xywh() +[[patches]] +[patches.pattern] +target = "engine/ui.lua" +pattern = '''_nt.h = math.max(_ct.h + padding, _nt.h)-- ''' +position = "after" +payload = ''' +if node.config and node.config.no_overflow then + if node.config.w then + _nt.w = node.config.w + elseif node.config.maxw then + _nt.w = math.min(_nt.w, node.config.maxw) + end + if node.config.h then + _nt.h = node.config.h + elseif node.config.maxh then + _nt.h = math.min(_nt.h, node.config.maxh) + end +end +''' +match_indent = true + +# Prevent text be rescaled by overflow container +# UIBox:calculate_xywh() +[[patches]] +[patches.pattern] +target = "engine/ui.lua" +pattern = '''fac = fac*restriction/(node.config.maxw and _ct.w or _ct.h)''' +position = "after" +payload = ''' +if node.config.no_overflow then fac = _scale or 1 end +''' +match_indent = true + +# Limit overflow container size during filling +# UIElement:set_wh() +[[patches]] +[patches.pattern] +target = "engine/ui.lua" +pattern = '''if w.UIT == G.UIT.C then w.T.h = _max_h end''' +position = "after" +payload = ''' +if w.config and w.config.no_overflow then + if w.config.w then + w.T.w = w.config.w + elseif w.config.maxw then + w.T.w = math.min(w.T.w, w.config.maxw) + end + if w.config.h then + w.T.h = w.config.h + elseif w.config.maxh then + w.T.h = math.min(w.T.h, w.config.maxh) + end +end +''' +match_indent = true + +# Exclude overflowed elements from colliding +# Controller:get_cursor_collision() +[[patches]] +[patches.pattern] +target = "engine/controller.lua" +pattern = '''if v:collides_with_point(cursor_trans) and not v.REMOVED then''' +position = "at" +payload = ''' +if v:collides_with_point(cursor_trans) and not v.REMOVED and v:inside_overflow_boundaries(cursor_trans) then +''' +match_indent = true +overwrite = false diff --git a/lsp_def/ui.lua b/lsp_def/ui.lua index d17b03a57..9d5963c1f 100644 --- a/lsp_def/ui.lua +++ b/lsp_def/ui.lua @@ -3,6 +3,9 @@ SMODS.GUI = {} SMODS.GUI.DynamicUIManager = {} +--- @type table +SMODS.stencil_stack = {} + ---@type string|"achievements"|"config"|"credits"|"mod_desc"|"additions" SMODS.LAST_SELECTED_MOD_TAB = "" @@ -53,6 +56,7 @@ G.UIT = { ---@field vert? boolean Sets if the text is drawn vertically. ---@field object? Node Object to render. ---@field role? "Major"|"Minor"|"Glued" Sets object's role type. +---@field no_overflow? boolean Renders node as overflow container: constrain it's size, truncate drawing and prevent colliding child nodes which go outside of parent's boundaries --- Internal class for annotating UIBox/UIElement tables before being turned into objects. ---@class UINode: table @@ -60,7 +64,68 @@ G.UIT = { ---@field config UINode.config Config of the UINode. ---@field nodes? UINode[] Child UINodes +-- + +---@class SMODS.UIScrollBox.input +---@field content Moveable | { definition: UINode, config: table, T?: table } Moveable or UIBox definition to render inside scrollable content (passed to G.UIT.O). +---@field container? { node_config?: UINode.config, config?: table, T?: table } UIBox args for scroll container which will be moved to create scroll effect. +---@field overflow? { node_config?: UINode.config, config?: table, T?: table } UIBox args for main element. +---@field progress? { x?: number, y?: number } Value of scroll content relative offset in directions (0-1). Keeps reference for original table. +---@field offset? { x?: number, y?: number } Value of scroll content absolute offset in directions (in game units). Keeps reference for original table. +---@field sync_mode? "offset" | "progress" | "none" Sync mode. `offset` sync progress to match offset, `progress` sync offset to match progress, `none` disables syncing. Default is `progress`. +---@field scroll_move? fun(self: SMODS.UIScrollBox, dt: number) Function which called every frame before scroll syncing and can be used to perform automatic scrolling. + +--- Element for displaying scrollable content +---@class SMODS.UIScrollBox: UIBox +---@field content Moveable Displayed content. +---@field content_container UIBox Container which positions `content` according to scroll offset. +---@field scroll_args SMODS.UIScrollBox.input Input args +---@field scroll_progress { x: number, y: number } Relative offset of scroll content in directions (0-1). Keeps reference for original table. +---@field scroll_offset { x: number, y: number } Absolute offset of scroll content in directions (in game units). Keeps reference for original table. +---@field scroll_sync_mode "offset" | "progress" | "none" Sync mode. `offset` sync progress to match offset, `progress` sync offset to match progress, `none` disables syncing. Default is `progress`. +---@overload fun(args: SMODS.UIScrollBox.input): SMODS.UIScrollBox +SMODS.UIScrollBox = {} +SMODS.UIScrollBox.__index = SMODS.UIScrollBox +SMODS.UIScrollBox.super = UIBox + +---@return number, number +--- Distance of content overflow in both directions +function SMODS.UIScrollBox:get_scroll_distance() end + +--- Update offset to match progress. Called every frame if `scroll_sync_mode = "progress"` +function SMODS.UIScrollBox:sync_scroll_offset() end + +--- Update progress to match offset. Called every frame if `scroll_sync_mode = "offset"` +function SMODS.UIScrollBox:sync_scroll_progress() end + +---@param t? { x?: number, y?: number } +--- Set new table for offset (keeps reference), and sync progress to match new offset +function SMODS.UIScrollBox:set_scroll_offset(t) end + +---@param t? { x?: number, y?: number } +--- Set new table for progress (keeps reference), and sync offset to match new progress +function SMODS.UIScrollBox:set_scroll_progress(t) end + +---@param dt number +---@param init? boolean Is sync called during initialization +--- Perform syncing according to `scroll_sync_mode`, and position elements to match result offset +function SMODS.UIScrollBox:sync_scroll(dt, init) end + -- UI Functions + +---@param stencil_fn fun(exit?: boolean) +--- Add new stencil to stencil stack; result stencil is sum of all stencils in stack +function SMODS.push_to_stencil_stack(stencil_fn) end + +--- Discard last applied stencil in stack +function SMODS.pop_from_stencil_stack() end + +--- Cleanup stencil stack +function SMODS.reset_stencil_stack() end + +--- Reload stencil stack by cleaning up current stencil and redrawing all stencils from stack +function SMODS.reload_stencil_stack() end + ---@param str string ---@return any --- Unpacks provided string. diff --git a/src/ui.lua b/src/ui.lua index bcda315e4..adab902bb 100644 --- a/src/ui.lua +++ b/src/ui.lua @@ -1,6 +1,201 @@ SMODS.GUI = {} SMODS.GUI.DynamicUIManager = {} +-- used to properly truncate overflow content inside another overflow content +SMODS.stencil_stack = {} + +function SMODS.push_to_stencil_stack(stencil_fn) + assert(type(stencil_fn) == "function", "No stencil function passed to SMODS.push_to_stencil_stack") + local old_level = #SMODS.stencil_stack + local new_level = old_level + 1 + + love.graphics.setStencilTest("equal", old_level) + love.graphics.stencil(function() + stencil_fn(false) + end, "increment", 1, true) + love.graphics.setStencilTest("equal", new_level) + + SMODS.stencil_stack[new_level] = stencil_fn +end +function SMODS.pop_from_stencil_stack() + local old_level = #SMODS.stencil_stack + local new_level = old_level - 1 + + local stencil_fn = SMODS.stencil_stack[old_level] + if not stencil_fn then + return + end + + love.graphics.setStencilTest("equal", old_level) + love.graphics.stencil(function() + stencil_fn(true) + end, "decrement", 1, true) + love.graphics.setStencilTest("equal", new_level) + + SMODS.stencil_stack[old_level] = nil +end +function SMODS.reset_stencil_stack() + EMPTY(SMODS.stencil_stack) + love.graphics.setStencilTest() + love.graphics.stencil(function() end) +end +function SMODS.reload_stencil_stack() + local stack_snapshot = SMODS.shallow_copy(SMODS.stencil_stack) + SMODS.reset_stencil_stack() + for _, stencil_fn in ipairs(stack_snapshot) do + SMODS.push_to_stencil_stack(stencil_fn) + end +end + +local gameDrawRef = Game.draw +function Game:draw(...) + SMODS.reset_stencil_stack() + gameDrawRef(self, ...) +end + +-- + +local uieDrawChildrenRef = UIElement.draw_children +function UIElement:draw_children(...) + local stenciled = false + if self.states.visible and self.config and self.config.no_overflow then + -- draw stencil for overflow container + stenciled = true + SMODS.push_to_stencil_stack(function(exit) + prep_draw(self, 1) + love.graphics.scale(1 / G.TILESIZE) + love.graphics.setColor(0, 0, 0, 1) + + if self.config.r and self.VT.w > 0.01 then + self:draw_pixellated_rect("fill", 0) + else + love.graphics.rectangle("fill", 0, 0, self.VT.w * G.TILESIZE, self.VT.h * G.TILESIZE) + end + + love.graphics.pop() + end) + end + uieDrawChildrenRef(self, ...) + -- cancel stencil for overflow container + if stenciled then SMODS.pop_from_stencil_stack() end +end + + +-- collision check +function Node:inside_overflow_boundaries(point) + -- Use cached value if present for current point + if self.overflow_check_timer == G.TIMERS.REAL and self.overflow_check_point == point then + return self.overflow_check_result or false + end + self.overflow_check_timer = G.TIMERS.REAL + self.overflow_check_point = point + local r = true + + -- No parent = no overflow can be done so collide as usual + if not self.parent then r = true + -- If parent has overflow then we should check do we collide with it and if not, all children in it cannot be collided too + elseif self.parent.config and self.parent.config.no_overflow and not Node.collides_with_point(self.parent, point) then r = false + -- Otherwise process all parents looking for first non-collideable overflow + else r = Node.inside_overflow_boundaries(self.parent, point) end + + self.overflow_check_result = r + return r +end + +-- + +SMODS.UIScrollBox = UIBox:extend() +function SMODS.UIScrollBox:init(args) + args = SMODS.merge_defaults(args, { content = {}, container = {}, overflow = {}, sync_mode = "progress" }) + args.progress = SMODS.merge_defaults(args.progress, { x = 0, y = 0 }) + args.offset = SMODS.merge_defaults(args.offset, { x = 0, y = 0 }) + + self.scroll_args = args + self.scroll_progress = args.progress + self.scroll_offset = args.offset + self.scroll_sync_mode = args.sync_mode + + if args.content and args.content.is and args.content:is(Object) then + self.content = args.content + else + self.content = UIBox(args.content) + end + + args.container.config = SMODS.merge_defaults(args.container.config, { align = "cm", offset = { x = 0, y = 0 } }) + args.container.node_config = SMODS.merge_defaults(args.container.node_config, { colour = G.C.CLEAR }) + args.container.definition = { + n = G.UIT.ROOT, + config = args.container.node_config, + nodes = { + { + n = G.UIT.O, + config = { + object = self.content, + }, + }, + }, + } + self.content_container = UIBox(args.container) + + args.overflow.config = SMODS.merge_defaults(args.overflow.config, {}) + args.overflow.node_config = SMODS.merge_defaults(args.overflow.node_config, { colour = G.C.CLEAR, no_overflow = true }) + args.overflow.definition = { + n = G.UIT.ROOT, + config = args.overflow.node_config, + nodes = { + { + n = G.UIT.O, + config = { + object = self.content_container, + }, + }, + }, + } + + UIBox.init(self, args.overflow) + + self:sync_scroll(0, true) +end +function SMODS.UIScrollBox:get_scroll_distance() + return math.max(0, self.content_container.T.w - self.T.w), math.max(0, self.content_container.T.h - self.T.h) +end +function SMODS.UIScrollBox:sync_scroll_offset() + local dx, dy = self:get_scroll_distance() + self.scroll_offset.x = dx * (self.scroll_progress.x or 0) + self.scroll_offset.y = dy * (self.scroll_progress.y or 0) +end +function SMODS.UIScrollBox:sync_scroll_progress() + local dx, dy = self:get_scroll_distance() + self.scroll_progress.x = (dx == 0 and 0) or ((self.offset.x or 0) / dx) + self.scroll_progress.y = (dy == 0 and 0) or ((self.offset.y or 0) / dy) +end +function SMODS.UIScrollBox:set_scroll_offset(t) + self.scroll_offset = SMODS.merge_defaults(t, { x = 0, y = 0 }) + self:sync_scroll_progress() +end +function SMODS.UIScrollBox:set_scroll_progress(t) + self.scroll_progress = SMODS.merge_defaults(t, { x = 0, y = 0 }) + self:sync_scroll_offset() +end +function SMODS.UIScrollBox:sync_scroll(dt, init) + if self.scroll_sync_mode == "progress" then + self:sync_scroll_offset() + elseif self.scroll_sync_mode == "offset" then + self:sync_scroll_progress() + end + self.content_container.config.offset.x = -(self.scroll_offset.x or 0) + self.content_container.config.offset.y = -(self.scroll_offset.y or 0) +end +function SMODS.UIScrollBox:update(dt) + if self.scroll_args.scroll_move then + self.scroll_args.scroll_move(self, dt) + end + self:sync_scroll(dt) + UIBox.update(self, dt) +end + +-- + function STR_UNPACK(str) local chunk, err = loadstring(str) if chunk then