Skip to content
87 changes: 87 additions & 0 deletions lovely/ui_overflow.toml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions lsp_def/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
SMODS.GUI = {}
SMODS.GUI.DynamicUIManager = {}

--- @type table<function>
SMODS.stencil_stack = {}

---@type string|"achievements"|"config"|"credits"|"mod_desc"|"additions"
SMODS.LAST_SELECTED_MOD_TAB = ""

Expand Down Expand Up @@ -53,14 +56,76 @@ 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
---@field n G.UIT Type of UIBox/UIElement
---@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.
Expand Down
195 changes: 195 additions & 0 deletions src/ui.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down