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:
345
init.lua
Normal file
345
init.lua
Normal 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
|
||||
Reference in New Issue
Block a user