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.
This commit is contained in:
14
fsm.lua
14
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
|
||||||
|
|||||||
30
init.lua
30
init.lua
@@ -15,6 +15,7 @@ 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 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
|
||||||
@@ -339,6 +340,30 @@ function update(ctx, dt)
|
|||||||
|
|
||||||
local mx, my = engine.input.get_mouse_pos()
|
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 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")
|
||||||
@@ -370,6 +395,11 @@ 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 ctx_panels.modal_open == "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
|
||||||
|
|||||||
@@ -115,10 +115,22 @@ function M.render(ctx)
|
|||||||
if r and r.kind == "error" then ctx.list_tint = "error"
|
if r and r.kind == "error" then ctx.list_tint = "error"
|
||||||
elseif r and r.kind == "warn" then ctx.list_tint = "warn"
|
elseif r and r.kind == "warn" then ctx.list_tint = "warn"
|
||||||
end
|
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)
|
||||||
-- 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
|
if not ctx.selected_entry or ctx.selected_entry == "news" then
|
||||||
return false
|
return false
|
||||||
end
|
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
|
if s and ctx.carousel_mod and ctx.carousel_mod.handle_click(s, mx, my) then
|
||||||
return true
|
return true
|
||||||
end
|
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
|
return false
|
||||||
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 = "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
|
||||||
Reference in New Issue
Block a user