Compare commits

..

2 Commits

Author SHA1 Message Date
Calic
332a37e155 feat(launcher): wire panels into master-detail layout
Replaces the centered-button list with a 1/3 list + 2/3 detail split
driven by panels/list.lua and panels/detail.lua. The shared ctx_panels
table threads UI state (selected_entry, scroll_y, modal_open, etc.)
through both panels. Existing dep + update checks remain on the module
entries; FSM and ESC-quit flow unchanged. Detail-panel content is still
a stub until L.4-L.7.
2026-06-11 01:44:29 +02:00
Calic
41b28ff37d feat(launcher): add list + detail panel stubs (master-detail L.2)
Splits rendering into panels/list.lua and panels/detail.lua. List
implements row hit-test, scroll-state, whole-list bg-tint hook for the
dep-conflict UX (L-Q6). Detail renders an empty-state placeholder
until L.4-L.7 fill in title, description, deps, carousel, and launch
button.
2026-06-11 01:41:11 +02:00
4 changed files with 162 additions and 62 deletions

103
init.lua
View File

@@ -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)

View File

@@ -1,6 +1,6 @@
{
"id": "lib-management.launcher",
"version": "0.2.0",
"version": "0.3.0",
"kind": "module",
"api": "^0.1",
"ci_frames": 10,

37
panels/detail.lua Normal file
View 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
View 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