Compare commits
14 Commits
332a37e155
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a9a42054 | ||
|
|
2595759b3e | ||
|
|
fa558adfeb | ||
|
|
29c6c409e0 | ||
|
|
a19dd7f1dd | ||
|
|
a49aa3ef2b | ||
|
|
89b7b060d1 | ||
|
|
10d0ab6529 | ||
|
|
94b55c42f2 | ||
|
|
72fa4d3135 | ||
|
|
0a96b009f1 | ||
|
|
b360ee6230 | ||
|
|
d63dd91bb6 | ||
|
|
f2b2a8859b |
BIN
assets/launcher/background.png
Normal file
BIN
assets/launcher/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/launcher/default_teaser.png
Normal file
BIN
assets/launcher/default_teaser.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
28
fsm.lua
28
fsm.lua
@@ -1,23 +1,23 @@
|
|||||||
-- Sporel Launcher — pure-Lua finite state machine.
|
|
||||||
-- No engine.* calls; testable directly by lib-management.launcher-test.
|
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
M.STATE_LIST = "list"
|
M.STATE_LIST = "list"
|
||||||
M.STATE_QUIT_CONFIRM = "quit_confirm"
|
M.STATE_QUIT_CONFIRM = "quit_confirm"
|
||||||
M.EXIT = "exit" -- sentinel: caller should engine.exit(0)
|
M.STATE_MODAL_CONFLICT = "modal_conflict"
|
||||||
|
M.EXIT = "exit"
|
||||||
|
|
||||||
-- Events: "esc", "enter", "click_yes", "click_no", "click_module"
|
|
||||||
function M.next(state, event)
|
function M.next(state, event)
|
||||||
if state == M.STATE_LIST then
|
if state == M.STATE_LIST then
|
||||||
if event == "esc" then return M.STATE_QUIT_CONFIRM end
|
if event == "esc" then return M.STATE_QUIT_CONFIRM end
|
||||||
return M.STATE_LIST
|
if event == "open_conflict" then return M.STATE_MODAL_CONFLICT end
|
||||||
elseif state == M.STATE_QUIT_CONFIRM then
|
elseif state == M.STATE_QUIT_CONFIRM then
|
||||||
if event == "esc" then return M.STATE_LIST end
|
if event == "esc" then return M.STATE_LIST end
|
||||||
if event == "enter" then return M.EXIT end
|
if event == "enter" then return M.EXIT end
|
||||||
if event == "click_yes" then return M.EXIT end
|
if event == "click_yes" then return M.EXIT end
|
||||||
if event == "click_no" then return M.STATE_LIST end
|
if event == "click_no" then return M.STATE_LIST end
|
||||||
return M.STATE_QUIT_CONFIRM
|
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
|
end
|
||||||
return state
|
return state
|
||||||
end
|
end
|
||||||
|
|||||||
84
icons.lua
84
icons.lua
@@ -1,84 +0,0 @@
|
|||||||
-- icons.lua — inline status-icon primitives for the launcher.
|
|
||||||
--
|
|
||||||
-- The engine ships text + rect bindings; no sprites, no draw_circle yet,
|
|
||||||
-- so every icon is composed from draw_rect + draw_text. Each helper
|
|
||||||
-- returns the rect it occupied so the caller can stash it for hit-tests
|
|
||||||
-- (tooltip + update-badge click).
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
M.SPINNER_COLOR = 0xC0C0C0FF
|
|
||||||
M.WARN_COLOR = 0xFFD000FF
|
|
||||||
M.UPDATE_COLOR = 0x4090FFFF
|
|
||||||
M.ERROR_COLOR = 0xFF4040FF
|
|
||||||
M.LOCAL_COLOR = 0x707070FF -- muted gray for "local-only" repos
|
|
||||||
|
|
||||||
M.ICON_W = 16
|
|
||||||
M.ICON_H = 16
|
|
||||||
|
|
||||||
local SPINNER_GLYPHS = { "|", "/", "-", "\\" }
|
|
||||||
|
|
||||||
-- Spinner: rotating ASCII glyph cycled at ~7.5 Hz at 60 fps (every 8
|
|
||||||
-- frames). Returns its bounding rect so the caller can hit-test for
|
|
||||||
-- tooltip ("checking for updates...").
|
|
||||||
function M.draw_spinner(x, y, frame)
|
|
||||||
local glyph = SPINNER_GLYPHS[(math.floor(frame / 8) % 4) + 1]
|
|
||||||
engine.render.draw_text(glyph, x + 4, y, 12, M.SPINNER_COLOR)
|
|
||||||
return { x = x, y = y, w = M.ICON_W, h = M.ICON_H }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Warn: yellow square with "!" centered.
|
|
||||||
function M.draw_warn(x, y)
|
|
||||||
engine.render.draw_rect(x, y, M.ICON_W, M.ICON_H, M.WARN_COLOR)
|
|
||||||
engine.render.draw_text("!", x + 5, y, 14, 0x000000FF)
|
|
||||||
return { x = x, y = y, w = M.ICON_W, h = M.ICON_H }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Update badge: blue pill "^v0.5.5". Width depends on the version
|
|
||||||
-- string; returned rect carries the dynamic width so callers can do
|
|
||||||
-- right-to-left layout and click-hit-tests.
|
|
||||||
function M.draw_update_badge(x, y, version_str)
|
|
||||||
local label = "^" .. version_str
|
|
||||||
local tw, _ = engine.render.measure_text(label, 12)
|
|
||||||
local w = tw + 10
|
|
||||||
engine.render.draw_rect(x, y, w, M.ICON_H, M.UPDATE_COLOR)
|
|
||||||
engine.render.draw_text(label, x + 5, y + 1, 12, 0xFFFFFFFF)
|
|
||||||
return { x = x, y = y, w = w, h = M.ICON_H }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Error: red square with white "!" — same shape as warn but different
|
|
||||||
-- color so colorblind users still see the shape difference via the
|
|
||||||
-- background tint contrast.
|
|
||||||
function M.draw_error(x, y)
|
|
||||||
engine.render.draw_rect(x, y, M.ICON_W, M.ICON_H, M.ERROR_COLOR)
|
|
||||||
engine.render.draw_text("!", x + 5, y, 14, 0xFFFFFFFF)
|
|
||||||
return { x = x, y = y, w = M.ICON_W, h = M.ICON_H }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Local-only: muted-gray pill with "L" — module exists in the local
|
|
||||||
-- install but the remote returns 404 even with credentials, so the
|
|
||||||
-- update channel doesn't apply. Informational, not a warning.
|
|
||||||
function M.draw_local(x, y)
|
|
||||||
engine.render.draw_rect(x, y, M.ICON_W, M.ICON_H, M.LOCAL_COLOR)
|
|
||||||
engine.render.draw_text("L", x + 5, y, 12, 0xFFFFFFFF)
|
|
||||||
return { x = x, y = y, w = M.ICON_W, h = M.ICON_H }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Tooltip: small dark panel anchored to the right of (mx, my).
|
|
||||||
-- Centered vertically on the cursor. Clamped to screen via the
|
|
||||||
-- screen-size args.
|
|
||||||
function M.draw_tooltip(mx, my, text, screen_w, screen_h)
|
|
||||||
if not text or text == "" then return end
|
|
||||||
local tw, th = engine.render.measure_text(text, 12)
|
|
||||||
local pad_x, pad_y = 6, 3
|
|
||||||
local w, h = tw + 2 * pad_x, th + 2 * pad_y
|
|
||||||
local x = mx + 14
|
|
||||||
local y = my - h / 2
|
|
||||||
if x + w > screen_w then x = mx - w - 8 end
|
|
||||||
if y < 0 then y = 0 end
|
|
||||||
if y + h > screen_h then y = screen_h - h end
|
|
||||||
engine.render.draw_rect(x, y, w, h, 0x202020E0)
|
|
||||||
engine.render.draw_text(text, x + pad_x, y + pad_y, 12, 0xFFFFFFFF)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
338
init.lua
338
init.lua
@@ -1,12 +1,12 @@
|
|||||||
local input = require("lib-core.input")
|
local input = require("lib-core.input")
|
||||||
local git = require("lib-core.git")
|
local git = require("lib-core.git")
|
||||||
local depf = require("lib-management.dep-fetcher")
|
local depf = require("lib-management.dep-fetcher")
|
||||||
-- fsm.lua + icons.lua live in this module's dir; engine_lua_lib's searcher
|
-- fsm.lua lives in this module's dir; engine_lua_lib's searcher only
|
||||||
-- only resolves declared lib-deps, so multi-file modules use dofile +
|
-- resolves declared lib-deps, so multi-file modules use dofile +
|
||||||
-- engine.module.dir_of.
|
-- engine.module.dir_of.
|
||||||
local MODULE_DIR = engine.module.dir_of("lib-management.launcher")
|
local MODULE_DIR = engine.module.dir_of("lib-management.launcher")
|
||||||
local fsm = dofile(MODULE_DIR .. "/fsm.lua")
|
local fsm = dofile(MODULE_DIR .. "/fsm.lua")
|
||||||
local icons = dofile(MODULE_DIR .. "/icons.lua")
|
local manifest_loader = dofile(MODULE_DIR .. "/manifest_loader.lua")
|
||||||
|
|
||||||
local function require_panel(name)
|
local function require_panel(name)
|
||||||
return dofile(MODULE_DIR .. "/panels/" .. name .. ".lua")
|
return dofile(MODULE_DIR .. "/panels/" .. name .. ".lua")
|
||||||
@@ -14,6 +14,8 @@ end
|
|||||||
|
|
||||||
local list_panel = require_panel("list")
|
local list_panel = require_panel("list")
|
||||||
local detail_panel = require_panel("detail")
|
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_LIST, STATE_QUIT, EXIT = fsm.STATE_LIST, fsm.STATE_QUIT_CONFIRM, fsm.EXIT
|
||||||
local state = STATE_LIST
|
local state = STATE_LIST
|
||||||
@@ -28,18 +30,15 @@ local gitea_base = nil -- resolved in init() from engine.config
|
|||||||
local frames = 0
|
local frames = 0
|
||||||
local CI_FRAMES = 60
|
local CI_FRAMES = 60
|
||||||
|
|
||||||
local TITLE = "Sporel Launcher"
|
-- L.8: All UI-facing strings now live in strings.lua and are accessed
|
||||||
local QUIT_PROMPT = "Sporel beenden?"
|
-- via ctx_panels.t(key, ...). The previous TITLE / QUIT_PROMPT /
|
||||||
local YES_LABEL = "Ja"
|
-- YES_LABEL / NO_LABEL / EMPTY_LABEL locals were retired in this slice.
|
||||||
local NO_LABEL = "Nein"
|
local STRINGS = dofile(MODULE_DIR .. "/strings.lua")
|
||||||
local EMPTY_LABEL = "Keine Module installiert."
|
|
||||||
|
|
||||||
local FONT_TITLE = 28
|
local FONT_TITLE = 28
|
||||||
local FONT_BUTTON = 20
|
local FONT_BUTTON = 20
|
||||||
local PAD_X = 16
|
local PAD_X = 16
|
||||||
local PAD_Y = 8
|
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_BG = 0x202020FF
|
||||||
local COLOR_BTN = 0x404040FF
|
local COLOR_BTN = 0x404040FF
|
||||||
@@ -125,98 +124,57 @@ local function consume_update_check(m)
|
|||||||
end
|
end
|
||||||
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
|
-- Lazy: only re-check dep-fetcher when the consumer flow asks for it
|
||||||
-- (init + after-update). Heavy-lift in init() is acceptable per the
|
-- (init + after-update). Heavy-lift in init() is acceptable per the
|
||||||
-- plan; an async dep-check is future work.
|
-- plan; an async dep-check is future work.
|
||||||
local function run_dep_check(m)
|
local function run_dep_check(m)
|
||||||
local ok, result = pcall(depf.ensure_for_module, m.id)
|
local ok, result = pcall(depf.ensure_for_module, m.id)
|
||||||
if ok and type(result) == "table" then
|
if not (ok and type(result) == "table") then
|
||||||
m.status.dep_check_result = result
|
result = {
|
||||||
else
|
|
||||||
m.status.dep_check_result = {
|
|
||||||
ok = false, conflicts = {}, warnings = {},
|
ok = false, conflicts = {}, warnings = {},
|
||||||
errors = {{ lib_id = "<launcher>", kind = "internal",
|
errors = {{ lib_id = "<launcher>", kind = "internal",
|
||||||
message = tostring(result) }},
|
message = tostring(result) }},
|
||||||
closure = {},
|
closure = {},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
result.deps = compose_dep_status_list(result, m._raw_deps or {})
|
||||||
|
result.kind = derive_result_kind(result)
|
||||||
-- Build the tooltip text for whichever icon the mouse is over.
|
m.status.dep_check_result = result
|
||||||
-- 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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ----- lifecycle -----------------------------------------------------
|
-- ----- lifecycle -----------------------------------------------------
|
||||||
@@ -239,20 +197,39 @@ function init(ctx)
|
|||||||
ctx_panels = {
|
ctx_panels = {
|
||||||
-- Persistent UI context, threaded through the panels.
|
-- Persistent UI context, threaded through the panels.
|
||||||
module_entries = {}, -- filled below from engine.module.list
|
module_entries = {}, -- filled below from engine.module.list
|
||||||
news_entry = { title = "News", body_path = "news.md" },
|
news_entry = { title = nil, body_path = "news.md" },
|
||||||
selected_entry = nil,
|
selected_entry = nil,
|
||||||
scroll_y = 0,
|
scroll_y = 0,
|
||||||
list_tint = nil, -- nil | "warn" | "error" — set in L.4
|
list_tint = nil, -- nil | "warn" | "error" — set in L.4
|
||||||
list_panel_rect = nil, -- set below from window size
|
list_panel_rect = nil, -- set below from window size
|
||||||
detail_panel_rect = nil,
|
detail_panel_rect = nil,
|
||||||
bg_texture = nil, -- L.10 loads it
|
bg_texture = nil, -- assigned below from engine.asset.load_texture
|
||||||
default_teaser_texture = nil, -- L.10 loads it
|
default_teaser_texture = nil, -- assigned below from engine.asset.load_texture
|
||||||
t = function(k) return k end, -- L.8 swaps in real t()
|
t = nil, -- L.8: real implementation installed below
|
||||||
modal_open = nil, -- L.7 sets to "quit" | "conflict"
|
|
||||||
carousel_state = {}, -- per-module-id state, L.5 populates
|
carousel_state = {}, -- per-module-id state, L.5 populates
|
||||||
manifest_cache = {}, -- L.3 populates
|
manifest_cache = {}, -- L.3 populates
|
||||||
|
launcher_dir = MODULE_DIR, -- L.4: detail.lua dofiles markdown.lua via this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
ctx_panels.news_entry.title = ctx_panels.t("news_default_title")
|
||||||
|
|
||||||
-- Window split: 1/3 list, 2/3 detail. Margin 12px between panels.
|
-- Window split: 1/3 list, 2/3 detail. Margin 12px between panels.
|
||||||
local SW, SH = screen_w, screen_h
|
local SW, SH = screen_w, screen_h
|
||||||
ctx_panels.list_panel_rect = { x = 0, y = 0, w = SW / 3, h = SH }
|
ctx_panels.list_panel_rect = { x = 0, y = 0, w = SW / 3, h = SH }
|
||||||
@@ -279,8 +256,58 @@ function init(ctx)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON)
|
-- L.3: load + cache manifest.launcher.md for each module. Missing
|
||||||
local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON)
|
-- 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_w = math.max(wy, wn) + 2 * PAD_X
|
||||||
local qbtn_h = math.max(hy, hn) + 2 * PAD_Y
|
local qbtn_h = math.max(hy, hn) + 2 * PAD_Y
|
||||||
local total_w = 2 * qbtn_w + 24
|
local total_w = 2 * qbtn_w + 24
|
||||||
@@ -322,8 +349,48 @@ function update(ctx, dt)
|
|||||||
end
|
end
|
||||||
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()
|
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 state == STATE_LIST then
|
||||||
if input.was_action_pressed("ui_back") then
|
if input.was_action_pressed("ui_back") then
|
||||||
state = fsm.next(state, "esc")
|
state = fsm.next(state, "esc")
|
||||||
@@ -347,67 +414,6 @@ function update(ctx, dt)
|
|||||||
end
|
end
|
||||||
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)
|
function render(ctx)
|
||||||
local mx, my = engine.input.get_mouse_pos()
|
local mx, my = engine.input.get_mouse_pos()
|
||||||
|
|
||||||
@@ -416,21 +422,27 @@ function render(ctx)
|
|||||||
list_panel.render(ctx_panels)
|
list_panel.render(ctx_panels)
|
||||||
detail_panel.render(ctx_panels)
|
detail_panel.render(ctx_panels)
|
||||||
|
|
||||||
|
-- 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
|
if state == STATE_QUIT then
|
||||||
engine.render.draw_rect(0, 0, screen_w, screen_h, COLOR_DIM)
|
engine.render.draw_rect(0, 0, screen_w, screen_h, COLOR_DIM)
|
||||||
local modal_w, modal_h = 360, 140
|
local modal_w, modal_h = 360, 140
|
||||||
engine.render.draw_rect((screen_w - modal_w) / 2,
|
engine.render.draw_rect((screen_w - modal_w) / 2,
|
||||||
(screen_h - modal_h) / 2,
|
(screen_h - modal_h) / 2,
|
||||||
modal_w, modal_h, COLOR_MODAL)
|
modal_w, modal_h, COLOR_MODAL)
|
||||||
local pw = engine.render.measure_text(QUIT_PROMPT, FONT_BUTTON)
|
local prompt = ctx_panels.t("quit_prompt")
|
||||||
engine.render.draw_text(QUIT_PROMPT, (screen_w - pw) / 2,
|
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)
|
screen_h / 2 - 24, FONT_BUTTON, COLOR_TEXT)
|
||||||
|
|
||||||
for _, key in ipairs({ "yes", "no" }) do
|
for _, key in ipairs({ "yes", "no" }) do
|
||||||
local b = quit_buttons[key]
|
local b = quit_buttons[key]
|
||||||
local color = hit(b, mx, my) and COLOR_HOVER or COLOR_BTN
|
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)
|
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)
|
local lw = engine.render.measure_text(label, FONT_BUTTON)
|
||||||
engine.render.draw_text(label, b.x + (b.w - lw) / 2,
|
engine.render.draw_text(label, b.x + (b.w - lw) / 2,
|
||||||
b.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)
|
b.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)
|
||||||
|
|||||||
19
manifest.launcher.md
Normal file
19
manifest.launcher.md
Normal 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "lib-management.launcher",
|
"id": "lib-management.launcher",
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"kind": "module",
|
"kind": "module",
|
||||||
"api": "^0.1",
|
"api": "^0.1",
|
||||||
"ci_frames": 10,
|
"ci_frames": 10,
|
||||||
|
|||||||
33
manifest_loader.lua
Normal file
33
manifest_loader.lua
Normal 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
|
||||||
10
markdown.lua
10
markdown.lua
@@ -176,7 +176,11 @@ local function render_spans(spans, x, y, max_w, size)
|
|||||||
return cur_y + line_h
|
return cur_y + line_h
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.render(blocks, x, y, max_w, module_id_for_imgs)
|
-- `t` (optional, 6th arg) is the launcher's ctx.t string-lookup function.
|
||||||
|
-- When present, M.render uses it to localize the image-missing placeholder
|
||||||
|
-- via the `image_missing_fmt` key. Callers that don't have a t (e.g. tests)
|
||||||
|
-- may pass nil; the hardcoded English fallback then renders verbatim.
|
||||||
|
function M.render(blocks, x, y, max_w, module_id_for_imgs, t)
|
||||||
local cur_y = y
|
local cur_y = y
|
||||||
for _, b in ipairs(blocks) do
|
for _, b in ipairs(blocks) do
|
||||||
if b.type == "h1" then
|
if b.type == "h1" then
|
||||||
@@ -216,7 +220,9 @@ function M.render(blocks, x, y, max_w, module_id_for_imgs)
|
|||||||
-- assume a 16:9 max_w-wide image for layout.
|
-- assume a 16:9 max_w-wide image for layout.
|
||||||
cur_y = cur_y + math.floor(max_w * 9 / 16) + PARA_GAP
|
cur_y = cur_y + math.floor(max_w * 9 / 16) + PARA_GAP
|
||||||
else
|
else
|
||||||
engine.render.draw_text("[image missing: " .. b.src .. "]",
|
local missing_text = t and t("image_missing_fmt", b.src)
|
||||||
|
or ("[image missing: " .. b.src .. "]")
|
||||||
|
engine.render.draw_text(missing_text,
|
||||||
x, cur_y, BODY_SIZE, COLOR_DIM)
|
x, cur_y, BODY_SIZE, COLOR_DIM)
|
||||||
local _, h = engine.render.measure_text("[image]", BODY_SIZE)
|
local _, h = engine.render.measure_text("[image]", BODY_SIZE)
|
||||||
cur_y = cur_y + h + PARA_GAP
|
cur_y = cur_y + h + PARA_GAP
|
||||||
|
|||||||
22
news.md
Normal file
22
news.md
Normal 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
108
panels/carousel.lua
Normal 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
|
||||||
@@ -1,36 +1,175 @@
|
|||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local PADDING = 24
|
local PADDING = 24
|
||||||
local COLOR_BG = 0x282828FF
|
local COLOR_BG = 0x282828FF
|
||||||
local COLOR_TEXT = 0xE0E0E0FF
|
local COLOR_TEXT = 0xE0E0E0FF
|
||||||
local COLOR_DIM = 0xA0A0A0FF
|
local COLOR_DIM = 0xA0A0A0FF
|
||||||
local FONT_TITLE = 28
|
local COLOR_OK = 0x80C080FF
|
||||||
local FONT_BODY = 14
|
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)
|
function M.render(ctx)
|
||||||
local p = ctx.detail_panel_rect
|
local p = ctx.detail_panel_rect
|
||||||
engine.render.draw_rect(p.x, p.y, p.w, p.h, COLOR_BG)
|
engine.render.draw_rect(p.x, p.y, p.w, p.h, COLOR_BG)
|
||||||
|
|
||||||
if not ctx.selected_entry then
|
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
|
||||||
if ctx.bg_texture then
|
|
||||||
engine.render.draw_texture(ctx.bg_texture, p.x, p.y)
|
|
||||||
end
|
|
||||||
local hint = ctx.t("empty_hint")
|
local hint = ctx.t("empty_hint")
|
||||||
local w, h = engine.render.measure_text(hint, FONT_BODY)
|
local w, h = engine.render.measure_text(hint, FONT_BODY)
|
||||||
engine.render.draw_text(hint,
|
engine.render.draw_text(hint, p.x + (p.w - w) / 2, p.y + (p.h - h) / 2,
|
||||||
p.x + (p.w - w) / 2, p.y + (p.h - h) / 2,
|
FONT_BODY, COLOR_DIM)
|
||||||
FONT_BODY, COLOR_DIM)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Stub for L.4/L.5/L.6/L.7: full content lands later.
|
-- News: render news.md.
|
||||||
engine.render.draw_text(tostring(ctx.selected_entry),
|
if ctx.selected_entry == "news" then
|
||||||
p.x + PADDING, p.y + PADDING, FONT_TITLE, COLOR_TEXT)
|
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, ctx.t)
|
||||||
|
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, ctx.t)
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
function M.handle_click(ctx, mx, my)
|
function M.handle_click(ctx, mx, my)
|
||||||
-- Stub: real hit-tests for launch button + carousel arrows in L.5/L.7.
|
-- 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
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ function M.render(ctx)
|
|||||||
}
|
}
|
||||||
local col = (ctx.selected_entry == "news") and COLOR_ROW_ACTIVE or COLOR_ROW
|
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_rect(rect.x, rect.y, rect.w, rect.h, col)
|
||||||
engine.render.draw_text(ctx.news_entry.title or "News",
|
engine.render.draw_text(
|
||||||
|
ctx.news_entry.title or ctx.t("news_default_title"),
|
||||||
rect.x + PADDING, rect.y + PADDING, FONT, COLOR_TEXT)
|
rect.x + PADDING, rect.y + PADDING, FONT, COLOR_TEXT)
|
||||||
ctx.news_entry.rect = rect
|
ctx.news_entry.rect = rect
|
||||||
end
|
end
|
||||||
|
|||||||
57
panels/modal.lua
Normal file
57
panels/modal.lua
Normal 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
|
||||||
28
strings.lua
Normal file
28
strings.lua
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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",
|
||||||
|
-- Markdown image-missing placeholder (rendered by markdown.lua when
|
||||||
|
-- engine.module.load_texture returns nil for an inline image source).
|
||||||
|
image_missing_fmt = "[image missing: %s]",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user