Merge remote-tracking branch 'origin/ui-lib'

Co-authored-by: Ivan Yuriev <ivanyr44@gmail.com>
This commit is contained in:
Neckrat 2025-08-21 20:59:54 +03:00
commit ace775f676
3 changed files with 595 additions and 0 deletions

553
lib/ui/core.lua Normal file
View File

@ -0,0 +1,553 @@
---@class UIConstraints
---@field min_w number
---@field min_h number
---@field max_w number
---@field max_h number
local ui = {}
-- =============== Constraints helpers ===============
local function make_constraints(min_w, min_h, max_w, max_h)
return {
min_w = min_w or 0,
min_h = min_h or 0,
max_w = max_w or math.huge,
max_h = max_h or math.huge,
}
end
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
local function clamp_size(w, h, c)
return clamp(w, c.min_w, c.max_w), clamp(h, c.min_h, c.max_h)
end
local function loosen(c)
-- "loose" constraints (0..max), удобно для wrap-контейнеров/Align
return make_constraints(0, 0, c.max_w, c.max_h)
end
local function tighten(c, w, h)
-- exact (tight) constraints — зажимают размер ребёнка
w, h = clamp_size(w, h, c)
return make_constraints(w, h, w, h)
end
-- =============== Base elements ===============
--- @class Element
--- @field parent Element|nil
--- @field children Element[]
--- @field child Element|nil
--- @field origin Vec3
--- @field size Vec3
--- @field state table
local Element = {}
Element.__index = Element
function Element:new(props)
local o = setmetatable({}, self)
o.parent = nil
o.children = {}
o.origin = Vec3 { 0, 0, 0 }
o.size = Vec3 { 0, 0, 0 }
o.state = {}
if props then
-- Копируем "публичные" поля; Таблицы клонируем, если можно
for k, v in pairs(props) do
if type(v) == "table" then
o[k] = v.copy and v:copy() or v
else
o[k] = v
end
end
end
return o
end
---@param dt number
function Element:update(dt)
-- По умолчанию спускаем update вниз
for _, ch in ipairs(self.children or {}) do
ch:update(dt)
end
end
---@param constraints UIConstraints
function Element:layout(constraints)
-- Базовое поведение: размер = объединённый bbox детей (wrap-content)
local max_w, max_h = 0, 0
for _, ch in ipairs(self.children) do
ch:layout(loosen(constraints)) -- по умолчанию не заставляем детей растягиваться
max_w = math.max(max_w, ch.size.x)
max_h = math.max(max_h, ch.size.y)
end
local w, h = clamp_size(max_w, max_h, constraints)
self.size.x, self.size.y = w, h
end
---@param origin Vec3
function Element:arrange(origin)
-- Базово: ставим себя, а детей кладём в (0,0) внутри нас
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
for _, ch in ipairs(self.children) do
ch:arrange(Vec3 { self.origin.x, self.origin.y, self.origin.z })
end
end
function Element:draw()
-- По умолчанию — только рисуем детей
for _, ch in ipairs(self.children) do
ch:draw()
end
end
-- =============== SingleChild / MultiChild базовые ===============
--- @class SingleChildElement: Element
local SingleChildElement = setmetatable({}, { __index = Element })
SingleChildElement.__index = SingleChildElement
function SingleChildElement:new(props)
local o = Element.new(self, props)
if o.child then
o.child.parent = o
o.children = { o.child }
end
return o
end
---@class MultiChildElement: Element
local MultiChildElement = setmetatable({}, { __index = Element })
MultiChildElement.__index = MultiChildElement
function MultiChildElement:new(props)
local o = Element.new(self, props)
o.children = o.children or o.children or {}
o.children = o.children -- ensure array
if props and props.children then
for _, ch in ipairs(props.children) do
ch.parent = o
end
end
return o
end
-- =============== Root ===============
---@class Root: SingleChildElement
local Root = setmetatable({}, { __index = SingleChildElement })
Root.__index = Root
function Root:new(props)
return SingleChildElement.new(self, props)
end
function Root:update(dt)
-- Root может делать глобальные обновления, но главное — спустить вниз
if self.child then self.child:update(dt) end
-- На каждом кадре делаем полный проход раскладки:
local w, h = love.graphics.getWidth(), love.graphics.getHeight()
self.size = Vec3 { w, h, 0 }
local constraints = make_constraints(w, h, w, h) -- tight: размер окна
if self.child then
-- layout с точными ограничениями родителя
self.child:layout(loosen(constraints)) -- разрешаем контенту быть меньше окна
-- arrange кладём ребёнка в начало (0,0), а Align/др. уже выровняет внутри себя
self.child:arrange(Vec3 { 0, 0, 0 })
end
end
function Root:draw()
if self.child then self.child:draw() end
end
-- =============== Rectangle (leaf) ===============
---@class Rectangle: Element
---@field color number[] @ {r,g,b} 0..1
---@field size Vec3
local Rectangle = setmetatable({}, { __index = Element })
Rectangle.__index = Rectangle
function Rectangle:new(props)
local o = Element.new(self, props)
--- @cast o Rectangle
o.color = o.color or { 1, 1, 1 }
-- size должен быть задан; если нет — size = 0,0
o.size = o.size or Vec3 { 0, 0, 0 }
return o
end
function Rectangle:layout(constraints)
local w = self.size.x or 0
local h = self.size.y or 0
w, h = clamp_size(w, h, constraints)
self.size.x, self.size.y = w, h
end
function Rectangle:draw()
love.graphics.setColor(self.color[1], self.color[2], self.color[3], 1)
love.graphics.rectangle("fill", self.origin.x, self.origin.y, self.size.x, self.size.y)
-- Сброс цвета — по вкусу
end
-- =============== Align (single child) ===============
---@class Align: SingleChildElement
---@field alignment string "center_left" | "center" | "center_right" | "top_left" | "top_center" | "top_right" | "bottom_left" | "bottom_center" | "bottom_right"
---@field expand boolean если true — занимает все доступное от родителя
local Align = setmetatable({}, { __index = SingleChildElement })
Align.__index = Align
function Align:new(props)
local o = SingleChildElement.new(self, props)
---@cast o Align
o.alignment = o.alignment or "center"
o.expand = (o.expand ~= nil) and o.expand or true -- по умолчанию растягиваемся под родителя
return o
end
function Align:layout(constraints)
if self.child then
-- Ребёнка считаем "loose" — пусть занимает сколько хочет
self.child:layout(loosen(constraints))
if self.expand then
-- Сам Align займет максимум
self.size.x, self.size.y = clamp_size(constraints.max_w, constraints.max_h, constraints)
else
-- Или же wrap по ребёнку
local w, h = self.child.size.x, self.child.size.y
self.size.x, self.size.y = clamp_size(w, h, constraints)
end
else
self.size.x, self.size.y = clamp_size(0, 0, constraints)
end
end
function Align:arrange(origin)
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
if not self.child then return end
local pw, ph = self.size.x, self.size.y
local cw, ch = self.child.size.x, self.child.size.y
local x, y = self.origin.x, self.origin.y
--- @todo сделать красиво
if self.alignment == "center" then
x = x + (pw - cw) / 2
y = y + (ph - ch) / 2
elseif self.alignment == "center_left" then
y = y + (ph - ch) / 2
elseif self.alignment == "center_right" then
x = x + (pw - cw)
y = y + (ph - ch) / 2
elseif self.alignment == "top_left" then
-- x,y остаются
elseif self.alignment == "top_center" then
x = x + (pw - cw) / 2
elseif self.alignment == "top_right" then
x = x + (pw - cw)
elseif self.alignment == "bottom_left" then
y = y + (ph - ch)
elseif self.alignment == "bottom_center" then
y = y + (ph - ch)
x = x + (pw - cw) / 2
elseif self.alignment == "bottom_right" then
x = x + (pw - cw)
y = y + (ph - ch)
else
-- неизвестное — по центру
x = x + (pw - cw) / 2
y = y + (ph - ch) / 2
end
self.child:arrange(Vec3 { x, y, self.origin.z })
end
-- =============== Row / Column (multi) ===============
-- helper: прочитать flex у ребёнка (если есть)
local function get_flex_of(ch)
return (ch.get_flex and ch:get_flex()) or 0
end
---@class Row: MultiChildElement
---@field gap number расстояние между детьми
local Row = setmetatable({}, { __index = MultiChildElement })
Row.__index = Row
function Row:new(props)
local o = MultiChildElement.new(self, props)
---@cast o Row
o.gap = o.gap or 0
return o
end
function Row:layout(constraints)
local total_gap = (#self.children > 1) and (self.gap * (#self.children - 1)) or 0
local fixed_w, max_h = 0, 0
local flex_children, total_flex = {}, 0
local loose = loosen(constraints)
-- проход 1: меряем нефлекс-детей, собираем флекс-список
for _, ch in ipairs(self.children) do
local f = get_flex_of(ch)
if f > 0 then
total_flex = total_flex + f
flex_children[#flex_children + 1] = { node = ch, flex = f }
else
ch:layout(loose)
fixed_w = fixed_w + ch.size.x
if ch.size.y > max_h then max_h = ch.size.y end
end
end
local min_row_w = fixed_w + total_gap
local wants_expand = total_flex > 0
-- КЛЮЧ: если есть флекс → растягиваемся до доступного max_w
local candidate = wants_expand and constraints.max_w or min_row_w
local target_w = clamp(candidate, math.max(min_row_w, constraints.min_w), constraints.max_w)
local remaining = math.max(0, target_w - min_row_w)
-- проход 2: раздаём остаток флекс-детям (tight по ширине)
if total_flex > 0 and remaining > 0 then
local acc = 0
for i, item in ipairs(flex_children) do
local alloc = (i == #flex_children)
and (remaining - acc) -- последний добирает остаток для суммирования в точности в target_w
or (remaining * item.flex / total_flex)
acc = acc + alloc
local c = make_constraints(alloc, 0, alloc, loose.max_h)
item.node:layout(c)
if item.node.size.y > max_h then max_h = item.node.size.y end
end
else
-- даже если remaining==0, флекс-детей всё равно надо промерить (tight нулевой шириной)
for _, item in ipairs(flex_children) do
local c = make_constraints(0, 0, 0, loose.max_h)
item.node:layout(c)
if item.node.size.y > max_h then max_h = item.node.size.y end
end
end
self.size.x, self.size.y = target_w, clamp(max_h, constraints.min_h, constraints.max_h)
end
function Row:arrange(origin)
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
local cursor_x = self.origin.x
local y_base = self.origin.y
for i, ch in ipairs(self.children) do
-- Вертикальное выравнивание: по базовой линии (top). Можно прокачать до "center" / "bottom".
ch:arrange(Vec3 { cursor_x, y_base, self.origin.z })
cursor_x = cursor_x + ch.size.x + (i < #self.children and self.gap or 0)
end
end
---@class Column: MultiChildElement
---@field gap number
local Column = setmetatable({}, { __index = MultiChildElement })
Column.__index = Column
function Column:new(props)
local o = MultiChildElement.new(self, props)
--- @cast o Column
o.gap = o.gap or 0
return o
end
function Column:layout(constraints)
local total_gap = (#self.children > 1) and (self.gap * (#self.children - 1)) or 0
local fixed_h, max_w = 0, 0
local flex_children, total_flex = {}, 0
local loose = loosen(constraints)
for _, ch in ipairs(self.children) do
local f = get_flex_of(ch)
if f > 0 then
total_flex = total_flex + f
flex_children[#flex_children + 1] = { node = ch, flex = f }
else
ch:layout(loose)
fixed_h = fixed_h + ch.size.y
if ch.size.x > max_w then max_w = ch.size.x end
end
end
local min_col_h = fixed_h + total_gap
local wants_expand = (total_flex > 0)
local candidate = wants_expand and constraints.max_h or min_col_h
local target_h = clamp(candidate, math.max(min_col_h, constraints.min_h), constraints.max_h)
local remaining = math.max(0, target_h - min_col_h)
if total_flex > 0 and remaining > 0 then
local acc = 0
for i, item in ipairs(flex_children) do
local alloc = (i == #flex_children)
and (remaining - acc)
or (remaining * item.flex / total_flex)
acc = acc + alloc
local c = make_constraints(0, alloc, loose.max_w, alloc)
item.node:layout(c)
if item.node.size.x > max_w then max_w = item.node.size.x end
end
else
for _, item in ipairs(flex_children) do
local c = make_constraints(0, 0, loose.max_w, 0)
item.node:layout(c)
if item.node.size.x > max_w then max_w = item.node.size.x end
end
end
self.size.x, self.size.y = clamp(max_w, constraints.min_w, constraints.max_w), target_h
end
function Column:arrange(origin)
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
local x_base = self.origin.x
local cursor_y = self.origin.y
for i, ch in ipairs(self.children) do
ch:arrange(Vec3 { x_base, cursor_y, self.origin.z })
cursor_y = cursor_y + ch.size.y + (i < #self.children and self.gap or 0)
end
end
-----------------------------------------------------------------
---@class Expanded: SingleChildElement
---@field flex integer -- коэффициент флекса (>=1)
local Expanded = setmetatable({}, { __index = SingleChildElement })
Expanded.__index = Expanded
---@param props { flex?: integer, child?: Element }|nil
---@return Expanded
function Expanded:new(props)
local o = SingleChildElement.new(self, props)
---@cast o Expanded
o.flex = (o.flex and math.max(1, math.floor(o.flex))) or 1
return o
end
function Expanded:get_flex()
return self.flex
end
function Expanded:layout(constraints)
if self.child then
self.child:layout(constraints)
-- Становимся размером с ребёнка с учётом ограничений
local w, h = self.child.size.x, self.child.size.y
w, h = clamp_size(w, h, constraints)
self.size.x, self.size.y = w, h
else
-- Пустой Expanded — просто занимает выделенное родителем место (можно для Spacer)
local w, h = clamp_size(0, 0, constraints)
self.size.x, self.size.y = w, h
end
end
function Expanded:arrange(origin)
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
if self.child then
self.child:arrange(self.origin)
end
end
-- =============== Padding (single child) ===============
---@class Padding: SingleChildElement
---@field padding {left?: number, right?: number, top?: number, bottom?: number}
local Padding = setmetatable({}, { __index = SingleChildElement })
Padding.__index = Padding
---@param props { padding?: {left?: number, right?: number, top?: number, bottom?: number}, child?: Element }|nil
---@return Padding
function Padding:new(props)
local o = SingleChildElement.new(self, props)
---@cast o Padding
local p = o.padding or {}
o.padding = {
left = tonumber(p.left) or 0,
right = tonumber(p.right) or 0,
top = tonumber(p.top) or 0,
bottom = tonumber(p.bottom) or 0,
}
return o
end
function Padding:layout(constraints)
local L = self.padding.left
local R = self.padding.right
local T = self.padding.top
local B = self.padding.bottom
local hp = L + R
local vp = T + B
-- Вычитаем паддинги из ограничений для ребёнка
local child_min_w = math.max(0, constraints.min_w - hp)
local child_min_h = math.max(0, constraints.min_h - vp)
local child_max_w = math.max(0, constraints.max_w - hp)
local child_max_h = math.max(0, constraints.max_h - vp)
local child_c = make_constraints(child_min_w, child_min_h, child_max_w, child_max_h)
if self.child then
self.child:layout(child_c)
local w = self.child.size.x + hp
local h = self.child.size.y + vp
self.size.x, self.size.y = clamp_size(w, h, constraints)
else
-- Нет ребёнка: размер равен сумме паддингов (с учётом ограничений)
self.size.x, self.size.y = clamp_size(hp, vp, constraints)
end
end
function Padding:arrange(origin)
self.origin = Vec3 { origin.x, origin.y, origin.z or 0 }
if not self.child then return end
local L = self.padding.left
local T = self.padding.top
self.child:arrange(Vec3 { self.origin.x + L, self.origin.y + T, self.origin.z })
end
-- =============== Public constructors (callable) ===============
local function mk_ctor(class)
-- Сохраняем существующую метатаблицу (в ней уже есть __index на родителя!)
local mt = getmetatable(class) or {}
mt.__call = function(_, props)
return class:new(props or {})
end
setmetatable(class, mt)
return class
end
ui.Element = Element
ui.Root = mk_ctor(Root)
ui.Align = mk_ctor(Align)
ui.Row = mk_ctor(Row)
ui.Column = mk_ctor(Column)
ui.Rectangle = mk_ctor(Rectangle)
ui.Expanded = mk_ctor(Expanded)
ui.Padding = mk_ctor(Padding)
-- Экспорт вспомогательных, если пригодится
ui.constraints = {
make = make_constraints,
loosen = loosen,
tighten = tighten,
clamp_size = clamp_size,
}
return ui

36
lib/ui/layout.lua Normal file
View File

@ -0,0 +1,36 @@
local Vec3 = require "lib.utils.vec3"
local ui = require "lib.ui.core"
--- @type Rectangle
local ReactiveRectangle = ui.Rectangle {
size = Vec3 { 100, 100 },
color = { 1, 0, 0 },
state = { tick = 0 }
}
function ReactiveRectangle:update(dt)
getmetatable(self):update(dt)
local mx, my = love.mouse.getPosition()
if mx > self.origin.x and mx < self.origin.x + self.size.x
and my > self.origin.y and my < self.origin.y + self.size.y
then
self.color = { 0, 1, 0 }
else
self.color = { 1, 0, 0 }
end
self.state.tick = self.state.tick + 1
end
local Layout = ui.Root {
child = ui.Align {
alignment = "bottom_center",
child = ui.Row {
children = {
ReactiveRectangle
}
}
}
}
return Layout

View File

@ -1,5 +1,6 @@
-- CameraLoader = require 'lib/camera'
local Widgets = require "lib.ui.layout"
local character = require "lib/character/character"
require "lib/tree"
@ -18,9 +19,11 @@ local lt = "0"
function love.update(dt)
local t1 = love.timer.getTime()
Tree.controls:poll()
Widgets:update(dt) -- логика UI-слоя должна отработать раньше всех, потому что нужно перехватить жесты и не пустить их дальше
Tree.panning:update(dt)
Tree.level:update(dt)
Tree.controls:cache()
local t2 = love.timer.getTime()
lt = string.format("%.3f", (t2 - t1) * 1000)
end
@ -51,6 +54,9 @@ function love.draw()
Tree.level.camera:detach()
Widgets:draw()
love.graphics.setColor(1, 1, 1)
local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt
love.graphics.print(stats, 10, 10)