From 017f971311a4c82e1e1e997c2c9d0996de14cff1 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Sun, 17 Aug 2025 06:18:49 +0300 Subject: [PATCH] complete ui rewrite --- lib/ui/core.lua | 490 +++++++++++++++++++++++++++++++++++++++++++++ lib/ui/element.lua | 44 ---- lib/ui/widgets.lua | 72 ------- main.lua | 44 ++-- 4 files changed, 517 insertions(+), 133 deletions(-) create mode 100644 lib/ui/core.lua delete mode 100644 lib/ui/element.lua delete mode 100644 lib/ui/widgets.lua diff --git a/lib/ui/core.lua b/lib/ui/core.lua new file mode 100644 index 0000000..8bc3a75 --- /dev/null +++ b/lib/ui/core.lua @@ -0,0 +1,490 @@ +---@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 + +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.constraints = { + make = make_constraints, + loosen = loosen, + tighten = tighten, + clamp_size = clamp_size, +} + +return ui diff --git a/lib/ui/element.lua b/lib/ui/element.lua deleted file mode 100644 index 3a46b12..0000000 --- a/lib/ui/element.lua +++ /dev/null @@ -1,44 +0,0 @@ -require "lib.utils.vec3" ---- Stateful UI element ---- @class Element ---- @field state table ---- @field update fun(self: Element, dt: number) called each logical frame, alters self.state ---- @field draw fun(self: Element) uses self.state to draw, should not alter anything ---- @field origin Vec3 ---- @field size Vec3 ---- @field parent Element | nil ---- @field child Element | nil -local Element = {} -Element.__index = Element - -Element.state = {} -function Element:update(dt) - local parent = self.parent - if not parent then return end - - self.origin = self.origin or parent.origin - self.size = self.size or parent.size -end - -function Element:draw() end - ---- Recursive depth-first traversal. ---- If `visit` returns false, traversal is stopped early. ---- @param visit fun(el: Element): boolean|nil ---- @return boolean -function Element:traverse(visit) - local cont = visit(self) - if not cont then return false end - if not self.child then return false end - self.child.parent = self - return not not self.child:traverse(visit) -end - ---- template constructor ---- @param data {state: table, update: fun(dt: number), draw: fun(), [any]: any} ---- @return Element -function Element.new(data) - return setmetatable(data, Element) -end - -return Element diff --git a/lib/ui/widgets.lua b/lib/ui/widgets.lua deleted file mode 100644 index 084e569..0000000 --- a/lib/ui/widgets.lua +++ /dev/null @@ -1,72 +0,0 @@ -require "lib.utils.vec3" -local baseElement = require "lib.ui.element" - -local W = {} ---- @alias Alignment "topLeft" | "topCenter" | "topRight" | "centerLeft" | "center" | "centerRight" | "bottomLeft" | "bottomCenter" | "bottomRight" ---- @type {[Alignment]: Vec3} -local alignments = { - topLeft = Vec3 { 0, 0 }, - topCenter = Vec3 { 0.5, 0 }, - topRight = Vec3 { 1, 0 }, - centerLeft = Vec3 { 0, 0.5 }, - center = Vec3 { 0.5, 0.5 }, - centerRight = Vec3 { 1, 0.5 }, - bottomLeft = Vec3 { 0, 1 }, - bottomCenter = Vec3 { 0.5, 1 }, - bottomRight = Vec3 { 1, 1 } -} - - ---- @class UIRoot : Element -local Root = {} -setmetatable(Root, { __index = baseElement }) - -function Root.new(data) - return setmetatable(data, { __index = Root }) -end - -function Root:update(dt) - self.size = Vec3 { love.graphics.getWidth(), love.graphics.getHeight() } -end - -W.Root = Root.new --------------------------------------------------- - ---- @class Align : Element ---- @field alignment Alignment -local Align = {} -setmetatable(Align, { __index = baseElement }) - -function Align.new(data) - data.alignment = data.alignment or "center" - return setmetatable(data, { __index = Align }) -end - -function Align:update(dt) - local parent = self.parent --[[@as Element]] - local shift = alignments[self.alignment] - self.origin = Vec3 { parent.size.x * shift.x, parent.size.y * shift.y } -end - -W.Align = Align.new --------------------------------------------------- - ---- @class Rectangle : Element ---- @field color number[] -local Rectangle = {} -setmetatable(Rectangle, { __index = baseElement }) - -function Rectangle.new(data) - return setmetatable(data, { __index = Rectangle }) -end - -function Rectangle:draw() - love.graphics.setColor(self.color or { 1, 1, 1 }) - love.graphics.rectangle("fill", self.origin.x - self.size.x / 2, self.origin.y - self.size.y / 2, self.size.x, - self.size.y) -end - -W.Rectangle = Rectangle.new ---------------------------------------------------- - -return W diff --git a/main.lua b/main.lua index 53dcbcb..82d98e1 100644 --- a/main.lua +++ b/main.lua @@ -1,6 +1,6 @@ -- CameraLoader = require 'lib/camera' -local ui = require "lib.ui.widgets" +local ui = require "lib.ui.core" local character = require "lib/character/character" require "lib/tree" @@ -25,13 +25,31 @@ function love.load() end Widgets = ui.Root { - child = ui.Align { - alignment = "bottomCenter", - child = ui.Rectangle { - size = Vec3 { 100, 100 }, - color = { 0, 0, 1 } + child = + ui.Column { + children = { + ui.Row { + gap = 8, + children = { + ui.Rectangle { size = Vec3 { 100, 100 }, color = { 0, 0, 1 } }, + ui.Rectangle { size = Vec3 { 200, 100 }, color = { 1, 0, 0 } }, + ui.Expanded { flex = 1, child = ui.Rectangle { size = Vec3 { 100, 150 }, color = { 0, 1, 0 } } }, + ui.Rectangle { size = Vec3 { 100, 100 }, color = { 1, 1, 0 } }, + } + }, + ui.Expanded {}, + ui.Row { + gap = 8, + children = { + ui.Rectangle { size = Vec3 { 100, 100 }, color = { 0, 0, 1 } }, + ui.Rectangle { size = Vec3 { 200, 100 }, color = { 1, 0, 0 } }, + ui.Expanded { flex = 1, child = ui.Rectangle { size = Vec3 { 100, 150 }, color = { 0, 1, 0 } } }, + ui.Rectangle { size = Vec3 { 100, 100 }, color = { 1, 1, 0 } }, + } + }, + } } - } + } -- PlayerFaction.characters = { Hero1, Hero2 } @@ -46,10 +64,7 @@ function love.update(dt) Tree.level:update(dt) Tree.controls:cache() - Widgets:traverse(function(el) - el:update(dt) - return true - end) + Widgets:update(dt) local t2 = love.timer.getTime() lt = string.format("%.3f", (t2 - t1) * 1000) end @@ -99,12 +114,7 @@ function love.draw() Tree.level:draw() Tree.level.camera:detach() - Widgets:traverse( - function(el) - el:draw() - return true - end - ) + Widgets:draw() love.graphics.setColor(1, 1, 1) local stats = "fps: " .. love.timer.getFPS() .. " lt: " .. lt .. " dt: " .. dt