15 Commits

Author SHA1 Message Date
Calic
2595759b3e feat(launcher): ship default brand assets
Solid-fill placeholders for the empty-state background (1920×1080) and
the zero-teaser hero fallback (800×450). Brand-refresh slice can drop
both files in without touching code. Loaded once on init via
engine.asset.load_texture (launcher's own sandbox).
2026-06-11 08:10:01 +02:00
Calic
fa558adfeb feat: ship manifest.launcher.md for master-detail launcher 2026-06-11 07:58:05 +02:00
Calic
29c6c409e0 fix(launcher): route dep-status glyphs through t() indirection
The status_glyph helper in detail.lua returned 'OK'/'WARN'/'ERR'/'?'
literals - user-visible UI text that the Mid-Gate-B grep missed
because the pattern required a lowercase suffix. Now lives in
strings.lua alongside the other UI keys, so v2 localization-lib will
pick it up without touching detail.lua.
2026-06-11 07:54:53 +02:00
Calic
a19dd7f1dd feat(launcher): route all UI strings through t(key) indirection
strings.lua holds English defaults. ctx.t(key, ...) looks up the key,
optionally string.formats with extra args, and falls back to '[key]'
for missing entries. All panels and init.lua sweep clean of inline
literals — verified by a grep over '"[A-Z][a-z][a-z ]+"'. This sets
up v2's localization-lib-pattern (separate slice, ADR-0036 trigger)
to swap STRINGS without touching the launcher code.
2026-06-11 07:49:38 +02:00
Calic
a49aa3ef2b fix(launcher): wire FSM dispatch + correct conflict-count
The conflict-modal path bypassed the FSM extension landed in L.7:
panels/detail.lua set ctx.modal_open directly and init.lua drove
modal handling off the side-channel field, so STATE_MODAL_CONFLICT
was unreachable at runtime (only the test-lib asserts exercised it).
Now detail.lua marks ctx._pending_modal_open and init.lua dispatches
the open_conflict event so state actually transitions through the
fsm. Modal entry/exit is gated on state == STATE_MODAL_CONFLICT.

Also fixes the conflict body line: r.missing doesn't exist on the
dep-fetcher result; use #(errors) + #(conflicts) for the count
displayed in the modal body.
2026-06-11 03:10:56 +02:00
Calic
89b7b060d1 feat(launcher): launch button + dep-conflict modal
Detail-panel bottom-right Launch button: switches to the selected
module via engine.switch_module if dep-check is clean, otherwise
opens a modal listing conflicts with Cancel + 'Launch anyway'. The
latter is greyed out unless engine.module.switch_module_supports_fallback
is exposed (separate engine slice). FSM adds STATE_MODAL_CONFLICT
with transitions to/from STATE_LIST via cancel/launch_anyway/esc.

Generic panels/modal.lua takes a def-table (title/body/buttons) and
returns the clicked button id from handle_click; reusable for future
modals beyond the conflict case.
2026-06-11 02:54:55 +02:00
Calic
10d0ab6529 feat(launcher): render news.md in detail panel for news entry
Static 'coming soon' stub authored as Markdown with JSON-frontmatter
title. Loaded once on init, parsed via the same fm + md helpers as
modules. Live news (Gitea-API or RSS) deferred — single-source-of-
truth in the launcher repo keeps update-pace under our control.
2026-06-11 02:39:01 +02:00
Calic
94b55c42f2 feat(launcher): hero carousel with auto-rotate + manual nav
panels/carousel.lua holds per-module state (index, timer, manual pause,
texture cache). Auto-rotate at carousel_interval_seconds (5s default,
frontmatter-overrideable). Click on overlay arrows steps and pauses
auto-rotate for 30s. Max 7 indicator dots, then 'X / N' overlay (L-Q4).
Single-teaser hides the arrows and dots. Zero-teaser falls through to
ctx.default_teaser_texture which L.10 ships.
2026-06-11 02:26:09 +02:00
Calic
72fa4d3135 fix(launcher): right-align dep-status glyph in fixed gutter
ASCII fallback glyphs ('OK', 'WARN', 'ERR') have variable width; the
previous 40px column gap was too narrow for 'WARN', causing the id
column to wobble between modules. Widens the gutter to 48px and
right-aligns the glyph inside it so the id text always starts at a
predictable x.
2026-06-11 02:15:47 +02:00
Calic
0a96b009f1 feat(launcher): render detail panel content (title/desc/deps)
panels/detail.lua now renders the selected module's title, version,
author, markdown description, and per-dep status list with OK/WARN/ERR
glyphs. Dep-list order follows manifest.module::deps[] (L-Q6 load-
order, no re-sort). Whole-list bg-tint is bubbled to ctx.list_tint so
the list panel picks up a yellow/red ambient cue for warn/error
states. Hero strip remains a placeholder until L.5.
2026-06-11 02:09:04 +02:00
Calic
b360ee6230 refactor(launcher): memoize markdown.lua in manifest_loader ensure_deps
Matches the existing frontmatter.lua memoization pattern. Avoids
re-dofile'ing markdown.lua per successful load_for call (matters
once L.9 rolls out manifest.launcher.md to every module and the
init-time call count goes from 1 to N).
2026-06-11 02:03:19 +02:00
Calic
d63dd91bb6 feat(launcher): cache manifest.launcher.md per module on init
manifest_loader.load_for(id) reads <install>/modules/<id>/
manifest.launcher.md via engine.module.read_file, runs the JSON
frontmatter through frontmatter.parse, and pre-parses the body via
markdown.parse. Cached on the shared ctx so the detail-panel render
loop does not pay the parse cost per frame. Missing files degrade to
{_missing=true} and the list falls back to the module's name/id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 01:58:38 +02:00
Calic
f2b2a8859b refactor(launcher): remove dead button-list helpers after panel refactor
draw_module_row, tooltip_for, and apply_update were the v0.2.0
button-list rendering helpers. The master-detail panel refactor in
41b28ff moved rendering into panels/list.lua + panels/detail.lua,
leaving these three functions with no call sites. Also drops the
now-unused icons import and ICON_GAP/ICON_STRIP_W constants that only
the deleted helpers referenced; FONT_BUTTON, COLOR_DIM and COLOR_MODAL
stay because the quit-confirm modal still uses them.

