summaryrefslogtreecommitdiff
path: root/ui.lua
diff options
context:
space:
mode:
Diffstat (limited to 'ui.lua')
-rw-r--r--ui.lua412
1 files changed, 412 insertions, 0 deletions
diff --git a/ui.lua b/ui.lua
new file mode 100644
index 0000000..81f453c
--- /dev/null
+++ b/ui.lua
@@ -0,0 +1,412 @@
+local bit32 = require("bit32")
+local utf8 = require("utf8")
+local distribute = require("distribute")
+
+local function ui_new(root)
+ return { targets = {}, root = root }
+end
+
+local function compute(x, ...)
+ if type(x) == "function" then
+ return x(...)
+ end
+ return x
+end
+
+local function access(t, k)
+ if t ~= nil then
+ return t[k]
+ end
+end
+
+local function align(kind, pos, needed, avail)
+ if not kind then
+ return pos, avail
+ elseif kind == "start" then
+ return pos, needed
+ elseif kind == "center" then
+ return pos+(avail-needed)/2, needed
+ elseif kind == "end" then
+ return pos+avail-needed, needed
+ end
+end
+
+local function add_computed(node, attr, k, v)
+ node.computed = node.computed or {}
+ node.computed[attr] = node.computed[attr] or {}
+ node.computed[attr][k] = v
+end
+
+local function node_compute_min_size(node, dir)
+ local size = access(compute(node.size, node), dir)
+ add_computed(node, "fixed_size", dir, size)
+
+ if not size then
+ size = 0
+ for _, child in ipairs(node) do
+ local child_size = node_compute_min_size(child, dir)
+ if node.stack == dir then
+ size = size + child_size
+ else
+ size = math.max(size, child_size)
+ end
+ end
+ end
+
+ add_computed(node, "min_size", dir, size)
+ return size
+end
+
+local function node_compute_layout(node, dir, pos, size)
+ size = node.computed.fixed_size[dir] or size
+ local align_as = access(node.align, dir)
+
+ add_computed(node, "pos", dir, pos)
+ add_computed(node, "size", dir, size)
+
+ if node.stack ~= dir then
+ for _, child in ipairs(node) do
+ node_compute_layout(child, dir, align(align_as, pos, child.computed.min_size[dir], size))
+ end
+ return
+ end
+
+ local min_size_noflex = 0
+ local flex_elems = {}
+ local has_flex
+ for i, child in ipairs(node) do
+ if child.flex then
+ has_flex = true
+ flex_elems[i] = {
+ min = child.computed.min_size[dir],
+ weight = child.flex,
+ }
+ else
+ min_size_noflex = min_size_noflex + child.computed.min_size[dir]
+ end
+ end
+
+ distribute(size-min_size_noflex, flex_elems)
+
+ if not has_flex then
+ pos = align(align_as, pos, min_size_noflex, size)
+ end
+
+ for i, child in ipairs(node) do
+ local child_size = access(flex_elems[i], "receive") or child.computed.min_size[dir]
+ node_compute_layout(child, dir, pos, child_size)
+ pos = pos + child_size
+ end
+end
+
+local function tree_compute_layout(tree, x, y, width, height)
+ node_compute_min_size(tree, "x")
+ node_compute_min_size(tree, "y")
+ node_compute_layout(tree, "x", x, width)
+ node_compute_layout(tree, "y", y, height)
+end
+
+-- render
+
+local function update_flags(node, targets, flags)
+ local new = {}
+ for k, v in pairs(targets) do
+ new[k] = node == v or (flags[k] and not (node.events and node.events[k]))
+ end
+ return new
+end
+
+local function node_render(node, targets, flags)
+ flags = update_flags(node, targets, flags)
+
+ local pos, size = node.computed.pos, node.computed.size
+ if node.fill then
+ love.graphics.setColor(compute(node.fill, node, flags))
+ love.graphics.rectangle("fill", pos.x, pos.y, size.x, size.y)
+ end
+ if node.line then
+ love.graphics.setColor(compute(node.line, node, flags))
+ love.graphics.rectangle("line", pos.x, pos.y, size.x, size.y)
+ end
+ if node.text then
+ love.graphics.setColor(compute(node.color, node, flags))
+ love.graphics.draw(node.text, pos.x, pos.y)
+ end
+
+ for _, child in ipairs(node) do
+ node_render(child, targets, flags)
+ end
+end
+
+local function ui_render(ui)
+ node_render(ui.root, ui.targets, {})
+end
+
+-- events
+
+local function node_get_target(node, event, x, y)
+ -- todo: maybe reverse iteration?
+ for _, child in ipairs(node) do
+ local hov = node_get_target(child, event, x, y)
+ if hov then
+ return hov
+ end
+ end
+
+ if not node.events or not node.events[event] then
+ return nil
+ end
+
+ local pos, size = node.computed.pos, node.computed.size
+ if x >= pos.x and y >= pos.y and x < pos.x + size.x and y < pos.y + size.y then
+ return node
+ end
+end
+
+local function ui_get_target(ui, event)
+ return node_get_target(ui.root, event, love.mouse.getPosition())
+end
+
+local function fire_event(node, event, ...)
+ if node and node.events and node.events[event] then
+ if type(node.events[event]) == "function" then
+ node.events[event](node, ...)
+ end
+ return true
+ end
+ return false
+end
+
+local function ui_set_target(ui, event, elem)
+ local old = ui.targets[event]
+ if old ~= elem then
+ fire_event(old, event.."_end")
+ fire_event(elem, event)
+ end
+ ui.targets[event] = elem
+end
+
+local function ui_update_target(ui, event)
+ ui_set_target(ui, event, ui_get_target(ui, event))
+end
+
+local function ui_mousepressed(ui, x, y, button)
+ if button ~= 1 then
+ return false
+ end
+
+ ui_update_target(ui, "press")
+ ui_update_target(ui, "focus")
+
+ local focus = ui.targets.focus
+ if focus and focus.events.input then
+ local pos, size = focus.computed.pos, focus.computed.size
+ love.keyboard.setTextInput(true, pos.x, pos.y, size.x, size.y)
+ else
+ love.keyboard.setTextInput(false)
+ end
+
+ if ui.targets.press or ui.targets.focus then
+ return true
+ end
+
+ return false
+end
+
+local function ui_mousereleased(ui, x, y, button)
+ if button ~= 1 then
+ return false
+ end
+
+ local press = ui_get_target(ui, "press", love.mouse.getPosition())
+ if press == ui.targets.press then
+ fire_event(press, "click")
+ end
+ ui_set_target(ui, "press", nil)
+
+ if press then
+ return true
+ end
+ return false
+end
+
+local function ui_textinput(ui, text)
+ return fire_event(ui.targets.focus, "input", text)
+end
+
+local function ui_keypressed(ui, key)
+ if key == "backspace" then
+ return fire_event(ui.targets.focus, "input", "\b")
+ elseif key == "v" and (love.keyboard.isDown("rctrl") or love.keyboard.isDown("lctrl")) then
+ return fire_event(ui.targets.focus, "input", love.system.getClipboardText())
+ end
+
+ return false
+end
+
+-- update
+
+local function ui_update(ui)
+ tree_compute_layout(ui.root, 0, 0, love.graphics:getDimensions())
+ ui_update_target(ui, "hover")
+end
+
+-- presets
+
+local function color(hex)
+ return { love.math.colorFromBytes(
+ bit32.band(bit32.rshift(hex, 16), 0xff),
+ bit32.band(bit32.rshift(hex, 8), 0xff),
+ bit32.band(bit32.rshift(hex, 0), 0xff)
+ ) }
+end
+
+local function ui_flex(n, ...)
+ return { flex = n, ... }
+end
+
+local function pad(dir, size, ...)
+ local p = { size = { [dir] = size } }
+ local nodes = { ... }
+ if #nodes > 1 then
+ local x = { stack = dir }
+ for i, n in ipairs(nodes) do
+ table.insert(x, n)
+ if i < #nodes then
+ table.insert(x, p)
+ end
+ end
+ return x
+ elseif #nodes == 1 then
+ return { stack = dir, p, ui_flex(1, nodes[1]), p }
+ end
+ return p
+end
+
+local function ui_pad_x(...)
+ return pad("x", ...)
+end
+
+local function ui_pad_y(...)
+ return pad("y", ...)
+end
+
+local function center_x(...)
+ return { align = { x = "center" }, ... }
+end
+
+local function center_y(...)
+ return { align = { y = "center" }, ... }
+end
+
+local function stack_x(...)
+ return { stack = "x", ... }
+end
+
+local function stack_y(...)
+ return { stack = "y", ... }
+end
+
+local function ui_text(str, size, col)
+ return {
+ color = col,
+ text = love.graphics.newText(love.graphics.newFont(size), str),
+ size = function(self) return { x = self.text:getWidth(), y = self.text:getHeight() } end,
+ }
+end
+
+local function ui_button(label, click)
+ return {
+ fill = function(self, f)
+ return f.hover and color(0x8080a0) or color(0x808080)
+ end,
+ line = color(0x505050),
+ events = { hover = true, press = true, click = click, },
+ ui_pad_y(5, center_x(ui_pad_x(10,
+ ui_text(label, 25, color(0x000000))
+ )))
+ }
+end
+
+local function ui_input(placeholder)
+ local function is_placeholder(self, focus)
+ return self.input_value == "" and not focus
+ end
+ local function update_text(self, focus)
+ self.input_text:set(is_placeholder(self, focus) and self.input_placeholder or self.input_value)
+ self.input_interact_time = love.timer.getTime()
+ end
+
+ local text = ui_text(placeholder, 25, function(self, f)
+ return is_placeholder(self.input_parent, f.focus) and color(0xb0b0b0) or color(0x000000)
+ end)
+
+ local marker = {
+ size = { x = 1, y = text.text:getHeight() },
+ fill = function(self, f)
+ return (f.focus and
+ math.floor(love.timer.getTime() - self.input_parent.input_interact_time) % 2 == 0)
+ and color(0x000000) or color(0xffffff)
+ end
+ }
+
+ -- TODO: cursor
+ -- TODO: overflow as scroll
+ -- TODO: paste icon (optional)
+
+ local elem = {
+ fill = color(0xffffff),
+ line = function(self, f)
+ return f.focus and color(0x5050ff) or color(0x505050)
+ end,
+ events = {
+ focus = function(self)
+ update_text(self, true)
+ end,
+ focus_end = function(self)
+ update_text(self, false)
+ end,
+ input = function(self, text)
+ if text == "\b" then
+ local byteoffset = utf8.offset(self.input_value, -1)
+ if byteoffset then
+ self.input_value = string.sub(self.input_value, 1, byteoffset - 1)
+ end
+ else
+ self.input_value = self.input_value .. text
+ end
+ update_text(self, true)
+ end,
+ },
+ input_value = "",
+ input_text = text.text,
+ input_placeholder = placeholder,
+ ui_pad_y(5, ui_pad_x(10, stack_x(text, marker)))
+ }
+
+ text.input_parent = elem
+ marker.input_parent = elem
+
+ return elem
+end
+
+return {
+ new = ui_new,
+ render = ui_render,
+ update = ui_update,
+ keypressed = ui_keypressed,
+ textinput = ui_textinput,
+ mousepressed = ui_mousepressed,
+ mousereleased = ui_mousereleased,
+ color = color,
+ pad_x = ui_pad_x,
+ pad_y = ui_pad_y,
+ center_x = center_x,
+ center_y = center_y,
+ stack_x = stack_x,
+ stack_y = stack_y,
+ flex = ui_flex,
+ text = ui_text,
+ button = ui_button,
+ input = ui_input,
+}