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:
11
README.md
11
README.md
@@ -22,17 +22,20 @@ graph LR
|
|||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
- **Mouse-Left**: Click a module-button to launch it; click confirm/cancel in the quit-modal.
|
- **Mouse-Left**: Select a module-row in the left list to show its detail; click Launch in the detail panel to launch it; carousel arrows; confirm/cancel in the modals.
|
||||||
- **Enter**: Confirm quit when the modal is open.
|
- **Mouse-Wheel**: Scroll the panel under the cursor — the module list (left) or the detail content (right). Detail content (description + dependencies) scrolls between the fixed hero strip and the fixed launch button.
|
||||||
- **Escape**: Open the quit-modal from the chooser; cancel the modal when open.
|
- **Enter**: Confirm quit when the quit-modal is open.
|
||||||
|
- **Escape**: Open the quit-modal from the chooser; cancel the conflict/quit-modal when open.
|
||||||
|
|
||||||
## Demonstrates
|
## Demonstrates
|
||||||
|
|
||||||
- `engine.module.list` discovery of installed modules from the modules-dir.
|
- `engine.module.list` discovery of installed modules from the modules-dir.
|
||||||
- `engine.switch_module` runtime swap from the launcher to the selected module.
|
- `engine.switch_module` runtime swap from the launcher to the selected module.
|
||||||
|
- Master-detail UI: left list shows each module's **name** with a small genre-**tags** subline (from `manifest.launcher.md`); right panel shows hero carousel, rendered-markdown description, and per-dependency status.
|
||||||
|
- Scrollable panels via `engine.render.begin_view`/`end_view` (scissor-clipped) driven by `engine.input.get_mouse_wheel`, with inline scrollbars.
|
||||||
- Simple text + rect rendering via `engine.render` for the chooser UI.
|
- Simple text + rect rendering via `engine.render` for the chooser UI.
|
||||||
- Input-action binding through `lib-core.input` (`ui_click`, `ui_back`, `ui_confirm`).
|
- Input-action binding through `lib-core.input` (`ui_click`, `ui_back`, `ui_confirm`).
|
||||||
- Modal-state FSM (chooser <-> quit-modal) factored into a sibling `fsm.lua` loaded via `engine.module.dir_of`.
|
- Modal-state FSM (chooser <-> quit/conflict-modal) factored into a sibling `fsm.lua` loaded via `engine.module.dir_of`.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
16
init.lua
16
init.lua
@@ -199,7 +199,10 @@ function init(ctx)
|
|||||||
module_entries = {}, -- filled below from engine.module.list
|
module_entries = {}, -- filled below from engine.module.list
|
||||||
news_entry = { title = nil, body_path = "news.md" },
|
news_entry = { title = nil, body_path = "news.md" },
|
||||||
selected_entry = nil,
|
selected_entry = nil,
|
||||||
scroll_y = 0,
|
scroll_y = 0, -- list-panel vertical scroll
|
||||||
|
detail_scroll_y = 0, -- detail-panel content scroll
|
||||||
|
detail_content_h = 0, -- set by detail.render each frame
|
||||||
|
detail_view_h = 0, -- scrollable viewport height, set by detail.render
|
||||||
list_tint = nil, -- nil | "warn" | "error" — set in L.4
|
list_tint = nil, -- nil | "warn" | "error" — set in L.4
|
||||||
list_panel_rect = nil, -- set below from window size
|
list_panel_rect = nil, -- set below from window size
|
||||||
detail_panel_rect = nil,
|
detail_panel_rect = nil,
|
||||||
@@ -263,6 +266,7 @@ function init(ctx)
|
|||||||
local meta, blocks = manifest_loader.load_for(entry.id)
|
local meta, blocks = manifest_loader.load_for(entry.id)
|
||||||
ctx_panels.manifest_cache[entry.id] = { meta = meta, blocks = blocks }
|
ctx_panels.manifest_cache[entry.id] = { meta = meta, blocks = blocks }
|
||||||
entry.summary = meta.summary
|
entry.summary = meta.summary
|
||||||
|
entry.tags = meta.tags -- L-fix: list-row subline (genre tags)
|
||||||
|
|
||||||
-- L.4: also surface manifest.module::deps[] verbatim so the
|
-- L.4: also surface manifest.module::deps[] verbatim so the
|
||||||
-- detail-panel can render per-dep status glyphs in load order
|
-- detail-panel can render per-dep status glyphs in load order
|
||||||
@@ -392,6 +396,16 @@ function update(ctx, dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if state == STATE_LIST then
|
if state == STATE_LIST then
|
||||||
|
-- Mouse-wheel scroll: route to whichever panel the cursor is over.
|
||||||
|
local wheel = engine.input.get_mouse_wheel()
|
||||||
|
if wheel ~= 0 then
|
||||||
|
if hit(ctx_panels.detail_panel_rect, mx, my) then
|
||||||
|
detail_panel.handle_scroll(ctx_panels, wheel)
|
||||||
|
elseif hit(ctx_panels.list_panel_rect, mx, my) then
|
||||||
|
list_panel.handle_scroll(ctx_panels, wheel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
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")
|
||||||
elseif input.was_action_pressed("ui_click") then
|
elseif input.was_action_pressed("ui_click") then
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ local FONT_TITLE = 28
|
|||||||
local FONT_SUB = 16
|
local FONT_SUB = 16
|
||||||
local FONT_BODY = 14
|
local FONT_BODY = 14
|
||||||
local FONT_DEP = 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 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
|
if not md then md = dofile(launcher_dir .. "/markdown.lua") end
|
||||||
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)
|
local function status_glyph(ctx, dep_status)
|
||||||
if dep_status == "ok" then return ctx.t("dep_glyph_ok"), COLOR_OK end
|
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 == "warn" then return ctx.t("dep_glyph_warn"), COLOR_WARN end
|
||||||
@@ -46,11 +83,18 @@ function M.render(ctx)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- News: render news.md.
|
-- News: render news.md (scrollable, full panel — no hero/launch strip).
|
||||||
if ctx.selected_entry == "news" then
|
if ctx.selected_entry == "news" then
|
||||||
ensure_md(ctx.launcher_dir)
|
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 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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -58,18 +102,27 @@ function M.render(ctx)
|
|||||||
if not entry then return end
|
if not entry then return end
|
||||||
local mc = ctx.manifest_cache[entry.id] or { meta = {}, blocks = {} }
|
local mc = ctx.manifest_cache[entry.id] or { meta = {}, blocks = {} }
|
||||||
|
|
||||||
-- Hero carousel (L.5). Renders bg + texture + arrows + dots.
|
-- Hero carousel (L.5) — fixed, above the scroll region. Renders bg +
|
||||||
-- Zero-teaser modules render the default_teaser_texture (L.10)
|
-- texture + arrows + dots. Zero-teaser modules render the
|
||||||
-- on top of the COLOR_FALLBACK_BG strip.
|
-- default_teaser_texture (L.10) on top of the COLOR_FALLBACK_BG strip.
|
||||||
local hero_h = 160
|
|
||||||
local s = ctx.carousel_state[entry.id]
|
local s = ctx.carousel_state[entry.id]
|
||||||
if s and ctx.carousel_mod then
|
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
|
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
|
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.
|
-- Title row.
|
||||||
local title = entry.summary or entry.name or entry.id
|
local title = entry.summary or entry.name or entry.id
|
||||||
engine.render.draw_text(title, p.x + PADDING, cur_y, FONT_TITLE, COLOR_TEXT)
|
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
|
cur_y = cur_y + 12
|
||||||
|
|
||||||
-- Dep list — load-order from manifest.module per L-Q6.
|
-- 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
|
local dep_count = (r and r.deps and #r.deps) or 0
|
||||||
engine.render.draw_text(
|
engine.render.draw_text(
|
||||||
string.format("%s (%d)", ctx.t("dependencies"), dep_count),
|
string.format("%s (%d)", ctx.t("dependencies"), dep_count),
|
||||||
@@ -111,6 +163,13 @@ function M.render(ctx)
|
|||||||
end
|
end
|
||||||
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.
|
-- 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"
|
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"
|
||||||
@@ -173,4 +232,10 @@ function M.handle_click(ctx, mx, my)
|
|||||||
return false
|
return false
|
||||||
end
|
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
|
return M
|
||||||
|
|||||||
@@ -9,7 +9,34 @@ local COLOR_ROW_HOVER = 0x404040FF
|
|||||||
local COLOR_ROW_ACTIVE = 0x505080FF
|
local COLOR_ROW_ACTIVE = 0x505080FF
|
||||||
local COLOR_TEXT = 0xE0E0E0FF
|
local COLOR_TEXT = 0xE0E0E0FF
|
||||||
local COLOR_DIM = 0xA0A0A0FF
|
local COLOR_DIM = 0xA0A0A0FF
|
||||||
|
local COLOR_TAGS = 0x8090A0FF
|
||||||
local FONT = 18
|
local FONT = 18
|
||||||
|
local FONT_TAGS = 13
|
||||||
|
local SCROLLBAR_W = 6
|
||||||
|
local COLOR_SCROLL_TRACK = 0x00000040
|
||||||
|
local COLOR_SCROLL_THUMB = 0x707070FF
|
||||||
|
|
||||||
|
-- Total scrollable content height for the current entry set (news row +
|
||||||
|
-- all module rows). Used for scroll-clamp + scrollbar sizing.
|
||||||
|
local function content_height(ctx)
|
||||||
|
local rows = #ctx.module_entries + (ctx.news_entry and 1 or 0)
|
||||||
|
if rows == 0 then return 0 end
|
||||||
|
return rows * (ROW_HEIGHT + ROW_GAP) - ROW_GAP + 2 * PADDING
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw a vertical scrollbar inset on the panel's right edge when content
|
||||||
|
-- overflows the viewport. Mirrors detail.lua's scrollbar (kept inline in
|
||||||
|
-- both panels for cognitive locality — see Lib-Cut criterion).
|
||||||
|
local function draw_scrollbar(p, scroll_y, content_h, view_h)
|
||||||
|
if content_h <= view_h then return end
|
||||||
|
local track_x = p.x + p.w - SCROLLBAR_W - 2
|
||||||
|
engine.render.draw_rect(track_x, p.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_y / max_scroll) or 0
|
||||||
|
local thumb_y = p.y + frac * (view_h - thumb_h)
|
||||||
|
engine.render.draw_rect(track_x, thumb_y, SCROLLBAR_W, thumb_h, COLOR_SCROLL_THUMB)
|
||||||
|
end
|
||||||
|
|
||||||
-- Returns the rect rect-table {x,y,w,h} of the row for entry index i.
|
-- Returns the rect rect-table {x,y,w,h} of the row for entry index i.
|
||||||
function M.row_rect(ctx, i)
|
function M.row_rect(ctx, i)
|
||||||
@@ -22,9 +49,16 @@ function M.row_rect(ctx, i)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Pick the display label per spec L-Q8: summary || name || id.
|
-- Row title: name only (the long `summary` moved to the detail panel).
|
||||||
local function label_for(entry)
|
local function label_for(entry)
|
||||||
return entry.summary or entry.name or entry.id
|
return entry.name or entry.id
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Compact genre-tag subline ("Mittelalter · Hightech"), or nil if the
|
||||||
|
-- module's manifest.launcher.md carries no tags.
|
||||||
|
local function tags_line(entry)
|
||||||
|
if not entry.tags or #entry.tags == 0 then return nil end
|
||||||
|
return table.concat(entry.tags, " · ")
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.render(ctx)
|
function M.render(ctx)
|
||||||
@@ -55,21 +89,36 @@ function M.render(ctx)
|
|||||||
local col = COLOR_ROW
|
local col = COLOR_ROW
|
||||||
if ctx.selected_entry == entry.id then col = COLOR_ROW_ACTIVE end
|
if ctx.selected_entry == entry.id then col = COLOR_ROW_ACTIVE end
|
||||||
engine.render.draw_rect(rect.x, rect.y, rect.w, rect.h, col)
|
engine.render.draw_rect(rect.x, rect.y, rect.w, rect.h, col)
|
||||||
|
-- Module name on top, genre tags small below.
|
||||||
engine.render.draw_text(label_for(entry),
|
engine.render.draw_text(label_for(entry),
|
||||||
rect.x + PADDING, rect.y + PADDING, FONT, COLOR_TEXT)
|
rect.x + PADDING, rect.y + 8, FONT, COLOR_TEXT)
|
||||||
|
local tags = tags_line(entry)
|
||||||
|
if tags then
|
||||||
|
engine.render.draw_text(tags,
|
||||||
|
rect.x + PADDING, rect.y + 8 + FONT + 6, FONT_TAGS, COLOR_TAGS)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
entry.rect = rect
|
entry.rect = rect
|
||||||
end
|
end
|
||||||
|
|
||||||
|
draw_scrollbar(p, ctx.scroll_y, content_height(ctx), p.h)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Selecting a different entry resets the detail panel's scroll so the new
|
||||||
|
-- module's content starts from the top.
|
||||||
|
local function select(ctx, id)
|
||||||
|
if ctx.selected_entry ~= id then ctx.detail_scroll_y = 0 end
|
||||||
|
ctx.selected_entry = id
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.handle_click(ctx, mx, my)
|
function M.handle_click(ctx, mx, my)
|
||||||
if ctx.news_entry and engine.spatial.aabb_contains_point(ctx.news_entry.rect, mx, my) then
|
if ctx.news_entry and engine.spatial.aabb_contains_point(ctx.news_entry.rect, mx, my) then
|
||||||
ctx.selected_entry = "news"
|
select(ctx, "news")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
for _, entry in ipairs(ctx.module_entries) do
|
for _, entry in ipairs(ctx.module_entries) do
|
||||||
if entry.rect and engine.spatial.aabb_contains_point(entry.rect, mx, my) then
|
if entry.rect and engine.spatial.aabb_contains_point(entry.rect, mx, my) then
|
||||||
ctx.selected_entry = entry.id
|
select(ctx, entry.id)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -77,7 +126,9 @@ function M.handle_click(ctx, mx, my)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function M.handle_scroll(ctx, dy)
|
function M.handle_scroll(ctx, dy)
|
||||||
ctx.scroll_y = math.max(0, ctx.scroll_y - dy * 24)
|
local p = ctx.list_panel_rect
|
||||||
|
local max_scroll = math.max(0, content_height(ctx) - p.h)
|
||||||
|
ctx.scroll_y = math.max(0, math.min(max_scroll, ctx.scroll_y - dy * 24))
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
Reference in New Issue
Block a user