Files
sporel-lib-core.inventory-l…/init.lua
Calic 051f472442 feat: default_label_resolver reads name property; item-template convention
The default label_resolver now reads the optional player-facing `name`
property from the item-template; modules with named templates get pretty
row labels without registering an override. Falls back to "Item" when
`name` is absent or empty.

README documents the player-facing item-template convention: name,
description, weight, volume, composition.<material> — all optional,
all with documented defaults. Skill-gated description reveals stay on
the separate hints lib (designed, not yet implemented).
2026-06-14 00:26:29 +02:00

351 lines
15 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =====================================================================
-- 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
-- Reads the player-facing `name` property from the item-template. Modules
-- that declare `name` on their item-templates get pretty names automatically;
-- modules without `name` get the generic "Item" fallback.
-- Override via set_label_resolver for custom mappings (template-id, lookup
-- table, localization, …).
local function default_label_resolver(item)
local name = item:get_property("name")
if name and name ~= "" then return name end
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