Compare commits
5 Commits
v0.2.0
...
332a37e155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332a37e155 | ||
|
|
41b28ff37d | ||
|
|
1658aebe70 | ||
|
|
9d758e28c9 | ||
|
|
942e05ba3e |
68
frontmatter.lua
Normal file
68
frontmatter.lua
Normal file
@@ -0,0 +1,68 @@
|
||||
-- frontmatter.lua — JSON-Frontmatter parser for manifest.launcher.md
|
||||
-- Splits on the first standalone '---' line. Everything before goes to
|
||||
-- depf._json_decode; everything after (after a single LF) is body.
|
||||
-- Loud-Error semantics: parse failure returns nil + err-string.
|
||||
|
||||
local M = {}
|
||||
|
||||
-- depf is required lazily so this file can be dofile'd into a test-lib
|
||||
-- context where lib-management.dep-fetcher is not on the dep list. The
|
||||
-- launcher main module does require it eagerly; the test-lib monkeys
|
||||
-- a stub decoder into _G before calling fm.parse.
|
||||
local function get_decoder()
|
||||
if _G.LAUNCHER_FRONTMATTER_DECODER then
|
||||
return _G.LAUNCHER_FRONTMATTER_DECODER
|
||||
end
|
||||
local ok, depf = pcall(require, "lib-management.dep-fetcher")
|
||||
if ok and depf and depf._json_decode then return depf._json_decode end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Splits the content at the first line equal to '---'. The first such
|
||||
-- line ends the JSON region; lines before it are JSON, lines after
|
||||
-- are body. Both regions are LF-joined back into strings.
|
||||
local function split_at_separator(content)
|
||||
local lines = {}
|
||||
for line in (content .. "\n"):gmatch("([^\n]*)\n") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
-- A valid file starts with '---' (no leading whitespace).
|
||||
if lines[1] ~= "---" then return nil end
|
||||
local sep_idx = nil
|
||||
for i = 2, #lines do
|
||||
if lines[i] == "---" then sep_idx = i; break end
|
||||
end
|
||||
if not sep_idx then return nil end
|
||||
local json_lines = {}
|
||||
for i = 2, sep_idx - 1 do json_lines[#json_lines + 1] = lines[i] end
|
||||
local body_lines = {}
|
||||
for i = sep_idx + 1, #lines do body_lines[#body_lines + 1] = lines[i] end
|
||||
-- Drop the trailing empty entry produced by the final LF appended above,
|
||||
-- so the body matches user expectation ('body text' rather than 'body text\n').
|
||||
if #body_lines > 0 and body_lines[#body_lines] == "" then
|
||||
body_lines[#body_lines] = nil
|
||||
end
|
||||
return table.concat(json_lines, "\n"), table.concat(body_lines, "\n")
|
||||
end
|
||||
|
||||
function M.parse(content)
|
||||
if type(content) ~= "string" then
|
||||
return nil, nil, "frontmatter.parse: content must be string"
|
||||
end
|
||||
local json_str, body = split_at_separator(content)
|
||||
if not json_str then
|
||||
-- No frontmatter — treat the whole content as body, empty meta.
|
||||
return {}, content
|
||||
end
|
||||
local decode = get_decoder()
|
||||
if not decode then
|
||||
return nil, nil, "frontmatter.parse: no JSON decoder available"
|
||||
end
|
||||
local meta, derr = decode(json_str)
|
||||
if not meta then
|
||||
return nil, nil, "frontmatter.parse: " .. tostring(derr)
|
||||
end
|
||||
return meta, body
|
||||
end
|
||||
|
||||
return M
|
||||
103
init.lua
103
init.lua
@@ -8,10 +8,17 @@ local MODULE_DIR = engine.module.dir_of("lib-management.launcher")
|
||||
local fsm = dofile(MODULE_DIR .. "/fsm.lua")
|
||||
local icons = dofile(MODULE_DIR .. "/icons.lua")
|
||||
|
||||
local function require_panel(name)
|
||||
return dofile(MODULE_DIR .. "/panels/" .. name .. ".lua")
|
||||
end
|
||||
|
||||
local list_panel = require_panel("list")
|
||||
local detail_panel = require_panel("detail")
|
||||
|
||||
local STATE_LIST, STATE_QUIT, EXIT = fsm.STATE_LIST, fsm.STATE_QUIT_CONFIRM, fsm.EXIT
|
||||
local state = STATE_LIST
|
||||
|
||||
local modules = {} -- per-entry: see init() below
|
||||
local ctx_panels = nil -- built in init(); shared state for both panels
|
||||
local quit_buttons = {} -- {yes = rect, no = rect}
|
||||
local screen_w, screen_h
|
||||
local gitea_base = nil -- resolved in init() from engine.config
|
||||
@@ -229,27 +236,36 @@ function init(ctx)
|
||||
git.set_token(cfg_token)
|
||||
end
|
||||
|
||||
local list = engine.module.list()
|
||||
ctx_panels = {
|
||||
-- Persistent UI context, threaded through the panels.
|
||||
module_entries = {}, -- filled below from engine.module.list
|
||||
news_entry = { title = "News", body_path = "news.md" },
|
||||
selected_entry = nil,
|
||||
scroll_y = 0,
|
||||
list_tint = nil, -- nil | "warn" | "error" — set in L.4
|
||||
list_panel_rect = nil, -- set below from window size
|
||||
detail_panel_rect = nil,
|
||||
bg_texture = nil, -- L.10 loads it
|
||||
default_teaser_texture = nil, -- L.10 loads it
|
||||
t = function(k) return k end, -- L.8 swaps in real t()
|
||||
modal_open = nil, -- L.7 sets to "quit" | "conflict"
|
||||
carousel_state = {}, -- per-module-id state, L.5 populates
|
||||
manifest_cache = {}, -- L.3 populates
|
||||
}
|
||||
|
||||
-- Uniform button width: max label + padding + reserved icon strip.
|
||||
local max_label_w = 0
|
||||
for _, m in ipairs(list) do
|
||||
local label = m.name or m.id
|
||||
local lw = engine.render.measure_text(label, FONT_BUTTON)
|
||||
if lw > max_label_w then max_label_w = lw end
|
||||
end
|
||||
local _, label_h = engine.render.measure_text("Ay", FONT_BUTTON)
|
||||
local btn_w = max_label_w + 2 * PAD_X + ICON_STRIP_W
|
||||
local btn_h = label_h + 2 * PAD_Y
|
||||
-- Window split: 1/3 list, 2/3 detail. Margin 12px between panels.
|
||||
local SW, SH = screen_w, screen_h
|
||||
ctx_panels.list_panel_rect = { x = 0, y = 0, w = SW / 3, h = SH }
|
||||
ctx_panels.detail_panel_rect = { x = SW / 3 + 12, y = 0, w = SW * 2/3 - 12, h = SH }
|
||||
|
||||
local y = 80
|
||||
for i, m in ipairs(list) do
|
||||
modules[i] = {
|
||||
local raw_list = engine.module.list() or {}
|
||||
for i, m in ipairs(raw_list) do
|
||||
ctx_panels.module_entries[i] = {
|
||||
id = m.id,
|
||||
version = m.version,
|
||||
name = m.name or m.id,
|
||||
rect = { x = (screen_w - btn_w) / 2, y = y,
|
||||
w = btn_w, h = btn_h },
|
||||
summary = nil, -- L.3 fills from manifest.launcher.md
|
||||
rect = nil,
|
||||
status = {
|
||||
update_check_handle = nil,
|
||||
update_check_result = nil,
|
||||
@@ -261,7 +277,6 @@ function init(ctx)
|
||||
local_rect = nil,
|
||||
},
|
||||
}
|
||||
y = y + btn_h + 8
|
||||
end
|
||||
|
||||
local wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON)
|
||||
@@ -279,16 +294,17 @@ function init(ctx)
|
||||
-- Kick off async update-checks (one ls-remote per module). Network
|
||||
-- calls; gated off in CI to keep milestone-check offline.
|
||||
if os.getenv("SPOREL_CI") ~= "1" then
|
||||
for _, m in ipairs(modules) do start_update_check(m) end
|
||||
for _, m in ipairs(ctx_panels.module_entries) do start_update_check(m) end
|
||||
end
|
||||
|
||||
-- Pre-emptive dep-check per module. Sync today (plan §7.6 future
|
||||
-- work: async). In a packaged install with closures already
|
||||
-- present, this is filesystem-only and fast; the slow path
|
||||
-- (missing libs → clone) only fires on first launch / new module.
|
||||
for _, m in ipairs(modules) do run_dep_check(m) end
|
||||
for _, m in ipairs(ctx_panels.module_entries) do run_dep_check(m) end
|
||||
|
||||
engine.print(string.format("launcher: init ok, modules=%d", #modules))
|
||||
engine.print(string.format("launcher: init ok, modules=%d",
|
||||
#ctx_panels.module_entries))
|
||||
end
|
||||
|
||||
function update(ctx, dt)
|
||||
@@ -299,7 +315,7 @@ function update(ctx, dt)
|
||||
end
|
||||
|
||||
-- Poll async update-check handles.
|
||||
for _, m in ipairs(modules) do
|
||||
for _, m in ipairs(ctx_panels.module_entries) do
|
||||
local h = m.status.update_check_handle
|
||||
if h and git.is_done(h) then
|
||||
consume_update_check(m)
|
||||
@@ -312,23 +328,8 @@ function update(ctx, dt)
|
||||
if input.was_action_pressed("ui_back") then
|
||||
state = fsm.next(state, "esc")
|
||||
elseif input.was_action_pressed("ui_click") then
|
||||
for _, m in ipairs(modules) do
|
||||
-- Badge first — its hit-rect lives inside the button,
|
||||
-- so without prioritising it the launch path would
|
||||
-- always win.
|
||||
if m.status.badge_rect and hit(m.status.badge_rect, mx, my) then
|
||||
apply_update(m)
|
||||
return
|
||||
elseif hit(m.rect, mx, my) then
|
||||
local r = m.status.dep_check_result
|
||||
if r and r.ok then
|
||||
engine.switch_module(m.id)
|
||||
else
|
||||
engine.print("launcher: " .. m.id
|
||||
.. " blocked by dep-check")
|
||||
end
|
||||
return
|
||||
end
|
||||
if not list_panel.handle_click(ctx_panels, mx, my) then
|
||||
detail_panel.handle_click(ctx_panels, mx, my)
|
||||
end
|
||||
end
|
||||
elseif state == STATE_QUIT then
|
||||
@@ -411,29 +412,9 @@ function render(ctx)
|
||||
local mx, my = engine.input.get_mouse_pos()
|
||||
|
||||
engine.render.clear_color(COLOR_BG)
|
||||
local tw = engine.render.measure_text(TITLE, FONT_TITLE)
|
||||
engine.render.draw_text(TITLE, (screen_w - tw) / 2, 24,
|
||||
FONT_TITLE, COLOR_TEXT)
|
||||
|
||||
if #modules == 0 then
|
||||
local ew = engine.render.measure_text(EMPTY_LABEL, FONT_BUTTON)
|
||||
engine.render.draw_text(EMPTY_LABEL, (screen_w - ew) / 2,
|
||||
screen_h / 2, FONT_BUTTON, COLOR_TEXT)
|
||||
else
|
||||
for _, m in ipairs(modules) do
|
||||
draw_module_row(m)
|
||||
end
|
||||
|
||||
-- Tooltip: detected after all rows have populated their rects.
|
||||
local tip = nil
|
||||
for _, m in ipairs(modules) do
|
||||
tip = tooltip_for(m, mx, my)
|
||||
if tip then break end
|
||||
end
|
||||
if tip then
|
||||
icons.draw_tooltip(mx, my, tip, screen_w, screen_h)
|
||||
end
|
||||
end
|
||||
list_panel.render(ctx_panels)
|
||||
detail_panel.render(ctx_panels)
|
||||
|
||||
if state == STATE_QUIT then
|
||||
engine.render.draw_rect(0, 0, screen_w, screen_h, COLOR_DIM)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "lib-management.launcher",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"kind": "module",
|
||||
"api": "^0.1",
|
||||
"ci_frames": 10,
|
||||
|
||||
230
markdown.lua
Normal file
230
markdown.lua
Normal file
@@ -0,0 +1,230 @@
|
||||
-- markdown.lua — M3-subset parser + renderer for launcher description bodies.
|
||||
-- Supports:
|
||||
-- #/##/### headers (H1=24pt, H2=20pt, H3=16pt; per L-Q3)
|
||||
-- blank-line-separated paragraphs (body=14pt)
|
||||
-- bold (**text**) and italic (*text*) inline spans
|
||||
-- inline images: 
|
||||
-- bullet lists (- item) and ordered lists (1. item)
|
||||
-- inline code (`code`)
|
||||
-- Does NOT support: tables, blockquotes, links, code blocks, headers >H3.
|
||||
|
||||
local M = {}
|
||||
|
||||
local H1_SIZE = 24
|
||||
local H2_SIZE = 20
|
||||
local H3_SIZE = 16
|
||||
local BODY_SIZE = 14
|
||||
local LINE_GAP = 4
|
||||
local PARA_GAP = 12
|
||||
local BULLET_INDENT = 18
|
||||
local COLOR_TEXT = 0xE0E0E0FF
|
||||
local COLOR_DIM = 0xA0A0A0FF
|
||||
local COLOR_CODE = 0x80C0FFFF
|
||||
|
||||
-- ---- parse ----------------------------------------------------------
|
||||
|
||||
-- Returns a flat list of block tables. Each block:
|
||||
-- {type="h1"|"h2"|"h3", text=string}
|
||||
-- {type="p", spans={...}}
|
||||
-- {type="img", src=string, alt=string}
|
||||
-- {type="bullet", spans={...}}
|
||||
-- {type="ordered", index=int, spans={...}}
|
||||
|
||||
local function parse_spans(line)
|
||||
-- Tokenize into runs of {kind, text}: kind in plain|bold|italic|code.
|
||||
local spans = {}
|
||||
local i = 1
|
||||
local n = #line
|
||||
while i <= n do
|
||||
local b, e, cap
|
||||
-- Bold first (greedier delimiter).
|
||||
b, e, cap = line:find("%*%*([^%*]+)%*%*", i)
|
||||
if b == i then
|
||||
spans[#spans + 1] = { kind = "bold", text = cap }
|
||||
i = e + 1
|
||||
else
|
||||
b, e, cap = line:find("%*([^%*]+)%*", i)
|
||||
if b == i then
|
||||
spans[#spans + 1] = { kind = "italic", text = cap }
|
||||
i = e + 1
|
||||
else
|
||||
b, e, cap = line:find("`([^`]+)`", i)
|
||||
if b == i then
|
||||
spans[#spans + 1] = { kind = "code", text = cap }
|
||||
i = e + 1
|
||||
else
|
||||
-- Plain run up to the next delimiter or end-of-string.
|
||||
local nxt = n + 1
|
||||
for _, pat in ipairs({ "%*%*", "%*", "`" }) do
|
||||
local nb = line:find(pat, i)
|
||||
if nb and nb < nxt then nxt = nb end
|
||||
end
|
||||
spans[#spans + 1] = { kind = "plain", text = line:sub(i, nxt - 1) }
|
||||
i = nxt
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return spans
|
||||
end
|
||||
|
||||
function M.parse(body)
|
||||
local blocks = {}
|
||||
if type(body) ~= "string" then return blocks end
|
||||
-- Split into lines preserving blank-line boundaries.
|
||||
local lines = {}
|
||||
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
|
||||
local i = 1
|
||||
while i <= #lines do
|
||||
local line = lines[i]
|
||||
if line:match("^%s*$") then
|
||||
i = i + 1
|
||||
else
|
||||
-- Header?
|
||||
local h3 = line:match("^### (.+)$")
|
||||
local h2 = line:match("^## (.+)$")
|
||||
local h1 = line:match("^# (.+)$")
|
||||
if h1 then
|
||||
blocks[#blocks + 1] = { type = "h1", text = h1 }
|
||||
i = i + 1
|
||||
elseif h2 then
|
||||
blocks[#blocks + 1] = { type = "h2", text = h2 }
|
||||
i = i + 1
|
||||
elseif h3 then
|
||||
blocks[#blocks + 1] = { type = "h3", text = h3 }
|
||||
i = i + 1
|
||||
else
|
||||
-- Inline image alone on a line?
|
||||
local alt, src = line:match("^!%[(.-)%]%((.-)%)%s*$")
|
||||
if alt and src then
|
||||
blocks[#blocks + 1] = { type = "img", alt = alt, src = src }
|
||||
i = i + 1
|
||||
else
|
||||
-- Bullet?
|
||||
local bullet = line:match("^%- (.+)$")
|
||||
if bullet then
|
||||
blocks[#blocks + 1] = { type = "bullet", spans = parse_spans(bullet) }
|
||||
i = i + 1
|
||||
else
|
||||
-- Ordered?
|
||||
local idx, item = line:match("^(%d+)%. (.+)$")
|
||||
if idx and item then
|
||||
blocks[#blocks + 1] = {
|
||||
type = "ordered",
|
||||
index = tonumber(idx),
|
||||
spans = parse_spans(item),
|
||||
}
|
||||
i = i + 1
|
||||
else
|
||||
-- Paragraph: concatenate consecutive non-blank, non-special lines.
|
||||
local para_lines = { line }
|
||||
local j = i + 1
|
||||
while j <= #lines do
|
||||
local nxt = lines[j]
|
||||
if nxt:match("^%s*$")
|
||||
or nxt:match("^#")
|
||||
or nxt:match("^%- ")
|
||||
or nxt:match("^%d+%. ")
|
||||
or nxt:match("^!%[") then
|
||||
break
|
||||
end
|
||||
para_lines[#para_lines + 1] = nxt
|
||||
j = j + 1
|
||||
end
|
||||
local joined = table.concat(para_lines, " ")
|
||||
blocks[#blocks + 1] = { type = "p", spans = parse_spans(joined) }
|
||||
i = j
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return blocks
|
||||
end
|
||||
|
||||
-- ---- render ---------------------------------------------------------
|
||||
|
||||
local function span_color(kind)
|
||||
if kind == "code" then return COLOR_CODE end
|
||||
return COLOR_TEXT
|
||||
end
|
||||
|
||||
local function render_spans(spans, x, y, max_w, size)
|
||||
-- Naive flow: split each span's text by spaces, wrap at max_w.
|
||||
local cur_x = x
|
||||
local cur_y = y
|
||||
local _, line_h = engine.render.measure_text("Ay", size)
|
||||
for _, sp in ipairs(spans) do
|
||||
for word in sp.text:gmatch("%S+") do
|
||||
local prefix = (cur_x == x) and "" or " "
|
||||
local seg = prefix .. word
|
||||
local w, _ = engine.render.measure_text(seg, size)
|
||||
if cur_x + w > x + max_w and cur_x > x then
|
||||
cur_y = cur_y + line_h + LINE_GAP
|
||||
cur_x = x
|
||||
seg = word
|
||||
w, _ = engine.render.measure_text(seg, size)
|
||||
end
|
||||
engine.render.draw_text(seg, cur_x, cur_y, size, span_color(sp.kind))
|
||||
cur_x = cur_x + w
|
||||
end
|
||||
end
|
||||
return cur_y + line_h
|
||||
end
|
||||
|
||||
function M.render(blocks, x, y, max_w, module_id_for_imgs)
|
||||
local cur_y = y
|
||||
for _, b in ipairs(blocks) do
|
||||
if b.type == "h1" then
|
||||
engine.render.draw_text(b.text, x, cur_y, H1_SIZE, COLOR_TEXT)
|
||||
local _, h = engine.render.measure_text(b.text, H1_SIZE)
|
||||
cur_y = cur_y + h + PARA_GAP
|
||||
elseif b.type == "h2" then
|
||||
engine.render.draw_text(b.text, x, cur_y, H2_SIZE, COLOR_TEXT)
|
||||
local _, h = engine.render.measure_text(b.text, H2_SIZE)
|
||||
cur_y = cur_y + h + PARA_GAP
|
||||
elseif b.type == "h3" then
|
||||
engine.render.draw_text(b.text, x, cur_y, H3_SIZE, COLOR_TEXT)
|
||||
local _, h = engine.render.measure_text(b.text, H3_SIZE)
|
||||
cur_y = cur_y + h + PARA_GAP
|
||||
elseif b.type == "p" then
|
||||
local ny = render_spans(b.spans, x, cur_y, max_w, BODY_SIZE)
|
||||
cur_y = ny + PARA_GAP
|
||||
elseif b.type == "bullet" then
|
||||
engine.render.draw_text("•", x, cur_y, BODY_SIZE, COLOR_TEXT)
|
||||
local ny = render_spans(b.spans, x + BULLET_INDENT, cur_y,
|
||||
max_w - BULLET_INDENT, BODY_SIZE)
|
||||
cur_y = ny + LINE_GAP
|
||||
elseif b.type == "ordered" then
|
||||
local prefix = tostring(b.index) .. "."
|
||||
engine.render.draw_text(prefix, x, cur_y, BODY_SIZE, COLOR_TEXT)
|
||||
local ny = render_spans(b.spans, x + BULLET_INDENT, cur_y,
|
||||
max_w - BULLET_INDENT, BODY_SIZE)
|
||||
cur_y = ny + LINE_GAP
|
||||
elseif b.type == "img" then
|
||||
-- module_id_for_imgs lets the launcher resolve "<rel>" inside
|
||||
-- the module's own assets tree via engine.module.load_texture.
|
||||
if module_id_for_imgs and engine.module.load_texture then
|
||||
local tex = engine.module.load_texture(module_id_for_imgs, b.src)
|
||||
if tex then
|
||||
engine.render.draw_texture(tex, x, cur_y)
|
||||
-- Texture height is not introspectable from Lua in v1;
|
||||
-- assume a 16:9 max_w-wide image for layout.
|
||||
cur_y = cur_y + math.floor(max_w * 9 / 16) + PARA_GAP
|
||||
else
|
||||
engine.render.draw_text("[image missing: " .. b.src .. "]",
|
||||
x, cur_y, BODY_SIZE, COLOR_DIM)
|
||||
local _, h = engine.render.measure_text("[image]", BODY_SIZE)
|
||||
cur_y = cur_y + h + PARA_GAP
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return cur_y
|
||||
end
|
||||
|
||||
return M
|
||||
37
panels/detail.lua
Normal file
37
panels/detail.lua
Normal file
@@ -0,0 +1,37 @@
|
||||
local M = {}
|
||||
|
||||
local PADDING = 24
|
||||
local COLOR_BG = 0x282828FF
|
||||
local COLOR_TEXT = 0xE0E0E0FF
|
||||
local COLOR_DIM = 0xA0A0A0FF
|
||||
local FONT_TITLE = 28
|
||||
local FONT_BODY = 14
|
||||
|
||||
function M.render(ctx)
|
||||
local p = ctx.detail_panel_rect
|
||||
engine.render.draw_rect(p.x, p.y, p.w, p.h, COLOR_BG)
|
||||
|
||||
if not ctx.selected_entry then
|
||||
-- Empty state: background image + hint text (L.10 ships the image).
|
||||
if ctx.bg_texture then
|
||||
engine.render.draw_texture(ctx.bg_texture, p.x, p.y)
|
||||
end
|
||||
local hint = ctx.t("empty_hint")
|
||||
local w, h = engine.render.measure_text(hint, FONT_BODY)
|
||||
engine.render.draw_text(hint,
|
||||
p.x + (p.w - w) / 2, p.y + (p.h - h) / 2,
|
||||
FONT_BODY, COLOR_DIM)
|
||||
return
|
||||
end
|
||||
|
||||
-- Stub for L.4/L.5/L.6/L.7: full content lands later.
|
||||
engine.render.draw_text(tostring(ctx.selected_entry),
|
||||
p.x + PADDING, p.y + PADDING, FONT_TITLE, COLOR_TEXT)
|
||||
end
|
||||
|
||||
function M.handle_click(ctx, mx, my)
|
||||
-- Stub: real hit-tests for launch button + carousel arrows in L.5/L.7.
|
||||
return false
|
||||
end
|
||||
|
||||
return M
|
||||
82
panels/list.lua
Normal file
82
panels/list.lua
Normal file
@@ -0,0 +1,82 @@
|
||||
local M = {}
|
||||
|
||||
local PADDING = 16
|
||||
local ROW_HEIGHT = 56
|
||||
local ROW_GAP = 4
|
||||
local COLOR_BG = 0x202020FF
|
||||
local COLOR_ROW = 0x303030FF
|
||||
local COLOR_ROW_HOVER = 0x404040FF
|
||||
local COLOR_ROW_ACTIVE = 0x505080FF
|
||||
local COLOR_TEXT = 0xE0E0E0FF
|
||||
local COLOR_DIM = 0xA0A0A0FF
|
||||
local FONT = 18
|
||||
|
||||
-- Returns the rect rect-table {x,y,w,h} of the row for entry index i.
|
||||
function M.row_rect(ctx, i)
|
||||
local p = ctx.list_panel_rect
|
||||
return {
|
||||
x = p.x + PADDING,
|
||||
y = p.y + PADDING + (i - 1) * (ROW_HEIGHT + ROW_GAP) - ctx.scroll_y,
|
||||
w = p.w - 2 * PADDING,
|
||||
h = ROW_HEIGHT,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pick the display label per spec L-Q8: summary || name || id.
|
||||
local function label_for(entry)
|
||||
return entry.summary or entry.name or entry.id
|
||||
end
|
||||
|
||||
function M.render(ctx)
|
||||
local p = ctx.list_panel_rect
|
||||
-- Apply whole-list bg tint per L-Q6 if conflicts exist.
|
||||
local bg = COLOR_BG
|
||||
if ctx.list_tint == "warn" then bg = 0x4A3A1AFF end
|
||||
if ctx.list_tint == "error" then bg = 0x4A1A1AFF end
|
||||
engine.render.draw_rect(p.x, p.y, p.w, p.h, bg)
|
||||
|
||||
-- News row pinned on top.
|
||||
if ctx.news_entry then
|
||||
local rect = {
|
||||
x = p.x + PADDING, y = p.y + PADDING - ctx.scroll_y,
|
||||
w = p.w - 2 * PADDING, h = ROW_HEIGHT,
|
||||
}
|
||||
local col = (ctx.selected_entry == "news") and COLOR_ROW_ACTIVE or COLOR_ROW
|
||||
engine.render.draw_rect(rect.x, rect.y, rect.w, rect.h, col)
|
||||
engine.render.draw_text(ctx.news_entry.title or "News",
|
||||
rect.x + PADDING, rect.y + PADDING, FONT, COLOR_TEXT)
|
||||
ctx.news_entry.rect = rect
|
||||
end
|
||||
|
||||
for i, entry in ipairs(ctx.module_entries) do
|
||||
local rect = M.row_rect(ctx, i + (ctx.news_entry and 1 or 0))
|
||||
if rect.y + rect.h >= p.y and rect.y <= p.y + p.h then
|
||||
local col = COLOR_ROW
|
||||
if ctx.selected_entry == entry.id then col = COLOR_ROW_ACTIVE end
|
||||
engine.render.draw_rect(rect.x, rect.y, rect.w, rect.h, col)
|
||||
engine.render.draw_text(label_for(entry),
|
||||
rect.x + PADDING, rect.y + PADDING, FONT, COLOR_TEXT)
|
||||
end
|
||||
entry.rect = rect
|
||||
end
|
||||
end
|
||||
|
||||
function M.handle_click(ctx, mx, my)
|
||||
if ctx.news_entry and engine.spatial.aabb_contains_point(ctx.news_entry.rect, mx, my) then
|
||||
ctx.selected_entry = "news"
|
||||
return true
|
||||
end
|
||||
for _, entry in ipairs(ctx.module_entries) do
|
||||
if entry.rect and engine.spatial.aabb_contains_point(entry.rect, mx, my) then
|
||||
ctx.selected_entry = entry.id
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function M.handle_scroll(ctx, dy)
|
||||
ctx.scroll_y = math.max(0, ctx.scroll_y - dy * 24)
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user