From 89b7b060d1de34a033c88ee22e4403e9d9868958 Mon Sep 17 00:00:00 2001 From: Calic Date: Thu, 11 Jun 2026 02:54:55 +0200 Subject: [PATCH] feat(launcher): launch button + dep-conflict modal Detail-panel bottom-right Launch button: switches to the selected module via engine.switch_module if dep-check is clean, otherwise opens a modal listing conflicts with Cancel + 'Launch anyway'. The latter is greyed out unless engine.module.switch_module_supports_fallback is exposed (separate engine slice). FSM adds STATE_MODAL_CONFLICT with transitions to/from STATE_LIST via cancel/launch_anyway/esc. Generic panels/modal.lua takes a def-table (title/body/buttons) and returns the clicked button id from handle_click; reusable for future modals beyond the conflict case. --- fsm.lua | 28 +++++++++++------------ init.lua | 30 +++++++++++++++++++++++++ panels/detail.lua | 42 +++++++++++++++++++++++++++++++++- panels/modal.lua | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 panels/modal.lua diff --git a/fsm.lua b/fsm.lua index 939222c..879592d 100644 --- a/fsm.lua +++ b/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 = {} -M.STATE_LIST = "list" -M.STATE_QUIT_CONFIRM = "quit_confirm" -M.EXIT = "exit" -- sentinel: caller should engine.exit(0) +M.STATE_LIST = "list" +M.STATE_QUIT_CONFIRM = "quit_confirm" +M.STATE_MODAL_CONFLICT = "modal_conflict" +M.EXIT = "exit" --- Events: "esc", "enter", "click_yes", "click_no", "click_module" function M.next(state, event) if state == M.STATE_LIST then - if event == "esc" then return M.STATE_QUIT_CONFIRM end - return M.STATE_LIST + if event == "esc" then return M.STATE_QUIT_CONFIRM end + if event == "open_conflict" then return M.STATE_MODAL_CONFLICT end elseif state == M.STATE_QUIT_CONFIRM then - if event == "esc" then return M.STATE_LIST end - if event == "enter" then return M.EXIT end - if event == "click_yes" then return M.EXIT end - if event == "click_no" then return M.STATE_LIST end - return M.STATE_QUIT_CONFIRM + if event == "esc" then return M.STATE_LIST end + if event == "enter" then return M.EXIT end + if event == "click_yes" then return M.EXIT end + if event == "click_no" then return M.STATE_LIST end + elseif state == M.STATE_MODAL_CONFLICT then + if event == "esc" then return M.STATE_LIST end + if event == "cancel" then return M.STATE_LIST end + if event == "launch_anyway" then return M.STATE_LIST end -- side-effect end return state end diff --git a/init.lua b/init.lua index 93c9ef0..7d83680 100644 --- a/init.lua +++ b/init.lua @@ -15,6 +15,7 @@ end local list_panel = require_panel("list") local detail_panel = require_panel("detail") local carousel = require_panel("carousel") +local modal_panel = require_panel("modal") local STATE_LIST, STATE_QUIT, EXIT = fsm.STATE_LIST, fsm.STATE_QUIT_CONFIRM, fsm.EXIT local state = STATE_LIST @@ -339,6 +340,30 @@ function update(ctx, dt) local mx, my = engine.input.get_mouse_pos() + -- 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 ctx_panels.modal_open == "conflict" then + if input.was_action_pressed("ui_back") then + ctx_panels.modal_open = nil + state = fsm.next(state, "esc") + elseif input.was_action_pressed("ui_click") then + local id = modal_panel.handle_click(ctx_panels.modal_def, mx, my) + if id == "cancel" then + ctx_panels.modal_open = nil + state = fsm.next(state, "cancel") + elseif id == "launch_anyway" then + local mid = ctx_panels.modal_def.module_id + ctx_panels.modal_open = nil + state = fsm.next(state, "launch_anyway") + if engine.module.switch_module_supports_fallback then + engine.switch_module(mid, { mode = "fallback" }) + end + end + end + return + end + if state == STATE_LIST then if input.was_action_pressed("ui_back") then state = fsm.next(state, "esc") @@ -370,6 +395,11 @@ function render(ctx) list_panel.render(ctx_panels) detail_panel.render(ctx_panels) + -- L.7: Conflict modal renders above the panels (backdrop dims them). + if ctx_panels.modal_open == "conflict" then + modal_panel.render(ctx_panels.modal_def, screen_w, screen_h) + 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 diff --git a/panels/detail.lua b/panels/detail.lua index 939356f..12df29e 100644 --- a/panels/detail.lua +++ b/panels/detail.lua @@ -115,10 +115,22 @@ function M.render(ctx) if r and r.kind == "error" then ctx.list_tint = "error" elseif r and r.kind == "warn" then ctx.list_tint = "warn" end + + -- L.7: Launch button at the bottom-right of the detail panel. Label + -- runs through ctx.t() so L.8's strings.lua picks it up once wired. + local label = ctx.t("launch_module") + local lw, lh = engine.render.measure_text(label, 18) + local btn_w = lw + 32 + local btn_h = lh + 16 + local btn_x = p.x + p.w - PADDING - btn_w + local btn_y = p.y + p.h - PADDING - btn_h + engine.render.draw_rect(btn_x, btn_y, btn_w, btn_h, 0x405080FF) + engine.render.draw_text(label, btn_x + 16, btn_y + 8, 18, 0xFFFFFFFF) + ctx._launch_btn = { x = btn_x, y = btn_y, w = btn_w, h = btn_h } end function M.handle_click(ctx, mx, my) - -- L.5: carousel arrow hit-test. L.7 will add launch-button. + -- 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 @@ -128,6 +140,34 @@ function M.handle_click(ctx, mx, my) 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.missing 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, + } + ctx.modal_open = "conflict" + end + return true + end return false end diff --git a/panels/modal.lua b/panels/modal.lua new file mode 100644 index 0000000..f0643d5 --- /dev/null +++ b/panels/modal.lua @@ -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 = "Confirm", +-- body = "text or array of lines", +-- buttons = { { id="cancel", label="Cancel" }, { id="ok", label="Launch anyway", disabled=false } }, +-- } +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