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.
This commit is contained in:
20
init.lua
20
init.lua
@@ -14,6 +14,7 @@ end
|
|||||||
|
|
||||||
local list_panel = require_panel("list")
|
local list_panel = require_panel("list")
|
||||||
local detail_panel = require_panel("detail")
|
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_LIST, STATE_QUIT, EXIT = fsm.STATE_LIST, fsm.STATE_QUIT_CONFIRM, fsm.EXIT
|
||||||
local state = STATE_LIST
|
local state = STATE_LIST
|
||||||
@@ -255,6 +256,18 @@ function init(ctx)
|
|||||||
end
|
end
|
||||||
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 wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON)
|
||||||
local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON)
|
local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON)
|
||||||
local qbtn_w = math.max(wy, wn) + 2 * PAD_X
|
local qbtn_w = math.max(wy, wn) + 2 * PAD_X
|
||||||
@@ -298,6 +311,13 @@ function update(ctx, dt)
|
|||||||
end
|
end
|
||||||
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()
|
local mx, my = engine.input.get_mouse_pos()
|
||||||
|
|
||||||
if state == STATE_LIST then
|
if state == STATE_LIST then
|
||||||
|
|||||||
108
panels/carousel.lua
Normal file
108
panels/carousel.lua
Normal file
@@ -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
|
||||||
@@ -58,10 +58,13 @@ 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 = {} }
|
||||||
|
|
||||||
-- 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
|
local hero_h = 160
|
||||||
if ctx.carousel_state[entry.id] and ctx.carousel_mod then
|
local s = ctx.carousel_state[entry.id]
|
||||||
-- L.5 implements this; render call lands later.
|
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
|
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
|
||||||
@@ -115,7 +118,16 @@ function M.render(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function M.handle_click(ctx, mx, my)
|
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
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user