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).
351 lines
15 KiB
Lua
351 lines
15 KiB
Lua
-- =====================================================================
|
||
-- 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
|