- markdown.lua: M.render now accepts an optional `t` 6th arg (ctx.t). When present, the image-missing fallback string is looked up via the new `image_missing_fmt` key in strings.lua. Callers without t (the launcher-test bytecopy) get the hardcoded English string verbatim, matching previous behavior. - panels/detail.lua: both md.render call sites now thread ctx.t through. - strings.lua: add image_missing_fmt = "[image missing: %s]". - icons.lua: deleted. The v0.2.0 status-glyph helper was replaced by dep_glyph_* string keys in v0.3.0; grep confirmed no remaining refs. - manifest.module: bump 0.3.2 -> 0.3.3.
242 lines
9.7 KiB
Lua
242 lines
9.7 KiB
Lua
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 HERO_H = 160 -- fixed hero strip (outside scroll region)
|
|
local BOTTOM_RESERVE = 70 -- fixed launch-button strip (outside scroll region)
|
|
local SCROLLBAR_W = 6
|
|
local COLOR_SCROLL_TRACK = 0x00000040
|
|
local COLOR_SCROLL_THUMB = 0x707070FF
|
|
|
|
local md -- lazy-loaded markdown renderer
|
|
|
|
local function ensure_md(launcher_dir)
|
|
if not md then md = dofile(launcher_dir .. "/markdown.lua") end
|
|
end
|
|
|
|
-- Camera that shifts content up by `scroll` px and clips to `view` (set up
|
|
-- via engine.render.begin_view). Drawing inside uses absolute screen-space
|
|
-- coords (zoom 1, target.x = view-center → x unchanged); only y scrolls.
|
|
local function scroll_cam(view, scroll)
|
|
return {
|
|
target_x = view.x + view.w / 2,
|
|
target_y = view.y + view.h / 2 + scroll,
|
|
zoom = 1,
|
|
}
|
|
end
|
|
|
|
-- Clamp the live scroll offset to [0, content - view] using last frame's
|
|
-- measured content height. Returns the clamped value (also written back).
|
|
local function clamp_scroll(ctx)
|
|
local max_scroll = math.max(0, (ctx.detail_content_h or 0) - (ctx.detail_view_h or 0))
|
|
ctx.detail_scroll_y = math.max(0, math.min(max_scroll, ctx.detail_scroll_y or 0))
|
|
return ctx.detail_scroll_y
|
|
end
|
|
|
|
-- Vertical scrollbar on the view's right edge (mirrors list.lua; kept inline
|
|
-- per the Lib-Cut criterion — cognitive locality over de-duplication).
|
|
local function draw_scrollbar(view, scroll, content_h)
|
|
if content_h <= view.h then return end
|
|
local track_x = view.x + view.w - SCROLLBAR_W - 2
|
|
engine.render.draw_rect(track_x, view.y, SCROLLBAR_W, view.h, COLOR_SCROLL_TRACK)
|
|
local thumb_h = math.max(24, view.h * view.h / content_h)
|
|
local max_scroll = content_h - view.h
|
|
local frac = (max_scroll > 0) and (scroll / max_scroll) or 0
|
|
local thumb_y = view.y + frac * (view.h - thumb_h)
|
|
engine.render.draw_rect(track_x, thumb_y, SCROLLBAR_W, thumb_h, COLOR_SCROLL_THUMB)
|
|
end
|
|
|
|
local function status_glyph(ctx, dep_status)
|
|
if dep_status == "ok" then return ctx.t("dep_glyph_ok"), COLOR_OK end
|
|
if dep_status == "warn" then return ctx.t("dep_glyph_warn"), COLOR_WARN end
|
|
if dep_status == "error" then return ctx.t("dep_glyph_err"), COLOR_ERR end
|
|
return ctx.t("dep_glyph_unknown"), 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 (scrollable, full panel — no hero/launch strip).
|
|
if ctx.selected_entry == "news" then
|
|
ensure_md(ctx.launcher_dir)
|
|
local view = { x = p.x, y = p.y, w = p.w, h = p.h }
|
|
local scroll = clamp_scroll(ctx)
|
|
engine.render.begin_view(view, scroll_cam(view, scroll))
|
|
local body = ctx.news_body_blocks or {}
|
|
local end_y = md.render(body, p.x + PADDING, p.y + PADDING, p.w - 2 * PADDING, nil, ctx.t)
|
|
engine.render.end_view()
|
|
ctx.detail_content_h = (end_y - view.y) + PADDING
|
|
ctx.detail_view_h = view.h
|
|
draw_scrollbar(view, scroll, ctx.detail_content_h)
|
|
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) — fixed, above the scroll region. Renders bg +
|
|
-- texture + arrows + dots. Zero-teaser modules render the
|
|
-- default_teaser_texture (L.10) on top of the COLOR_FALLBACK_BG strip.
|
|
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 r = entry.status.dep_check_result
|
|
|
|
-- Scroll region: everything between hero and the fixed launch strip.
|
|
local view = {
|
|
x = p.x, y = p.y + HERO_H,
|
|
w = p.w, h = p.h - HERO_H - BOTTOM_RESERVE,
|
|
}
|
|
local scroll = clamp_scroll(ctx)
|
|
engine.render.begin_view(view, scroll_cam(view, scroll))
|
|
|
|
local cur_y = view.y + 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, ctx.t)
|
|
|
|
cur_y = cur_y + 12
|
|
|
|
-- Dep list — load-order from manifest.module per L-Q6.
|
|
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 dep_glyph_warn + breathing space
|
|
for _, d in ipairs(r.deps) do
|
|
local glyph, col = status_glyph(ctx, 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
|
|
|
|
engine.render.end_view()
|
|
|
|
-- Record content/viewport extent for scroll-clamp (next frame) + bar.
|
|
ctx.detail_content_h = (cur_y - view.y) + PADDING
|
|
ctx.detail_view_h = view.h
|
|
draw_scrollbar(view, scroll, ctx.detail_content_h)
|
|
|
|
-- 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.errors or {}) + #(r.conflicts or {})) 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,
|
|
}
|
|
-- C1: signal init.lua to dispatch open_conflict on the FSM
|
|
-- so state actually transitions into STATE_MODAL_CONFLICT.
|
|
ctx._pending_modal_open = "conflict"
|
|
end
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
function M.handle_scroll(ctx, dy)
|
|
local max_scroll = math.max(0, (ctx.detail_content_h or 0) - (ctx.detail_view_h or 0))
|
|
ctx.detail_scroll_y = math.max(0,
|
|
math.min(max_scroll, (ctx.detail_scroll_y or 0) - dy * 24))
|
|
end
|
|
|
|
return M
|