initial: inventory-list-display v0.1.0 — vertical list widget on lib-core.panel

5 public functions (create, register_action, unregister_action,
set_label_resolver, set_icon_resolver) plus 2 test backdoors
(_test_get_rows, _test_resolve_icon). Reads lib-core.inventory-list
contents each render frame; right-click rows open context-menu via
panel.show_context_menu. Default action-set is empty.
This commit is contained in:
Calic
2026-06-13 23:23:48 +02:00
commit 0921a03ede
4 changed files with 594 additions and 0 deletions

345
init.lua Normal file
View File

@@ -0,0 +1,345 @@
-- =====================================================================
-- lib-core.inventory-list-display v0.1.0 — Inventory vertical-list widget
--
-- Sits on top of lib-core.panel and reads lib-core.inventory-list +
-- lib-core.composition. Provides a ready-to-register panel widget that
-- renders the contents of a composition list-container as a vertical
-- scrollable list of icon + label + count rows.
--
-- Data-libs (inventory-list, composition) remain headless; all UI
-- render/input logic lives here.
--
-- Public API:
-- display.create(container_entity, opts) -> widget_def
-- display.register_action(widget_def, label, callback)
-- display.unregister_action(widget_def, label)
-- display.set_label_resolver(widget_def, fn)
-- display.set_icon_resolver(widget_def, fn)
--
-- widget_def conforms to the panel widget contract:
-- widget_def.render(ctx)
-- widget_def.handle_input(ctx, event)
-- widget_def.title (string)
--
-- Engine surfaces used (render-phase only; not called headless):
-- engine.render.draw_rect(x, y, w, h, color)
-- engine.render.draw_text(text, x, y, size, color)
-- engine.render.measure_text(text, size) -> w, h
-- engine.render.draw_sprite_transform(tex, x, y, ..., u, v, uw, vh)
-- engine.module.load_texture(module_id, path) -> texture handle
--
-- NOTE on draw_texture_uv: the engine exposes draw_sprite_transform (used
-- in vagrant-skeleton for Atlas-UV rendering) rather than a dedicated
-- draw_texture_uv. v0.1 uses draw_sprite_transform with rot=0, scl=1 for
-- icon rendering. If the texture handle is not available at render-time
-- (e.g. because the test module does not call M.init), icon rendering falls
-- back to a placeholder rect + "?" text.
--
-- DEFERRED (v0.1 non-goals):
-- - Row scrolling (wheel event silently ignored)
-- - Stack-count display (stack_mode="stack" not supported)
-- - Pretty item names (no composition.template_of accessor in v0.1;
-- overridable via set_label_resolver)
-- - Row selection highlight (selection_color unused)
-- =====================================================================
local panel = require("lib-core.panel")
local composition = require("lib-core.composition")
local inventory = require("lib-core.inventory-list")
local M = {}
-- -----------------------------------------------------------------------
-- entity_id workaround helper
-- get_children() returns fresh userdata wrappers per call; engine entities
-- have no __eq metamethod. Use composition.reg_id as stable identity.
-- Mirrors the same pattern used in inventory-list/init.lua.
-- -----------------------------------------------------------------------
local function entity_id(e)
return e:get_property("composition.reg_id")
end
-- -----------------------------------------------------------------------
-- Default resolvers
-- -----------------------------------------------------------------------
-- default_icon_resolver(item) -> icon_table or nil
-- Reads sprite_atlas + sprite_uv.* from item properties.
-- Returns nil if sprite_atlas is not set (no icon for this item).
local function default_icon_resolver(item)
local atlas = item:get_property("sprite_atlas")
if not atlas or atlas == "" then return nil end
return {
atlas = atlas,
uv = {
x = item:get_property("sprite_uv.x"),
y = item:get_property("sprite_uv.y"),
w = item:get_property("sprite_uv.w"),
h = item:get_property("sprite_uv.h"),
},
}
end
-- default_label_resolver(item) -> string
-- v0.1: no public composition.template_of accessor exists. Module
-- overrides via set_label_resolver to provide pretty names.
local function default_label_resolver(item)
return "Item"
end
-- -----------------------------------------------------------------------
-- Module-local render helper
-- -----------------------------------------------------------------------
-- render_widget(widget_def, ctx)
-- Called each render frame while the widget is active (delegated from
-- widget_def.render). Reads inventory contents and draws one row per item.
-- ctx = { bounds = {x,y,w,h}, theme = table, is_focused = bool }
local function render_widget(widget_def, ctx)
local items = inventory.contents(widget_def._container)
widget_def._row_rects = {} -- clear last-frame hit-test state
for i, item in ipairs(items) do
local row_y = ctx.bounds.y + (i - 1) * ctx.theme.row_height
-- Resolve icon
local icon = widget_def._icon_resolver(item)
if icon ~= nil then
-- Atlas-UV icon rendering via draw_sprite_transform.
-- engine.module.load_texture loads the texture lazily; result is
-- a handle or nil if the engine is not in a context that allows it.
local ok, tex = pcall(engine.module.load_texture,
widget_def.widget_id, icon.atlas)
if ok and tex then
local uv = icon.uv
-- draw_sprite_transform(tex, x, y, rot, scl_x, scl_y,
-- origin_x, origin_y, tint, u, v, uw, vh)
-- rot=0, scl=1, origin at top-left (0,0), tint=white
engine.render.draw_sprite_transform(
tex,
ctx.bounds.x, row_y,
0, 1, 1,
0, 0,
0xFFFFFFFF,
uv.x, uv.y, uv.w, uv.h)
else
-- draw_sprite_transform not available (headless / test context)
-- or texture load failed: fall through to placeholder rect.
engine.render.draw_rect(ctx.bounds.x, row_y,
32, 32,
ctx.theme.text_color_dim)
engine.render.draw_text("?",
ctx.bounds.x + 12, row_y + 8,
ctx.theme.font_size_body,
ctx.theme.text_color)
end
else
-- No icon: draw 32×32 placeholder rect + "?" glyph
engine.render.draw_rect(ctx.bounds.x, row_y,
32, 32,
ctx.theme.text_color_dim)
engine.render.draw_text("?",
ctx.bounds.x + 12, row_y + 8,
ctx.theme.font_size_body,
ctx.theme.text_color)
end
-- Resolve label
local label = widget_def._label_resolver(item)
engine.render.draw_text(label,
ctx.bounds.x + 36, row_y + 4,
ctx.theme.font_size_body,
ctx.theme.text_color)
-- Count ("×1" — stack_mode="individual" only in v0.1)
local count_str = "\xc3\x971" -- UTF-8 "×1"
local text_w, _ = engine.render.measure_text(count_str, ctx.theme.font_size_body)
local count_x = ctx.bounds.x + ctx.bounds.w - text_w - ctx.theme.padding
engine.render.draw_text(count_str,
count_x, row_y + 4,
ctx.theme.font_size_body,
ctx.theme.text_color)
-- Store row rect for right-click hit-test in handle_widget_input
widget_def._row_rects[i] = {
x = ctx.bounds.x,
y = row_y,
w = ctx.bounds.w,
h = ctx.theme.row_height,
item = item,
}
end
end
-- -----------------------------------------------------------------------
-- Module-local context-menu helper
-- -----------------------------------------------------------------------
-- open_context_menu(widget_def, item, x, y)
-- Builds actions array from widget_def._actions and calls panel.show_context_menu.
-- Skips if no actions are registered (would open an empty menu).
local function open_context_menu(widget_def, item, x, y)
local actions = {}
-- pairs() iteration order is non-deterministic; for v0.2 consider
-- preserving registration order via an internal action-list-array
-- alongside _actions (when more actions per widget become common).
for label, callback in pairs(widget_def._actions) do
local cb = callback -- capture for closure
table.insert(actions, {
label = label,
callback = function(menu_ctx)
cb(item, {
container = widget_def._container,
close_menu = menu_ctx.close_menu,
refresh = function() end, -- v0.1: free (next frame re-reads contents)
})
end,
})
end
if #actions == 0 then return end
panel.show_context_menu(x, y, actions)
end
-- -----------------------------------------------------------------------
-- Module-local input helper
-- -----------------------------------------------------------------------
-- handle_widget_input(widget_def, ctx, event)
-- Dispatched by panel for each input event while widget is active.
-- Right-click on a row opens the context menu.
-- Wheel events are silently ignored (scroll deferred to v0.2).
local function handle_widget_input(widget_def, ctx, event)
if event.kind == "click" and event.button == "right" then
for _, rect in ipairs(widget_def._row_rects) do
if event.x >= rect.x and event.x < rect.x + rect.w
and event.y >= rect.y and event.y < rect.y + rect.h then
open_context_menu(widget_def, rect.item, event.x, event.y)
return
end
end
end
-- Other event kinds (wheel, key, left-click): silent ignore in v0.1.
-- Wheel scroll deferred to v0.2 (list-Inv typically fits on one screen).
end
-- -----------------------------------------------------------------------
-- Public API
-- -----------------------------------------------------------------------
--- M.create(container_entity, opts) -> widget_def
--- Creates an inventory-list widget bound to `container_entity`.
--- container_entity must be a composition-entity with container.kind="list".
--- opts = {
--- title = string (default "Inventory"),
--- widget_id = string (default "inventory"),
--- pause_on_open = bool (default false),
--- icon_resolver = function(item)->table|nil,
--- label_resolver= function(item)->string,
--- }
--- Returns a widget_def table conforming to the panel widget contract.
--- Loud-error if container_entity is nil or has no container block.
function M.create(container_entity, opts)
if container_entity == nil then
error("inventory-list-display.create: container_entity must not be nil")
end
local block = composition.get_container(container_entity)
if block == nil then
error("inventory-list-display.create: container has no container block" ..
" (template must declare container={kind='list'})")
end
-- Defensive double-belt: composition currently rejects non-list kinds at
-- template-load, but guard here in case that constraint is relaxed later.
if block.kind ~= "list" then
error(string.format(
"inventory-list-display.create: container.kind must be 'list' (got '%s')",
tostring(block.kind)))
end
opts = opts or {}
local widget_def = {
title = opts.title or "Inventory",
widget_id = opts.widget_id or "inventory",
pause_on_open = opts.pause_on_open or false,
_container = container_entity,
_actions = {}, -- label -> callback (sparse; default empty)
_icon_resolver = opts.icon_resolver or default_icon_resolver,
_label_resolver = opts.label_resolver or default_label_resolver,
_row_rects = {}, -- populated each render frame for hit-testing
}
widget_def.render = function(ctx)
render_widget(widget_def, ctx)
end
widget_def.handle_input = function(ctx, event)
handle_widget_input(widget_def, ctx, event)
end
return widget_def
end
--- M.register_action(widget_def, label, callback)
--- Registers a context-menu action for right-click on a row.
--- callback(item, context) where context = { container, close_menu, refresh }.
--- Loud-error on duplicate label or non-function callback.
function M.register_action(widget_def, label, callback)
if type(label) ~= "string" or label == "" then
error("inventory-list-display.register_action: label must be a non-empty string")
end
if type(callback) ~= "function" then
error("inventory-list-display.register_action: callback must be a function")
end
if widget_def._actions[label] ~= nil then
error("inventory-list-display.register_action: action '" .. label .. "' already registered")
end
widget_def._actions[label] = callback
end
--- M.unregister_action(widget_def, label)
--- Removes a previously-registered context-menu action.
--- Idempotent: no error if the label was never registered.
function M.unregister_action(widget_def, label)
widget_def._actions[label] = nil
end
--- M.set_label_resolver(widget_def, fn)
--- Replaces the label resolver for this widget.
--- fn(item) -> string (called each render frame per row)
--- Loud-error if fn is not a function.
function M.set_label_resolver(widget_def, fn)
if type(fn) ~= "function" then
error("inventory-list-display.set_label_resolver: fn must be a function")
end
widget_def._label_resolver = fn
end
--- M.set_icon_resolver(widget_def, fn)
--- Replaces the icon resolver for this widget.
--- fn(item) -> {atlas=string, uv={x,y,w,h}} or nil
--- Loud-error if fn is not a function.
function M.set_icon_resolver(widget_def, fn)
if type(fn) ~= "function" then
error("inventory-list-display.set_icon_resolver: fn must be a function")
end
widget_def._icon_resolver = fn
end
-- -----------------------------------------------------------------------
-- Test backdoors
-- -----------------------------------------------------------------------
--- M._test_get_rows(widget_def) -> array of items (for tests only)
--- Returns the current contents of the widget's container via inventory.contents.
function M._test_get_rows(widget_def)
return inventory.contents(widget_def._container)
end
--- M._test_resolve_icon(widget_def, item) -> icon_table or nil (for tests only)
--- Invokes the widget's current icon resolver for `item`.
function M._test_resolve_icon(widget_def, item)
return widget_def._icon_resolver(item)
end
return M