feat(launcher): trim list rows to name+tags, scrollable detail panel

List rows showed the long `summary` (name+id+description fallback chain)
and overflowed. Now each row shows the module name with a small genre-
tags subline sourced from manifest.launcher.md (meta.tags, threaded onto
the entry in init). The list also scrolls properly now (wheel dispatch +
upper clamp) — it was wired but dead without a wheel binding.

Detail panel: description + dependency list ran off the bottom of the
window. The mid-section (title/description/deps) now scrolls inside a
begin_view/end_view scissor region between the fixed hero strip and the
fixed launch button; news view scrolls too. Both panels get an inline
scrollbar. Detail scroll resets on module switch.

Depends on engine.input.get_mouse_wheel (sporel-engine). README controls/
demonstrates synced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Calic
2026-06-11 11:58:43 +00:00
parent 2595759b3e
commit a7dc368712
4 changed files with 154 additions and 21 deletions

View File

@@ -11,6 +11,11 @@ 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
@@ -18,6 +23,38 @@ 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
@@ -46,11 +83,18 @@ function M.render(ctx)
return
end
-- News: render news.md.
-- 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 {}
md.render(body, p.x + PADDING, p.y + PADDING, p.w - 2 * PADDING, nil)
local end_y = md.render(body, p.x + PADDING, p.y + PADDING, p.w - 2 * PADDING, nil)
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
@@ -58,18 +102,27 @@ function M.render(ctx)
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
-- 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)
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)
engine.render.draw_rect(p.x, p.y, p.w, HERO_H, 0x404040FF)
end
local cur_y = p.y + hero_h + PADDING
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)
@@ -89,7 +142,6 @@ function M.render(ctx)
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),
@@ -111,6 +163,13 @@ function M.render(ctx)
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"
@@ -173,4 +232,10 @@ function M.handle_click(ctx, mx, my)
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