launcher 0.2.0: inline status icons + classified update-check + token auth wire
- Async update-check per module on init (git.start_ls_remote_tags),
polled per frame. Result classified into available / no-update /
fail / local-only via the new kind field from lib-core.git 0.2.0.
- Pre-emptive dep-check per module (depf.ensure_for_module) on init
populates a dep_check_result whose ok/conflicts/warnings/errors
drive the icon strip.
- Inline icon strip per module-row (icons.lua): rotating ASCII
spinner while pending, yellow warn for auth/network failures or
dirty deps, blue update-badge with target version (clickable to
trigger fetch+checkout), red error for dep-check conflicts, gray
L for repos that genuinely don't exist on the server. Tooltip on
hover shows the specific reason per icon.
- Click handler splits launch-vs-update by hit-testing the badge
rect before the row rect. Launch is blocked when dep-check failed.
- On init, reads engine.config("gitea_token") and forwards via
git.set_token so users can persist their token in their user-data
engine.json layer instead of using an env var.
- New deps: lib-core.git 0.1.0, lib-management.dep-fetcher 0.1.2.
This commit is contained in:
84
icons.lua
Normal file
84
icons.lua
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
-- 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
|
||||||
373
init.lua
373
init.lua
@@ -1,14 +1,20 @@
|
|||||||
local input = require("lib-core.input")
|
local input = require("lib-core.input")
|
||||||
-- fsm.lua lives in this module's dir; engine_lua_lib's searcher only resolves
|
local git = require("lib-core.git")
|
||||||
-- declared lib-deps, so multi-file modules use dofile + engine.module.dir_of.
|
local depf = require("lib-management.dep-fetcher")
|
||||||
local fsm = dofile(engine.module.dir_of("lib-management.launcher") .. "/fsm.lua")
|
-- 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 +
|
||||||
|
-- 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 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
|
||||||
|
|
||||||
local modules = {} -- [{id, version, name, rect={x,y,w,h}}]
|
local modules = {} -- per-entry: see init() below
|
||||||
local quit_buttons = {} -- {yes = rect, no = rect}
|
local quit_buttons = {} -- {yes = rect, no = rect}
|
||||||
local screen_w, screen_h -- queried in init() via engine.window.size() (P.3.5)
|
local screen_w, screen_h
|
||||||
|
local gitea_base = nil -- resolved in init() from engine.config
|
||||||
|
|
||||||
-- CI-conditional self-exit (matches spine-prototype's pattern).
|
-- CI-conditional self-exit (matches spine-prototype's pattern).
|
||||||
-- Interactive runs (no SPOREL_CI=1) loop until ESC or quit-confirm.
|
-- Interactive runs (no SPOREL_CI=1) loop until ESC or quit-confirm.
|
||||||
@@ -25,46 +31,263 @@ 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
|
||||||
local COLOR_HOVER = 0x606060FF
|
local COLOR_HOVER = 0x606060FF
|
||||||
local COLOR_TEXT = 0xE0E0E0FF
|
local COLOR_TEXT = 0xE0E0E0FF
|
||||||
local COLOR_DIM = 0x000000B4 -- semi-transparent overlay
|
local COLOR_DIM = 0x000000B4
|
||||||
local COLOR_MODAL = 0x303030FF
|
local COLOR_MODAL = 0x303030FF
|
||||||
|
|
||||||
local hit = engine.spatial.aabb_contains_point
|
local hit = engine.spatial.aabb_contains_point
|
||||||
|
|
||||||
|
-- ----- helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
-- Compare semver-ish "vX.Y.Z" strings. Returns true if a > b.
|
||||||
|
local function compare_versions(a, b)
|
||||||
|
local a_maj, a_min, a_pat = a:match("v?(%d+)%.(%d+)%.(%d+)")
|
||||||
|
local b_maj, b_min, b_pat = b:match("v?(%d+)%.(%d+)%.(%d+)")
|
||||||
|
if not a_maj or not b_maj then return false end
|
||||||
|
local av = { tonumber(a_maj), tonumber(a_min), tonumber(a_pat) }
|
||||||
|
local bv = { tonumber(b_maj), tonumber(b_min), tonumber(b_pat) }
|
||||||
|
for i = 1, 3 do
|
||||||
|
if av[i] > bv[i] then return true end
|
||||||
|
if av[i] < bv[i] then return false end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function module_repo_url(module_id)
|
||||||
|
return gitea_base .. "/sporel-module-" .. module_id .. ".git"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After a successful git.checkout on a module dir, reload its manifest
|
||||||
|
-- to surface the new version in the UI. Uses dep-fetcher's pure-Lua
|
||||||
|
-- JSON decoder so we don't add a new engine.json binding for one site.
|
||||||
|
local function refresh_module_version(m)
|
||||||
|
local path = engine.install_root() .. "/modules/" .. m.id
|
||||||
|
.. "/manifest.module"
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if not f then return end
|
||||||
|
local content = f:read("*a"); f:close()
|
||||||
|
local data = depf._json_decode(content)
|
||||||
|
if data and data.version then m.version = data.version end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_update_check(m)
|
||||||
|
if not gitea_base then return end
|
||||||
|
local url = module_repo_url(m.id)
|
||||||
|
local handle = git.start_ls_remote_tags(url)
|
||||||
|
m.status.update_check_handle = handle
|
||||||
|
m.status.update_check_result = "pending"
|
||||||
|
m.status.update_latest_tag = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Result poll: success -> compare highest vX.Y.Z tag against m.version.
|
||||||
|
-- Error kinds from lib-core.git v0.2.0: "auth-required" | "not-found" |
|
||||||
|
-- "network" | "other". "not-found" → local-only marker; everything else
|
||||||
|
-- folds into a generic "fail" with the kind preserved for tooltip text.
|
||||||
|
local function consume_update_check(m)
|
||||||
|
local result = git.take_result(m.status.update_check_handle)
|
||||||
|
m.status.update_check_handle = nil
|
||||||
|
if not result or result.error then
|
||||||
|
local kind = result and result.kind or "other"
|
||||||
|
if kind == "not-found" then
|
||||||
|
m.status.update_check_result = "local-only"
|
||||||
|
else
|
||||||
|
m.status.update_check_result = "fail"
|
||||||
|
m.status.update_check_fail_kind = kind
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local latest = nil
|
||||||
|
for _, tag in ipairs(result.tags or {}) do
|
||||||
|
if tag:match("^v%d+%.%d+%.%d+$") then
|
||||||
|
if not latest or compare_versions(tag, latest) then
|
||||||
|
latest = tag
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if latest and compare_versions(latest, "v" .. m.version) then
|
||||||
|
m.status.update_check_result = "available"
|
||||||
|
m.status.update_latest_tag = latest
|
||||||
|
else
|
||||||
|
m.status.update_check_result = "no-update"
|
||||||
|
end
|
||||||
|
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 = {
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----- lifecycle -----------------------------------------------------
|
||||||
|
|
||||||
function init(ctx)
|
function init(ctx)
|
||||||
screen_w, screen_h = engine.window.size()
|
screen_w, screen_h = engine.window.size()
|
||||||
|
gitea_base = engine.config("gitea_base")
|
||||||
|
or "https://git.davoryn.de/sporel"
|
||||||
|
|
||||||
|
-- Slice 7.1: override lib-core.git's token (defaulted from
|
||||||
|
-- SPOREL_GITEA_TOKEN env at lib_init) with the engine.json layer
|
||||||
|
-- value if the user-data config sets gitea_token. Allows users to
|
||||||
|
-- persist their Gitea PAT in %APPDATA%/Sporel/engine.json instead
|
||||||
|
-- of carrying an env var around.
|
||||||
|
local cfg_token = engine.config("gitea_token")
|
||||||
|
if cfg_token and cfg_token ~= "" and type(git.set_token) == "function" then
|
||||||
|
git.set_token(cfg_token)
|
||||||
|
end
|
||||||
|
|
||||||
local list = engine.module.list()
|
local list = engine.module.list()
|
||||||
|
|
||||||
|
-- Uniform button width: max label + padding + reserved icon strip.
|
||||||
|
local max_label_w = 0
|
||||||
|
for _, m in ipairs(list) do
|
||||||
|
local label = m.name or m.id
|
||||||
|
local lw = engine.render.measure_text(label, FONT_BUTTON)
|
||||||
|
if lw > max_label_w then max_label_w = lw end
|
||||||
|
end
|
||||||
|
local _, label_h = engine.render.measure_text("Ay", FONT_BUTTON)
|
||||||
|
local btn_w = max_label_w + 2 * PAD_X + ICON_STRIP_W
|
||||||
|
local btn_h = label_h + 2 * PAD_Y
|
||||||
|
|
||||||
local y = 80
|
local y = 80
|
||||||
for i, m in ipairs(list) do
|
for i, m in ipairs(list) do
|
||||||
local label = m.name or m.id
|
|
||||||
local w, h = engine.render.measure_text(label, FONT_BUTTON)
|
|
||||||
local btn_w = w + 2 * PAD_X
|
|
||||||
local btn_h = h + 2 * PAD_Y
|
|
||||||
modules[i] = {
|
modules[i] = {
|
||||||
id = m.id,
|
id = m.id,
|
||||||
version = m.version,
|
version = m.version,
|
||||||
name = label,
|
name = m.name or m.id,
|
||||||
rect = { x = (screen_w - btn_w) / 2, y = y, w = btn_w, h = btn_h }
|
rect = { x = (screen_w - btn_w) / 2, y = y,
|
||||||
|
w = btn_w, h = btn_h },
|
||||||
|
status = {
|
||||||
|
update_check_handle = nil,
|
||||||
|
update_check_result = nil,
|
||||||
|
update_check_fail_kind = nil,
|
||||||
|
update_latest_tag = nil,
|
||||||
|
dep_check_result = nil,
|
||||||
|
spinner_rect = nil, warn_rect = nil,
|
||||||
|
badge_rect = nil, error_rect = nil,
|
||||||
|
local_rect = nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
y = y + btn_h + 8
|
y = y + btn_h + 8
|
||||||
end
|
end
|
||||||
|
|
||||||
local wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON)
|
local wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON)
|
||||||
local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON)
|
local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON)
|
||||||
local btn_w = math.max(wy, wn) + 2 * PAD_X
|
local qbtn_w = math.max(wy, wn) + 2 * PAD_X
|
||||||
local btn_h = math.max(hy, hn) + 2 * PAD_Y
|
local qbtn_h = math.max(hy, hn) + 2 * PAD_Y
|
||||||
local total_w = 2 * btn_w + 24
|
local total_w = 2 * qbtn_w + 24
|
||||||
quit_buttons.yes = { x = (screen_w - total_w) / 2, y = screen_h / 2 + 24, w = btn_w, h = btn_h }
|
quit_buttons.yes = { x = (screen_w - total_w) / 2, y = screen_h / 2 + 24, w = qbtn_w, h = qbtn_h }
|
||||||
quit_buttons.no = { x = (screen_w - total_w) / 2 + btn_w + 24, y = screen_h / 2 + 24, w = btn_w, h = btn_h }
|
quit_buttons.no = { x = (screen_w - total_w) / 2 + qbtn_w + 24, y = screen_h / 2 + 24, w = qbtn_w, h = qbtn_h }
|
||||||
|
|
||||||
input.bind("ui_click", { "mouse_left" })
|
input.bind("ui_click", { "mouse_left" })
|
||||||
input.bind("ui_back", { "escape" })
|
input.bind("ui_back", { "escape" })
|
||||||
input.bind("ui_confirm", { "enter" })
|
input.bind("ui_confirm", { "enter" })
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
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
|
||||||
|
|
||||||
engine.print(string.format("launcher: init ok, modules=%d", #modules))
|
engine.print(string.format("launcher: init ok, modules=%d", #modules))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -75,6 +298,14 @@ function update(ctx, dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Poll async update-check handles.
|
||||||
|
for _, m in ipairs(modules) do
|
||||||
|
local h = m.status.update_check_handle
|
||||||
|
if h and git.is_done(h) then
|
||||||
|
consume_update_check(m)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local mx, my = engine.input.get_mouse_pos()
|
local mx, my = engine.input.get_mouse_pos()
|
||||||
|
|
||||||
if state == STATE_LIST then
|
if state == STATE_LIST then
|
||||||
@@ -82,8 +313,20 @@ function update(ctx, dt)
|
|||||||
state = fsm.next(state, "esc")
|
state = fsm.next(state, "esc")
|
||||||
elseif input.was_action_pressed("ui_click") then
|
elseif input.was_action_pressed("ui_click") then
|
||||||
for _, m in ipairs(modules) do
|
for _, m in ipairs(modules) do
|
||||||
if hit(m.rect, mx, my) then
|
-- Badge first — its hit-rect lives inside the button,
|
||||||
engine.switch_module(m.id)
|
-- 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
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -103,31 +346,104 @@ 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()
|
||||||
|
|
||||||
engine.render.clear_color(COLOR_BG)
|
engine.render.clear_color(COLOR_BG)
|
||||||
local tw = engine.render.measure_text(TITLE, FONT_TITLE)
|
local tw = engine.render.measure_text(TITLE, FONT_TITLE)
|
||||||
engine.render.draw_text(TITLE, (screen_w - tw) / 2, 24, FONT_TITLE, COLOR_TEXT)
|
engine.render.draw_text(TITLE, (screen_w - tw) / 2, 24,
|
||||||
|
FONT_TITLE, COLOR_TEXT)
|
||||||
|
|
||||||
if #modules == 0 then
|
if #modules == 0 then
|
||||||
local ew = engine.render.measure_text(EMPTY_LABEL, FONT_BUTTON)
|
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)
|
engine.render.draw_text(EMPTY_LABEL, (screen_w - ew) / 2,
|
||||||
|
screen_h / 2, FONT_BUTTON, COLOR_TEXT)
|
||||||
else
|
else
|
||||||
for _, m in ipairs(modules) do
|
for _, m in ipairs(modules) do
|
||||||
local color = hit(m.rect, mx, my) and COLOR_HOVER or COLOR_BTN
|
draw_module_row(m)
|
||||||
engine.render.draw_rect(m.rect.x, m.rect.y, m.rect.w, m.rect.h, color)
|
end
|
||||||
local lw = engine.render.measure_text(m.name, FONT_BUTTON)
|
|
||||||
engine.render.draw_text(m.name, m.rect.x + (m.rect.w - lw) / 2, m.rect.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)
|
-- Tooltip: detected after all rows have populated their rects.
|
||||||
|
local tip = nil
|
||||||
|
for _, m in ipairs(modules) do
|
||||||
|
tip = tooltip_for(m, mx, my)
|
||||||
|
if tip then break end
|
||||||
|
end
|
||||||
|
if tip then
|
||||||
|
icons.draw_tooltip(mx, my, tip, screen_w, screen_h)
|
||||||
end
|
end
|
||||||
end
|
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, (screen_h - modal_h) / 2, modal_w, modal_h, COLOR_MODAL)
|
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)
|
local pw = engine.render.measure_text(QUIT_PROMPT, FONT_BUTTON)
|
||||||
engine.render.draw_text(QUIT_PROMPT, (screen_w - pw) / 2, screen_h / 2 - 24, FONT_BUTTON, COLOR_TEXT)
|
engine.render.draw_text(QUIT_PROMPT, (screen_w - pw) / 2,
|
||||||
|
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]
|
||||||
@@ -135,7 +451,8 @@ function render(ctx)
|
|||||||
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 = (key == "yes") and YES_LABEL or NO_LABEL
|
||||||
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, b.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)
|
engine.render.draw_text(label, b.x + (b.w - lw) / 2,
|
||||||
|
b.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"id": "lib-management.launcher",
|
"id": "lib-management.launcher",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"kind": "module",
|
"kind": "module",
|
||||||
"api": "^0.1",
|
"api": "^0.1",
|
||||||
"ci_frames": 10,
|
"ci_frames": 10,
|
||||||
"name": "Sporel Launcher",
|
"name": "Sporel Launcher",
|
||||||
"entry_point": "init.lua",
|
"entry_point": "init.lua",
|
||||||
"deps": [
|
"deps": [
|
||||||
{ "id": "lib-core.input", "version": "0.4.0" }
|
{ "id": "lib-core.input", "version": "0.4.0" },
|
||||||
|
{ "id": "lib-core.git", "version": "0.2.0" },
|
||||||
|
{ "id": "lib-management.dep-fetcher", "version": "0.1.2" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user