Verified by grep that none of the removed symbols have remaining
references in init.lua or sibling files. CI boot is clean:
init ok, modules=6 + BOOTING -> RUNNING -> SHUTTING_DOWN, no FATAL.

init.lua drops 443 -> 302 lines; function count drops 14 -> 11.
2026-06-11 01:52:51 +02:00
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
13 changed files with 747 additions and 231 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

28
fsm.lua
View File

@@ -1,23 +1,23 @@
-- Sporel Launcher — pure-Lua finite state machine.
-- No engine.* calls; testable directly by lib-management.launcher-test.
local M = {}
M.STATE_LIST = "list"
M.STATE_QUIT_CONFIRM = "quit_confirm"
M.EXIT = "exit" -- sentinel: caller should engine.exit(0)
M.STATE_LIST = "list"
M.STATE_QUIT_CONFIRM = "quit_confirm"
M.STATE_MODAL_CONFLICT = "modal_conflict"
M.EXIT = "exit"
-- Events: "esc", "enter", "click_yes", "click_no", "click_module"
function M.next(state, event)
if state == M.STATE_LIST then
if event == "esc" then return M.STATE_QUIT_CONFIRM end
return M.STATE_LIST
if event == "esc" then return M.STATE_QUIT_CONFIRM end
if event == "open_conflict" then return M.STATE_MODAL_CONFLICT end
elseif state == M.STATE_QUIT_CONFIRM then
if event == "esc" then return M.STATE_LIST end
if event == "enter" then return M.EXIT end
if event == "click_yes" then return M.EXIT end
if event == "click_no" then return M.STATE_LIST end
return M.STATE_QUIT_CONFIRM
if event == "esc" then return M.STATE_LIST end
if event == "enter" then return M.EXIT end
if event == "click_yes" then return M.EXIT end
if event == "click_no" then return M.STATE_LIST end
elseif state == M.STATE_MODAL_CONFLICT then
if event == "esc" then return M.STATE_LIST end
if event == "cancel" then return M.STATE_LIST end
if event == "launch_anyway" then return M.STATE_LIST end -- side-effect
end
return state
end

425
init.lua
View File

