From 94b55c42f2887c01063abbfbc1f831d814d92a57 Mon Sep 17 00:00:00 2001 From: Calic Date: Thu, 11 Jun 2026 02:26:09 +0200 Subject: [PATCH] feat(launcher): hero carousel with auto-rotate + manual nav panels/carousel.lua holds per-module state (index, timer, manual pause, texture cache). Auto-rotate at carousel_interval_seconds (5s default, frontmatter-overrideable). Click on overlay arrows steps and pauses auto-rotate for 30s. Max 7 indicator dots, then 'X / N' overlay (L-Q4). Single-teaser hides the arrows and dots. Zero-teaser falls through to ctx.default_teaser_texture which L.10 ships. --- init.lua | 20 ++++++++ panels/carousel.lua | 108 ++++++++++++++++++++++++++++++++++++++++++++ panels/detail.lua | 20 ++++++-- 3 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 panels/carousel.lua diff --git a/init.lua b/init.lua index a07bf75..f44b726 100644 --- a/init.lua +++ b/init.lua @@ -14,6 +14,7 @@ end local list_panel = require_panel("list") local detail_panel = require_panel("detail") +local carousel = require_panel("carousel") local STATE_LIST, STATE_QUIT, EXIT = fsm.STATE_LIST, fsm.STATE_QUIT_CONFIRM, fsm.EXIT local state = STATE_LIST @@ -255,6 +256,18 @@ function init(ctx) end end + -- L.5: per-module carousel state. Reads teaser_images + + -- carousel_interval_seconds from the cached manifest.launcher.md + -- meta. Modules without teasers still get an empty-state for the + -- detail panel to render the fallback texture. + ctx_panels.carousel_mod = carousel + for _, entry in ipairs(ctx_panels.module_entries) do + local mc = ctx_panels.manifest_cache[entry.id] + local paths = (mc and mc.meta and mc.meta.teaser_images) or {} + local interval = (mc and mc.meta and mc.meta.carousel_interval_seconds) or 5.0 + ctx_panels.carousel_state[entry.id] = carousel.new_state(entry.id, paths, interval) + end + local wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON) local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON) local qbtn_w = math.max(wy, wn) + 2 * PAD_X @@ -298,6 +311,13 @@ function update(ctx, dt) end end + -- L.5: pump only the selected module's carousel (perf — no point + -- advancing offscreen rotation timers). + if ctx_panels.selected_entry and ctx_panels.selected_entry ~= "news" then + local s = ctx_panels.carousel_state[ctx_panels.selected_entry] + if s then ctx_panels.carousel_mod.update(s, dt) end + end + local mx, my = engine.input.get_mouse_pos() if state == STATE_LIST then diff --git a/panels/carousel.lua b/panels/carousel.lua new file mode 100644 index 0000000..3b1b20f --- /dev/null +++ b/panels/carousel.lua @@ -0,0 +1,108 @@ +local M = {} + +local DEFAULT_INTERVAL_S = 5.0 +local MANUAL_PAUSE_S = 30.0 +local MAX_DOTS = 7 +local ARROW_W = 32 +local DOT_R = 4 +local DOT_GAP = 6 +local COLOR_ARROW_BG = 0x00000080 +local COLOR_ARROW_FG = 0xE0E0E0FF +local COLOR_DOT = 0xC0C0C060 +local COLOR_DOT_ACTIVE = 0xFFFFFFFF +local COLOR_OVERLAY = 0xE0E0E0FF +local COLOR_FALLBACK_BG = 0x303030FF + +function M.new_state(module_id, teaser_paths, interval) + return { + module_id = module_id, + teaser_paths = teaser_paths or {}, + textures = {}, -- index -> texture or nil if load-failed + loaded = {}, -- index -> true once loaded + index = 1, + timer = 0, + interval = interval or DEFAULT_INTERVAL_S, + manual_pause = 0, + left_rect = nil, + right_rect = nil, + } +end + +local function ensure_texture(s, i, default_texture) + if s.loaded[i] then return s.textures[i] end + s.loaded[i] = true + local path = s.teaser_paths[i] + if not path then s.textures[i] = default_texture; return default_texture end + local tex, err = engine.module.load_texture(s.module_id, path) + s.textures[i] = tex or default_texture + return s.textures[i] +end + +function M.update(s, dt) + if #s.teaser_paths < 2 then return end + if s.manual_pause > 0 then + s.manual_pause = s.manual_pause - dt + return + end + s.timer = s.timer + dt + if s.timer >= s.interval then + s.timer = 0 + s.index = s.index + 1 + if s.index > #s.teaser_paths then s.index = 1 end + end +end + +function M.render(s, x, y, w, h, default_texture) + -- Background (clipping is up to the parent panel — we just clip-render). + engine.render.draw_rect(x, y, w, h, COLOR_FALLBACK_BG) + + local total = math.max(1, #s.teaser_paths) + local tex = ensure_texture(s, s.index, default_texture) or default_texture + if tex then engine.render.draw_texture(tex, x, y) end + + if total > 1 then + -- Arrows. + s.left_rect = { x = x, y = y + h / 2 - 16, w = ARROW_W, h = 32 } + s.right_rect = { x = x + w - ARROW_W, y = y + h / 2 - 16, w = ARROW_W, h = 32 } + engine.render.draw_rect(s.left_rect.x, s.left_rect.y, ARROW_W, 32, COLOR_ARROW_BG) + engine.render.draw_rect(s.right_rect.x, s.right_rect.y, ARROW_W, 32, COLOR_ARROW_BG) + engine.render.draw_text("<", s.left_rect.x + 10, s.left_rect.y + 6, 20, COLOR_ARROW_FG) + engine.render.draw_text(">", s.right_rect.x + 10, s.right_rect.y + 6, 20, COLOR_ARROW_FG) + + -- Dots. + local dot_count = math.min(total, MAX_DOTS) + local dots_w = dot_count * (DOT_R * 2) + (dot_count - 1) * DOT_GAP + local dot_x = x + (w - dots_w) / 2 + local dot_y = y + h - 16 + for i = 1, dot_count do + local col = (i == s.index) and COLOR_DOT_ACTIVE or COLOR_DOT + engine.render.draw_rect(dot_x, dot_y, DOT_R * 2, DOT_R * 2, col) + dot_x = dot_x + DOT_R * 2 + DOT_GAP + end + if total > MAX_DOTS then + local label = string.format("%d / %d", s.index, total) + engine.render.draw_text(label, x + w - 80, dot_y - 4, 14, COLOR_OVERLAY) + end + end +end + +function M.handle_click(s, mx, my) + if not s or not s.left_rect then return false end + if engine.spatial.aabb_contains_point(s.left_rect, mx, my) then + s.index = s.index - 1 + if s.index < 1 then s.index = #s.teaser_paths end + s.timer = 0 + s.manual_pause = MANUAL_PAUSE_S + return true + end + if engine.spatial.aabb_contains_point(s.right_rect, mx, my) then + s.index = s.index + 1 + if s.index > #s.teaser_paths then s.index = 1 end + s.timer = 0 + s.manual_pause = MANUAL_PAUSE_S + return true + end + return false +end + +return M diff --git a/panels/detail.lua b/panels/detail.lua index be46fba..939356f 100644 --- a/panels/detail.lua +++ b/panels/detail.lua @@ -58,10 +58,13 @@ function M.render(ctx) if not entry then return end local mc = ctx.manifest_cache[entry.id] or { meta = {}, blocks = {} } - -- Carousel hero placeholder: L.5 swaps this in. For now: a strip. + -- 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 - if ctx.carousel_state[entry.id] and ctx.carousel_mod then - -- L.5 implements this; render call lands later. + 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 @@ -115,7 +118,16 @@ function M.render(ctx) end function M.handle_click(ctx, mx, my) - -- L.5/L.7 add carousel-arrow + launch-button hit-tests. + -- L.5: carousel arrow hit-test. L.7 will add 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 return false end