---@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) 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"|"top_left"|"top_right"|"bottom_left"|"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 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_right" then x = x + (pw - cw) elseif self.alignment == "bottom_left" then y = y + (ph - ch) 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 -- =============== 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 -- =============== 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 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