@@ -1,17 +1,26 @@
local input = require("lib-core.input")
local git = require("lib-core.git")
local depf = require("lib-management.dep-fetcher")
-- fsm.lua + icons.lua live in this module's dir; engine_lua_lib's searcher
-- only resolves declared lib-deps, so multi-file modules use dofile +
-- fsm.lua lives in this module's dir; engine_lua_lib's searcher only
-- resolves declared lib-deps, so multi-file modules use dofile +
-- engine.module.dir_of.
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 fsm = dofile(MODULE_DIR .. "/fsm.lua")
local manifest_loader = dofile(MODULE_DIR .. "/manifest_loader.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 carousel = require_panel("carousel")
local modal_panel = require_panel("modal")
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
@@ -21,18 +30,15 @@ local gitea_base = nil -- resolved in init() from engine.config
local frames = 0
local CI_FRAMES = 60
local TITLE = "Sporel Launcher"
local QUIT_PROMPT = "Sporel beenden?"
local YES_LABEL = "Ja"
local NO_LABEL = "Nein"
local EMPTY_LABEL = "Keine Module installiert."
-- L.8: All UI-facing strings now live in strings.lua and are accessed
-- via ctx_panels.t(key, ...). The previous TITLE / QUIT_PROMPT /
-- YES_LABEL / NO_LABEL / EMPTY_LABEL locals were retired in this slice.
local STRINGS = dofile(MODULE_DIR .. "/strings.lua")
local FONT_TITLE = 28
local FONT_BUTTON = 20
local PAD_X = 16
local PAD_Y = 8
local ICON_GAP = 4
local ICON_STRIP_W = 140 -- reserved right-edge area inside each button
local COLOR_BG = 0x202020FF
local COLOR_BTN = 0x404040FF
@@ -118,98 +124,57 @@ local function consume_update_check(m)
end
end
-- Composes a per-dep status list keyed by lib_id from the dep-fetcher
-- result's `errors` and `warnings` arrays. Each input dep entry is
-- `{id, version}` (from manifest.module::deps[]); each output entry
-- adds `status = "ok" | "warn" | "error"`. Load-order preserved
-- (L-Q6: no re-sort).
local function compose_dep_status_list(result, module_deps)
local out = {}
local err_ids, warn_ids = {}, {}
for _, e in ipairs(result.errors or {}) do
if e.lib_id then err_ids[e.lib_id] = true end
end
for _, w in ipairs(result.warnings or {}) do
if w.lib_id then warn_ids[w.lib_id] = true end
end
for _, c in ipairs(result.conflicts or {}) do
if c.lib_id then err_ids[c.lib_id] = true end
end
for _, d in ipairs(module_deps) do
local status = "ok"
if err_ids[d.id] then status = "error"
elseif warn_ids[d.id] then status = "warn"
end
out[#out + 1] = { id = d.id, version = d.version, status = status }
end
return out
end
-- Derives a roll-up kind ("ok" | "warn" | "error") from the dep-fetcher
-- result. Drives the list-panel's bg-tint cue via ctx.list_tint.
local function derive_result_kind(result)
if not result.ok then return "error" end
if (result.warnings and #result.warnings > 0) then return "warn" end
return "ok"
end
-- Lazy: only re-check dep-fetcher when the consumer flow asks for it
-- (init + after-update). Heavy-lift in init() is acceptable per the
-- plan; an async dep-check is future work.
local function run_dep_check(m)
local ok, result = pcall(depf.ensure_for_module, m.id)
if ok and type(result) == "table" then
m.status.dep_check_result = result
else
m.status.dep_check_result = {
if not (ok and type(result) == "table") then
result = {
ok = false, conflicts = {}, warnings = {},
errors = {{ lib_id = "<launcher>", kind = "internal",
message = tostring(result) }},
closure = {},
}
end
end
-- Build the tooltip text for whichever icon the mouse is over.
-- Returns nil when no icon is hovered.
local function tooltip_for(m, mx, my)
local s = m.status
if s.error_rect and hit(s.error_rect, mx, my) then
local r = s.dep_check_result
if r then
if #r.conflicts > 0 then
local c = r.conflicts[1]
return "Pin-Konflikt: " .. c.lib_id
.. " (" .. tostring(#c.pins) .. " Versionen)"
elseif #r.errors > 0 then
local e = r.errors[1]
return "Fehler: " .. e.lib_id .. "" .. (e.message or e.kind)
end
end
return "Modul nicht startbereit"
end
if s.badge_rect and hit(s.badge_rect, mx, my) then
return "Update verfuegbar: " .. (s.update_latest_tag or "?")
.. " — klicken zum Aktualisieren"
end
if s.warn_rect and hit(s.warn_rect, mx, my) then
if s.update_check_result == "fail" then
local k = s.update_check_fail_kind
if k == "auth-required" then
return "Privat — SPOREL_GITEA_TOKEN setzen fuer Update-Check"
end
return "Updates konnten nicht geprueft werden (" .. (k or "fail") .. ")"
end
local r = s.dep_check_result
if r and #r.warnings > 0 then
local w = r.warnings[1]
return "Dirty: " .. w.lib_id .. " (" .. (w.kind or "warn") .. ")"
end
return "Warnung"
end
if s.local_rect and hit(s.local_rect, mx, my) then
return "Nur lokal vorhanden (kein Server-Repo)"
end
if s.spinner_rect and hit(s.spinner_rect, mx, my) then
return "Pruefe auf Updates..."
end
return nil
end
-- Update flow: fetch + checkout + dep-recheck + kick fresh update-check.
local function apply_update(m)
local url = module_repo_url(m.id)
local module_dir = engine.install_root() .. "/modules/" .. m.id
if git.is_repo(module_dir) then
local ok1, err1 = git.fetch(url, module_dir)
if ok1 then
local ok2, err2 = git.checkout(module_dir,
m.status.update_latest_tag)
if ok2 then
refresh_module_version(m)
else
engine.print("launcher: checkout failed for " .. m.id
.. ": " .. tostring(err2))
end
else
engine.print("launcher: fetch failed for " .. m.id
.. ": " .. tostring(err1))
end
else
-- Packaged install without .git (cmake --install excludes it).
-- Clone-into-temp-and-swap is slice-8 bundle work; for now
-- surface a clear log message and let the dep-check error path
-- show the user a red icon on next interaction.
engine.print("launcher: module " .. m.id
.. " has no .git — update flow requires a dev install")
end
run_dep_check(m)
start_update_check(m)
result.deps = compose_dep_status_list(result, m._raw_deps or {})
result.kind = derive_result_kind(result)
m.status.dep_check_result = result
end
-- ----- lifecycle -----------------------------------------------------
@@ -229,27 +194,55 @@ 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 = nil, 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, -- assigned below from engine.asset.load_texture
default_teaser_texture = nil, -- assigned below from engine.asset.load_texture
t = nil, -- L.8: real implementation installed below
carousel_state = {}, -- per-module-id state, L.5 populates
manifest_cache = {}, -- L.3 populates
launcher_dir = MODULE_DIR, -- L.4: detail.lua dofiles markdown.lua via this
}
-- 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
-- L.10: launcher-owned brand assets. Solid-fill placeholders today;
-- a brand-refresh slice can drop new PNGs in without touching code.
-- Loaded via engine.asset (the launcher's own sandbox-rooted loader),
-- not engine.module.load_texture — assets/launcher/ lives inside the
-- launcher's module dir.
ctx_panels.bg_texture = engine.asset.load_texture("assets/launcher/background.png")
ctx_panels.default_teaser_texture = engine.asset.load_texture("assets/launcher/default_teaser.png")
-- L.8: real string indirection. Looks up `key` in strings.lua, falls
-- back to "[key]" for missing entries, and string.formats with any
-- varargs so callers can pass format-args inline (e.g. counts).
ctx_panels.t = function(key, ...)
local s = STRINGS[key]
if not s then return "[" .. tostring(key) .. "]" end
if select("#", ...) > 0 then return string.format(s, ...) end
return s
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
ctx_panels.news_entry.title = ctx_panels.t("news_default_title")
local y = 80
for i, m in ipairs(list) do
modules[i] = {
-- 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 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,11 +254,60 @@ function init(ctx)
local_rect = nil,
},
}
y = y + btn_h + 8
end
local wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON)
local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON)
-- L.3: load + cache manifest.launcher.md for each module. Missing
-- files degrade gracefully (meta._missing=true) so the list-panel
-- falls back to name/id.
for _, entry in ipairs(ctx_panels.module_entries) do
local meta, blocks = manifest_loader.load_for(entry.id)
ctx_panels.manifest_cache[entry.id] = { meta = meta, blocks = blocks }
entry.summary = meta.summary
-- L.4: also surface manifest.module::deps[] verbatim so the
-- detail-panel can render per-dep status glyphs in load order
-- (no re-sort, per L-Q6).
local raw, _ = engine.module.read_file(entry.id, "manifest.module")
if raw then
local data = depf._json_decode(raw)
if data and data.deps then entry._raw_deps = data.deps end
end
end
-- L.5: per-module carousel state. Reads teaser_images +
-- carousel_interval_seconds from the cached manifest.launcher.md
-- meta. Modules without teasers still get an empty-state for the
-- detail panel to render the fallback texture.
ctx_panels.carousel_mod = carousel
for _, entry in ipairs(ctx_panels.module_entries) do
local mc = ctx_panels.manifest_cache[entry.id]
local paths = (mc and mc.meta and mc.meta.teaser_images) or {}
local interval = (mc and mc.meta and mc.meta.carousel_interval_seconds) or 5.0
ctx_panels.carousel_state[entry.id] = carousel.new_state(entry.id, paths, interval)
end
-- L.6: load + parse news.md once. The detail-panel's news branch
-- reads ctx.news_body_blocks; nil-safe so a missing file still
-- shows the news entry with an empty body.
do
local news_path = MODULE_DIR .. "/news.md"
local f = io.open(news_path, "rb")
if f then
local content = f:read("*a")
f:close()
local fm = dofile(MODULE_DIR .. "/frontmatter.lua")
local md = dofile(MODULE_DIR .. "/markdown.lua")
local meta, body = fm.parse(content)
if meta then
ctx_panels.news_entry.title = meta.title
or ctx_panels.t("news_default_title")
ctx_panels.news_body_blocks = md.parse(body)
end
end
end
local wy, hy = engine.render.measure_text(ctx_panels.t("yes"), FONT_BUTTON)
local wn, hn = engine.render.measure_text(ctx_panels.t("no"), FONT_BUTTON)
local qbtn_w = math.max(wy, wn) + 2 * PAD_X
local qbtn_h = math.max(hy, hn) + 2 * PAD_Y
local total_w = 2 * qbtn_w + 24
@@ -279,16 +321,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,36 +342,61 @@ 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)
end
end
-- L.5: pump only the selected module's carousel (perf — no point
-- advancing offscreen rotation timers).
if ctx_panels.selected_entry and ctx_panels.selected_entry ~= "news" then
local s = ctx_panels.carousel_state[ctx_panels.selected_entry]
if s then ctx_panels.carousel_mod.update(s, dt) end
end
local mx, my = engine.input.get_mouse_pos()
-- C1: detail.lua's launch-button branch signals a conflict via
-- ctx._pending_modal_open; dispatch the FSM event here so state
-- actually transitions into STATE_MODAL_CONFLICT before the modal
-- branch below runs.
if ctx_panels._pending_modal_open == "conflict" then
state = fsm.next(state, "open_conflict")
ctx_panels._pending_modal_open = nil
end
-- L.7: Conflict-modal takes priority over STATE_LIST input. esc /
-- cancel close the modal back to STATE_LIST; launch-anyway stays
-- a no-op until engine.module.switch_module_supports_fallback ships.
if state == fsm.STATE_MODAL_CONFLICT then
if input.was_action_pressed("ui_back") then
state = fsm.next(state, "esc")
ctx_panels.modal_def = nil
elseif input.was_action_pressed("ui_click") then
local id = modal_panel.handle_click(ctx_panels.modal_def, mx, my)
if id == "cancel" then
state = fsm.next(state, "cancel")
ctx_panels.modal_def = nil
elseif id == "launch_anyway" then
local mid = ctx_panels.modal_def.module_id
state = fsm.next(state, "launch_anyway")
ctx_panels.modal_def = nil
if engine.module.switch_module_supports_fallback then
engine.switch_module(mid, { mode = "fallback" })
end
end
end
return
end
if state == STATE_LIST then
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
@@ -346,93 +414,17 @@ function update(ctx, dt)
end
end
local function draw_module_row(m)
local mx, my = engine.input.get_mouse_pos()
local color = hit(m.rect, mx, my) and COLOR_HOVER or COLOR_BTN
engine.render.draw_rect(m.rect.x, m.rect.y, m.rect.w, m.rect.h, color)
-- Label left-aligned with PAD_X margin (room reserved on right
-- for the icon strip).
engine.render.draw_text(m.name, m.rect.x + PAD_X,
m.rect.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)
-- Icon strip: right-edge anchored, laid out right-to-left so the
-- rightmost icon's x stays stable across state changes.
local row_y = m.rect.y + (m.rect.h - icons.ICON_H) / 2
local right = m.rect.x + m.rect.w - PAD_X - icons.ICON_W
-- Reset rects each frame so stale hit-areas don't linger when a
-- status icon disappears.
m.status.spinner_rect = nil
m.status.warn_rect = nil
m.status.badge_rect = nil
m.status.error_rect = nil
m.status.local_rect = nil
local dep = m.status.dep_check_result
local uchk = m.status.update_check_result
-- 1. Error icon (rightmost) — conflict or error from dep-check.
if dep and (not dep.ok) then
m.status.error_rect = icons.draw_error(right, row_y)
right = right - icons.ICON_W - ICON_GAP
end
-- 2. Update badge — variable width; subtract its returned width.
if uchk == "available" and m.status.update_latest_tag then
local r = icons.draw_update_badge(
right - 40, row_y, m.status.update_latest_tag)
m.status.badge_rect = r
right = r.x - ICON_GAP - icons.ICON_W
end
-- 3. Warn icon — update-check failed (auth/network) or dirty deps.
-- Local-only is NOT a warn — it's an informational state below.
local warn_needed = (uchk == "fail")
or (dep and dep.ok and dep.warnings and #dep.warnings > 0)
if warn_needed then
m.status.warn_rect = icons.draw_warn(right, row_y)
right = right - icons.ICON_W - ICON_GAP
end
-- 3b. Local-only marker — repo not on server (404 even with creds).
if uchk == "local-only" then
m.status.local_rect = icons.draw_local(right, row_y)
right = right - icons.ICON_W - ICON_GAP
end
-- 4. Spinner — leftmost in the strip, still-pending update-check.
if uchk == "pending" then
m.status.spinner_rect = icons.draw_spinner(right, row_y, frames)
end
end
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
list_panel.render(ctx_panels)
detail_panel.render(ctx_panels)
-- 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
-- L.7: Conflict modal renders above the panels (backdrop dims them).
if state == fsm.STATE_MODAL_CONFLICT then
modal_panel.render(ctx_panels.modal_def, screen_w, screen_h)
end
if state == STATE_QUIT then
@@ -441,15 +433,16 @@ function render(ctx)
engine.render.draw_rect((screen_w - modal_w) / 2,
(screen_h - modal_h) / 2,
modal_w, modal_h, COLOR_MODAL)
local pw = engine.render.measure_text(QUIT_PROMPT, FONT_BUTTON)
engine.render.draw_text(QUIT_PROMPT, (screen_w - pw) / 2,
local prompt = ctx_panels.t("quit_prompt")
local pw = engine.render.measure_text(prompt, FONT_BUTTON)
engine.render.draw_text(prompt, (screen_w - pw) / 2,
screen_h / 2 - 24, FONT_BUTTON, COLOR_TEXT)
for _, key in ipairs({ "yes", "no" }) do
local b = quit_buttons[key]
local color = hit(b, mx, my) and COLOR_HOVER or COLOR_BTN
engine.render.draw_rect(b.x, b.y, b.w, b.h, color)
local label = (key == "yes") and YES_LABEL or NO_LABEL
local label = ctx_panels.t(key)
local lw = engine.render.measure_text(label, FONT_BUTTON)
engine.render.draw_text(label, b.x + (b.w - lw) / 2,
b.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)

19
manifest.launcher.md Normal file
View File

@@ -0,0 +1,19 @@
---
{"summary":"Sporel Launcher — module browser","author":"calic","tags":["host","launcher"],"teaser_images":[]}
---
# Sporel Launcher
Master-detail module browser. Lists every installed module on the
left, shows per-module hero, description, dependencies, and a Launch
button on the right.
## How to use
- Click a module to see its details.
- Click Launch Module to start it.
- ESC returns to this launcher from any module.
## What's next
See the News entry for in-progress + planned features.

View File

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

33
manifest_loader.lua Normal file
View File

@@ -0,0 +1,33 @@
local M = {}
local fm, md
local function ensure_deps()
if not fm then
fm = dofile(engine.module.dir_of("lib-management.launcher") .. "/frontmatter.lua")
end
if not md then
md = dofile(engine.module.dir_of("lib-management.launcher") .. "/markdown.lua")
end
end
-- Returns:
-- meta = { summary, description_md, author, tags, teaser_images,
-- carousel_interval_seconds, ... }
-- body_blocks = output of markdown.parse(body) — pre-parsed for cache reuse
-- On any failure (file missing, parse error): meta = {}, body_blocks = {}.
function M.load_for(module_id)
ensure_deps()
local content, err = engine.module.read_file(module_id, "manifest.launcher.md")
if not content then
return { _missing = true, _err = err }, {}
end
local meta, body, perr = fm.parse(content)
if not meta then
return { _missing = false, _err = perr }, {}
end
local blocks = md.parse(body)
return meta, blocks
end
return M

22
news.md Normal file
View File

@@ -0,0 +1,22 @@
---
{"title":"News","published":"2026-06-11"}
---
# Sporel News
## Coming soon
Live news will eventually appear here — release notes, mod-of-the-week,
known-issue advisories. For now this is a static stub.
## What works today
- Master-detail launcher with per-module description + dep-status
- Hero carousel with auto-rotate
- Dep-conflict ambient-tint + launch-modal
## What we are working on next
- Live news source (Gitea-API or RSS-feed)
- Module-categories filter (Game / Tool / Test)
- Last-played persistence

108
panels/carousel.lua Normal file
View File

@@ -0,0 +1,108 @@
local M = {}
local DEFAULT_INTERVAL_S = 5.0
local MANUAL_PAUSE_S = 30.0
local MAX_DOTS = 7
local ARROW_W = 32
local DOT_R = 4
local DOT_GAP = 6
local COLOR_ARROW_BG = 0x00000080
local COLOR_ARROW_FG = 0xE0E0E0FF
local COLOR_DOT = 0xC0C0C060
local COLOR_DOT_ACTIVE = 0xFFFFFFFF
local COLOR_OVERLAY = 0xE0E0E0FF
local COLOR_FALLBACK_BG = 0x303030FF
function M.new_state(module_id, teaser_paths, interval)
return {
module_id = module_id,
teaser_paths = teaser_paths or {},
textures = {}, -- index -> texture or nil if load-failed
loaded = {}, -- index -> true once loaded
index = 1,
timer = 0,
interval = interval or DEFAULT_INTERVAL_S,
manual_pause = 0,
left_rect = nil,
right_rect = nil,
}
end
local function ensure_texture(s, i, default_texture)
if s.loaded[i] then return s.textures[i] end
s.loaded[i] = true
local path = s.teaser_paths[i]
if not path then s.textures[i] = default_texture; return default_texture end
local tex, err = engine.module.load_texture(s.module_id, path)
s.textures[i] = tex or default_texture
return s.textures[i]
end
function M.update(s, dt)
if #s.teaser_paths < 2 then return end
if s.manual_pause > 0 then
s.manual_pause = s.manual_pause - dt
return
end
s.timer = s.timer + dt
if s.timer >= s.interval then
s.timer = 0
s.index = s.index + 1
if s.index > #s.teaser_paths then s.index = 1 end
end
end
function M.render(s, x, y, w, h, default_texture)
-- Background (clipping is up to the parent panel — we just clip-render).
engine.render.draw_rect(x, y, w, h, COLOR_FALLBACK_BG)
local total = math.max(1, #s.teaser_paths)
local tex = ensure_texture(s, s.index, default_texture) or default_texture
if tex then engine.render.draw_texture(tex, x, y) end
if total > 1 then
-- Arrows.
s.left_rect = { x = x, y = y + h / 2 - 16, w = ARROW_W, h = 32 }
s.right_rect = { x = x + w - ARROW_W, y = y + h / 2 - 16, w = ARROW_W, h = 32 }
engine.render.draw_rect(s.left_rect.x, s.left_rect.y, ARROW_W, 32, COLOR_ARROW_BG)
engine.render.draw_rect(s.right_rect.x, s.right_rect.y, ARROW_W, 32, COLOR_ARROW_BG)
engine.render.draw_text("<", s.left_rect.x + 10, s.left_rect.y + 6, 20, COLOR_ARROW_FG)
engine.render.draw_text(">", s.right_rect.x + 10, s.right_rect.y + 6, 20, COLOR_ARROW_FG)
-- Dots.
local dot_count = math.min(total, MAX_DOTS)
local dots_w = dot_count * (DOT_R * 2) + (dot_count - 1) * DOT_GAP
local dot_x = x + (w - dots_w) / 2
local dot_y = y + h - 16
for i = 1, dot_count do
local col = (i == s.index) and COLOR_DOT_ACTIVE or COLOR_DOT
engine.render.draw_rect(dot_x, dot_y, DOT_R * 2, DOT_R * 2, col)
dot_x = dot_x + DOT_R * 2 + DOT_GAP
end
if total > MAX_DOTS then
local label = string.format("%d / %d", s.index, total)
engine.render.draw_text(label, x + w - 80, dot_y - 4, 14, COLOR_OVERLAY)
end
end
end
function M.handle_click(s, mx, my)
if not s or not s.left_rect then return false end
if engine.spatial.aabb_contains_point(s.left_rect, mx, my) then
s.index = s.index - 1
if s.index < 1 then s.index = #s.teaser_paths end
s.timer = 0
s.manual_pause = MANUAL_PAUSE_S
return true
end
if engine.spatial.aabb_contains_point(s.right_rect, mx, my) then
s.index = s.index + 1
if s.index > #s.teaser_paths then s.index = 1 end
s.timer = 0
s.manual_pause = MANUAL_PAUSE_S
return true
end
return false
end
return M

176
panels/detail.lua Normal file
View File

@@ -0,0 +1,176 @@
local M = {}
local PADDING = 24
local COLOR_BG = 0x282828FF
local COLOR_TEXT = 0xE0E0E0FF
local COLOR_DIM = 0xA0A0A0FF
local COLOR_OK = 0x80C080FF
local COLOR_WARN = 0xE0C040FF
local COLOR_ERR = 0xE05040FF
local FONT_TITLE = 28
local FONT_SUB = 16
local FONT_BODY = 14
local FONT_DEP = 14
local md -- lazy-loaded markdown renderer
local function ensure_md(launcher_dir)
if not md then md = dofile(launcher_dir .. "/markdown.lua") end
end
local function status_glyph(ctx, dep_status)
if dep_status == "ok" then return ctx.t("dep_glyph_ok"), COLOR_OK end
if dep_status == "warn" then return ctx.t("dep_glyph_warn"), COLOR_WARN end
if dep_status == "error" then return ctx.t("dep_glyph_err"), COLOR_ERR end
return ctx.t("dep_glyph_unknown"), COLOR_DIM
end
local function find_entry(ctx, id)
if id == "news" then return nil end
for _, e in ipairs(ctx.module_entries) do
if e.id == id then return e end
end
return nil
end
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
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
-- News: render news.md.
if ctx.selected_entry == "news" then
ensure_md(ctx.launcher_dir)
local body = ctx.news_body_blocks or {}
md.render(body, p.x + PADDING, p.y + PADDING, p.w - 2 * PADDING, nil)
return
end
local entry = find_entry(ctx, ctx.selected_entry)
if not entry then return end
local mc = ctx.manifest_cache[entry.id] or { meta = {}, blocks = {} }
-- Hero carousel (L.5). Renders bg + texture + arrows + dots.
-- Zero-teaser modules render the default_teaser_texture (L.10)
-- on top of the COLOR_FALLBACK_BG strip.
local hero_h = 160
local s = ctx.carousel_state[entry.id]
if s and ctx.carousel_mod then
ctx.carousel_mod.render(s, p.x, p.y, p.w, hero_h, ctx.default_teaser_texture)
else
engine.render.draw_rect(p.x, p.y, p.w, hero_h, 0x404040FF)
end
local cur_y = p.y + hero_h + PADDING
-- Title row.
local title = entry.summary or entry.name or entry.id
engine.render.draw_text(title, p.x + PADDING, cur_y, FONT_TITLE, COLOR_TEXT)
local _, title_h = engine.render.measure_text(title, FONT_TITLE)
-- Version + author right-aligned.
local sub = string.format("v%s %s", entry.version, mc.meta.author or "")
local sub_w, _ = engine.render.measure_text(sub, FONT_SUB)
engine.render.draw_text(sub, p.x + p.w - PADDING - sub_w, cur_y + 6,
FONT_SUB, COLOR_DIM)
cur_y = cur_y + title_h + 12
-- Description (rendered MD).
ensure_md(ctx.launcher_dir)
cur_y = md.render(mc.blocks, p.x + PADDING, cur_y,
p.w - 2 * PADDING, entry.id)
cur_y = cur_y + 12
-- Dep list — load-order from manifest.module per L-Q6.
local r = entry.status.dep_check_result
local dep_count = (r and r.deps and #r.deps) or 0
engine.render.draw_text(
string.format("%s (%d)", ctx.t("dependencies"), dep_count),
p.x + PADDING, cur_y, FONT_SUB, COLOR_TEXT)
cur_y = cur_y + 24
if r and r.deps then
local GUTTER_W = 48 -- room for dep_glyph_warn + breathing space
for _, d in ipairs(r.deps) do
local glyph, col = status_glyph(ctx, d.status)
local gw, _ = engine.render.measure_text(glyph, FONT_DEP)
engine.render.draw_text(glyph,
p.x + PADDING + GUTTER_W - gw, -- right-align in gutter
cur_y, FONT_DEP, col)
engine.render.draw_text(string.format("%s %s", d.id, d.version),
p.x + PADDING + GUTTER_W + 8, -- id starts after gutter + 8px pad
cur_y, FONT_DEP, COLOR_TEXT)
cur_y = cur_y + 20
end
end
-- Whole-list tint hint: bubble up to ctx.list_tint so panels/list.lua picks it up.
if r and r.kind == "error" then ctx.list_tint = "error"
elseif r and r.kind == "warn" then ctx.list_tint = "warn"
end
-- L.7: Launch button at the bottom-right of the detail panel. Label
-- runs through ctx.t() so L.8's strings.lua picks it up once wired.
local label = ctx.t("launch_module")
local lw, lh = engine.render.measure_text(label, 18)
local btn_w = lw + 32
local btn_h = lh + 16
local btn_x = p.x + p.w - PADDING - btn_w
local btn_y = p.y + p.h - PADDING - btn_h
engine.render.draw_rect(btn_x, btn_y, btn_w, btn_h, 0x405080FF)
engine.render.draw_text(label, btn_x + 16, btn_y + 8, 18, 0xFFFFFFFF)
ctx._launch_btn = { x = btn_x, y = btn_y, w = btn_w, h = btn_h }
end
function M.handle_click(ctx, mx, my)
-- L.5: carousel arrow hit-test. L.7 adds the launch-button.
if not ctx.selected_entry or ctx.selected_entry == "news" then
return false
end
local entry = find_entry(ctx, ctx.selected_entry)
if not entry then return false end
local s = ctx.carousel_state[entry.id]
if s and ctx.carousel_mod and ctx.carousel_mod.handle_click(s, mx, my) then
return true
end
-- L.7: Launch button → either switch directly (clean deps) or open
-- the conflict modal (warn/error deps). The launch-anyway path
-- requires engine.module.switch_module_supports_fallback (separate
-- engine slice); until that ships the button stays disabled.
if ctx._launch_btn and engine.spatial.aabb_contains_point(ctx._launch_btn, mx, my) then
local r = entry.status.dep_check_result
if r and r.ok then
engine.switch_module(entry.id)
else
ctx.modal_def = {
title = ctx.t("conflict_title"),
body = {
ctx.t("conflict_body_line1"),
string.format(ctx.t("conflict_body_line2_fmt"),
r and (#(r.errors or {}) + #(r.conflicts or {})) or 0),
},
buttons = {
{ id = "cancel", label = ctx.t("cancel") },
{ id = "launch_anyway", label = ctx.t("launch_anyway"),
disabled = not engine.module.switch_module_supports_fallback },
},
module_id = entry.id,
}
-- C1: signal init.lua to dispatch open_conflict on the FSM
-- so state actually transitions into STATE_MODAL_CONFLICT.
ctx._pending_modal_open = "conflict"
end
return true
end
return false
end
return M

83
panels/list.lua Normal file
View File

@@ -0,0 +1,83 @@
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 ctx.t("news_default_title"),
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

57
panels/modal.lua Normal file
View File

@@ -0,0 +1,57 @@
local M = {}
local COLOR_BACKDROP = 0x000000C0
local COLOR_BG = 0x303030FF
local COLOR_BTN = 0x404040FF
local COLOR_BTN_HOT = 0x606060FF
local COLOR_TEXT = 0xE0E0E0FF
local FONT_TITLE = 22
local FONT_BTN = 18
local PADDING = 18
-- def = {
-- title = <localized-title-string>,
-- body = <line-or-array-of-localized-lines>,
-- buttons = { { id=<key>, label=<localized-label>, disabled=<bool> }, ... },
-- }
function M.render(def, screen_w, screen_h)
-- Backdrop.
engine.render.draw_rect(0, 0, screen_w, screen_h, COLOR_BACKDROP)
local w, h = math.min(screen_w - 80, 480), 0
-- Compute height: title + body-lines + buttons-row.
local body_lines = type(def.body) == "table" and def.body or { def.body or "" }
h = PADDING * 4 + FONT_TITLE + #body_lines * (FONT_BTN + 4) + 40
local x = (screen_w - w) / 2
local y = (screen_h - h) / 2
engine.render.draw_rect(x, y, w, h, COLOR_BG)
engine.render.draw_text(def.title, x + PADDING, y + PADDING, FONT_TITLE, COLOR_TEXT)
local cur_y = y + PADDING + FONT_TITLE + PADDING
for _, line in ipairs(body_lines) do
engine.render.draw_text(line, x + PADDING, cur_y, FONT_BTN, COLOR_TEXT)
cur_y = cur_y + FONT_BTN + 4
end
-- Buttons row at the bottom.
local btn_w = (w - PADDING * (#def.buttons + 1)) / #def.buttons
local btn_y = y + h - PADDING - 32
for i, b in ipairs(def.buttons) do
local bx = x + PADDING + (i - 1) * (btn_w + PADDING)
engine.render.draw_rect(bx, btn_y, btn_w, 32, b.disabled and 0x303030FF or COLOR_BTN)
local lw, lh = engine.render.measure_text(b.label, FONT_BTN)
engine.render.draw_text(b.label,
bx + (btn_w - lw) / 2, btn_y + (32 - lh) / 2, FONT_BTN, COLOR_TEXT)
b.rect = { x = bx, y = btn_y, w = btn_w, h = 32 }
end
end
function M.handle_click(def, mx, my)
for _, b in ipairs(def.buttons) do
if not b.disabled and b.rect
and engine.spatial.aabb_contains_point(b.rect, mx, my) then
return b.id
end
end
return nil
end
return M

25
strings.lua Normal file
View File

@@ -0,0 +1,25 @@
return {
-- Empty-state hint
empty_hint = "Select a module to see details.",
-- Detail panel sections
dependencies = "Dependencies",
-- Dependency status glyphs
dep_glyph_ok = "OK",
dep_glyph_warn = "WARN",
dep_glyph_err = "ERR",
dep_glyph_unknown = "?",
-- Launch flow
launch_module = "Launch Module",
cancel = "Cancel",
launch_anyway = "Launch anyway",
-- Conflict modal
conflict_title = "Dependency conflict",
conflict_body_line1 = "This module has unresolved dependencies.",
conflict_body_line2_fmt = "Missing libs: %d",
-- Quit modal (already in v0.2.0 but moves to strings now)
quit_prompt = "Quit Sporel?",
yes = "Yes",
no = "No",
-- News entry default title
news_default_title = "News",
}