diff --git a/README.md b/README.md index a87717f..5fe3f11 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,20 @@ graph LR ## Controls -- **Mouse-Left**: Click a module-button to launch it; click confirm/cancel in the quit-modal. -- **Enter**: Confirm quit when the modal is open. -- **Escape**: Open the quit-modal from the chooser; cancel the modal when open. +- **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. +- **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. +- **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 - `engine.module.list` discovery of installed modules from the modules-dir. - `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. - 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 diff --git a/init.lua b/init.lua index 0d0746f..ea44b75 100644 --- a/init.lua +++ b/init.lua @@ -199,7 +199,10 @@ function init(ctx) module_entries = {}, -- filled below from engine.module.list news_entry = { title = nil, body_path = "news.md" }, 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_panel_rect = nil, -- set below from window size detail_panel_rect = nil, @@ -263,6 +266,7 @@ function init(ctx) local meta, blocks = manifest_loader.load_for(entry.id) ctx_panels.manifest_cache[entry.id] = { meta = meta, blocks = blocks } 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 -- detail-panel can render per-dep status glyphs in load order @@ -392,6 +396,16 @@ function update(ctx, dt) end 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 state = fsm.next(state, "esc") elseif input.was_action_pressed("ui_click") then diff --git a/panels/detail.lua b/panels/detail.lua index 5012ca2..6601f16 100644 --- a/panels/detail.lua +++ b/panels/detail.lua @@ -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 diff --git a/panels/list.lua b/panels/list.lua index 24b877d..0000802 100644 --- a/panels/list.lua +++ b/panels/list.lua @@ -9,7 +9,34 @@ local COLOR_ROW_HOVER = 0x404040FF local COLOR_ROW_ACTIVE = 0x505080FF local COLOR_TEXT = 0xE0E0E0FF local COLOR_DIM = 0xA0A0A0FF +local COLOR_TAGS = 0x8090A0FF 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. function M.row_rect(ctx, i) @@ -22,9 +49,16 @@ function M.row_rect(ctx, i) } 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) - 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 function M.render(ctx) @@ -55,21 +89,36 @@ function M.render(ctx) local col = COLOR_ROW 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) + -- Module name on top, genre tags small below. 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 entry.rect = rect 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 function M.handle_click(ctx, mx, my) 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 end for _, entry in ipairs(ctx.module_entries) do 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 end end @@ -77,7 +126,9 @@ function M.handle_click(ctx, mx, my) end 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 return M