diff --git a/icons.lua b/icons.lua new file mode 100644 index 0000000..f8d39fa --- /dev/null +++ b/icons.lua @@ -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 diff --git a/init.lua b/init.lua index dcdeb41..a0d1049 100644 --- a/init.lua +++ b/init.lua @@ -1,14 +1,20 @@ local input = require("lib-core.input") --- 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 fsm = dofile(engine.module.dir_of("lib-management.launcher") .. "/fsm.lua") +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 + +-- 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 = 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 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). -- 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 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 local COLOR_HOVER = 0x606060FF local COLOR_TEXT = 0xE0E0E0FF -local COLOR_DIM = 0x000000B4 -- semi-transparent overlay +local COLOR_DIM = 0x000000B4 local COLOR_MODAL = 0x303030FF 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 = "", 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) 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() + + -- 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 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] = { id = m.id, version = m.version, - name = label, - rect = { x = (screen_w - btn_w) / 2, y = y, w = btn_w, h = btn_h } + name = m.name or m.id, + 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 end local wy, hy = engine.render.measure_text(YES_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 btn_h = math.max(hy, hn) + 2 * PAD_Y - local total_w = 2 * btn_w + 24 - quit_buttons.yes = { x = (screen_w - total_w) / 2, y = screen_h / 2 + 24, w = btn_w, h = btn_h } - quit_buttons.no = { x = (screen_w - total_w) / 2 + btn_w + 24, y = screen_h / 2 + 24, w = btn_w, h = btn_h } + 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 + 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 + qbtn_w + 24, y = screen_h / 2 + 24, w = qbtn_w, h = qbtn_h } input.bind("ui_click", { "mouse_left" }) input.bind("ui_back", { "escape" }) 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)) end @@ -75,6 +298,14 @@ function update(ctx, dt) return 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() if state == STATE_LIST then @@ -82,8 +313,20 @@ function update(ctx, dt) state = fsm.next(state, "esc") elseif input.was_action_pressed("ui_click") then for _, m in ipairs(modules) do - if hit(m.rect, mx, my) then - engine.switch_module(m.id) + -- 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 end @@ -103,31 +346,104 @@ 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) + 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) + engine.render.draw_text(EMPTY_LABEL, (screen_w - ew) / 2, + screen_h / 2, FONT_BUTTON, COLOR_TEXT) else for _, m in ipairs(modules) do - 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) - 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) + draw_module_row(m) + end + + -- Tooltip: detected after all rows have populated their rects. + local tip = nil + for _, m in ipairs(modules) do + tip = tooltip_for(m, mx, my) + if tip then break end + end + if tip then + icons.draw_tooltip(mx, my, tip, screen_w, screen_h) end end if state == STATE_QUIT then engine.render.draw_rect(0, 0, screen_w, screen_h, COLOR_DIM) 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) - 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 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) local label = (key == "yes") and YES_LABEL or NO_LABEL 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 diff --git a/manifest.module b/manifest.module index 7565458..38552a1 100644 --- a/manifest.module +++ b/manifest.module @@ -1,12 +1,14 @@ { "id": "lib-management.launcher", - "version": "0.1.0", + "version": "0.2.0", "kind": "module", "api": "^0.1", "ci_frames": 10, "name": "Sporel Launcher", "entry_point": "init.lua", "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" } ] -} \ No newline at end of file +}