local M = {} local PADDING = 24 local COLOR_BG = 0x282828FF local COLOR_TEXT = 0xE0E0E0FF local COLOR_DIM = 0xA0A0A0FF local COLOR_OK = 0x80C080FF 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(dep_status) if dep_status == "ok" then return "OK", COLOR_OK end if dep_status == "warn" then return "WARN", COLOR_WARN end if dep_status == "error" then return "ERR", COLOR_ERR end return "?", 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) local p = ctx.detail_panel_rect engine.render.draw_rect(p.x, p.y, p.w, p.h, COLOR_BG) if not ctx.selected_entry then if ctx.bg_texture then engine.render.draw_texture(ctx.bg_texture, p.x, p.y) end local hint = ctx.t("empty_hint") local w, h = engine.render.measure_text(hint, FONT_BODY) engine.render.draw_text(hint, p.x + (p.w - w) / 2, p.y + (p.h - h) / 2, FONT_BODY, COLOR_DIM) return end -- News: render news.md. if ctx.selected_entry == "news" then 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) 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) 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 "WARN" + breathing space for _, d in ipairs(r.deps) do local glyph, col = status_glyph(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 function M.handle_click(ctx, mx, my) -- 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.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 return M