commit 0921a03ede6d55ee62ed4c03d97df6135dceb8a9 Author: Calic Date: Sat Jun 13 23:23:48 2026 +0200 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2026 Calic. All rights reserved. + +This software is part of the Sporel platform — **Tier 1 (Official / +Proprietary)** content per the Three-Tier Licensing Model documented in +`meta/docs/archive/design/vision.md §Licensing Model` (current source; +migration to `meta/docs/architecture/licensing-model.md` pending). + +⚠ **WIP — Legal review required before public launch.** The terms below +reflect design intent only; the formalized license framework will be +finalized through legal counsel before the first public release. Until +then, this notice serves as a placeholder defending the platform owner's +rights against unintentional re-licensing. + +No license is granted to copy, modify, distribute, sublicense, or otherwise +use this software in any form without prior written permission from the +copyright holder. + +References: +- Tier 1 (this file): all rights reserved, proprietary, sold/distributed + via official channels (Steam, etc.) +- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue- + share — see vision.md §Licensing Model +- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies + to community-uploaded libs/modules/assets, not this repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..fad437f --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# lib-core.inventory-list-display + +Inventory vertical-list widget. Reads a composition list-container via +`lib-core.inventory-list` and renders its contents as a panel widget +managed by `lib-core.panel`. Provides icon + label + count rows with +right-click context-menu support. + +**Version:** 0.1.0 +**Lib-ID:** lib-core.inventory-list-display +**Requires:** lib-core.panel v0.1.0, lib-core.inventory-list v0.1.0, lib-core.composition v0.2.0 +**Tags:** inventory, ui, panel, widget, list + +## Topology + + +```mermaid +graph LR + this["lib-core.inventory-list-display"] + panel["lib-core.panel"] + inv["lib-core.inventory-list"] + comp["lib-core.composition"] + this --> panel + this --> inv + this --> comp +``` + + +## Scope (v0.1.0) + +v0.1 ships a minimal vertical-list inventory widget: + +- `create(container_entity, opts)` — builds a panel-compatible widget_def +- `register_action` / `unregister_action` — context-menu actions (right-click on row) +- `set_label_resolver` / `set_icon_resolver` — override default row content +- Render: icon (Atlas-UV via `draw_sprite_transform`) + label + "×1" count per row +- Input: right-click row opens context-menu via `panel.show_context_menu` + +**Intentional non-goals (deferred):** +- Row scrolling (wheel event silently ignored) +- Stack-count display (`stack_mode="stack"` not supported in v0.1) +- Pretty item names (no `composition.template_of` accessor yet; override via `set_label_resolver`) +- Row selection highlight +- Multi-column layout + +## API + +### `display.create(container_entity, opts)` + +**Syntax:** `display.create(container_entity: entity, opts: table|nil) -> widget_def` + +**Example:** +```lua +local display = require("lib-core.inventory-list-display") +local panel = require("lib-core.panel") + +local widget = display.create(backpack_entity, { + title = "Backpack", + widget_id = "backpack", + pause_on_open = true, +}) +panel.register(widget.widget_id, widget) +panel.bind_default_trigger("i", widget.widget_id) +``` + +Creates a widget_def bound to `container_entity`. `opts` keys: +- `title` (string, default `"Inventory"`) — panel title bar text +- `widget_id` (string, default `"inventory"`) — key for `panel.register` +- `pause_on_open` (bool, default `false`) — passed to panel for `is_pausing()` +- `icon_resolver` (function) — overrides default icon resolver at creation time +- `label_resolver` (function) — overrides default label resolver at creation time + +Loud-error if `container_entity` is `nil` or has no container block with `kind="list"`. + +--- + +### `display.register_action(widget_def, label, callback)` + +**Syntax:** `display.register_action(widget_def: table, label: string, callback: function) -> void` + +**Example:** +```lua +display.register_action(widget, "Drop", function(item, ctx) + inventory.remove(ctx.container, item) + ctx.close_menu() +end) +``` + +Registers a context-menu action shown on right-click of any row. `callback` receives +`(item, context)` where `context = { container, close_menu, refresh }`. + +Loud-error on duplicate `label` or if `callback` is not a function. +The default action-set is empty — all actions must be registered explicitly. + +--- + +### `display.unregister_action(widget_def, label)` + +**Syntax:** `display.unregister_action(widget_def: table, label: string) -> void` + +Removes a context-menu action. Idempotent: no error if `label` was never registered. + +--- + +### `display.set_label_resolver(widget_def, fn)` + +**Syntax:** `display.set_label_resolver(widget_def: table, fn: function) -> void` + +**Example:** +```lua +display.set_label_resolver(widget, function(item) + return item:get_property("display_name") or "Unknown" +end) +``` + +Replaces the label resolver. `fn(item) -> string`. Called each render frame per row. +Default returns `"Item"` for all items (no `composition.template_of` in v0.1). +Loud-error if `fn` is not a function. + +--- + +### `display.set_icon_resolver(widget_def, fn)` + +**Syntax:** `display.set_icon_resolver(widget_def: table, fn: function) -> void` + +**Example:** +```lua +display.set_icon_resolver(widget, function(item) + local atlas = item:get_property("sprite_atlas") + if not atlas then return nil end + return { + atlas = atlas, + uv = { x=0, y=0, w=16, h=16 }, + } +end) +``` + +Replaces the icon resolver. `fn(item) -> {atlas=string, uv={x,y,w,h}} or nil`. +Default reads `sprite_atlas` and `sprite_uv.*` directly from item properties. +Loud-error if `fn` is not a function. + +--- + +### Test backdoors + +- `display._test_get_rows(widget_def)` — returns `inventory.contents(container)` directly +- `display._test_resolve_icon(widget_def, item)` — invokes the current icon resolver + +Both are for test modules only and should not be called in production code. + +## Row Layout + +``` + ctx.bounds.x + | + +--+----------------------------------+------+ + | | Label ×1 | | + |32| | | row_height (24px) + +--+----------------------------------+------+ + ^36px ^right-aligned (measure_text + padding) + icon (32×32 or placeholder rect + "?") +``` + +- Icon column: 32 px wide, left-aligned at `ctx.bounds.x` +- Label: at `ctx.bounds.x + 36`, `row_y + 4` +- Count ("×1"): right-aligned using `measure_text`; `ctx.bounds.x + ctx.bounds.w - text_w - padding` +- Row height: `ctx.theme.row_height` (default 24 px from panel theme) + +## Item-Template-Convention + +Items displayed by this widget must satisfy the inventory-list contract: + +| Property | Required | Description | +|----------|----------|-------------| +| `stack_mode` | yes (`"individual"`) | inventory-list requirement | +| `sprite_atlas` | recommended | path to atlas PNG (default icon resolver) | +| `sprite_uv.x/y/w/h` | recommended | UV rect within atlas (default icon resolver) | +| `position` | yes | world anchor (inventory-list requirement) | + +Tags: `{"renderable", "item"}` (renderable tag removed on add, restored on remove). + +If `sprite_atlas` is absent or empty, the default icon resolver returns `nil` and a +placeholder rect + "?" glyph is drawn instead. + +## Default Action-Set + +The default action-set is **empty**. No "Drop", "Use", or "Inspect" actions are +registered automatically. Consuming modules must call `display.register_action` to +populate the right-click context-menu. + +## Glue-Pattern + +Minimal module setup: + +```lua +local display = require("lib-core.inventory-list-display") +local panel = require("lib-core.panel") +local inventory = require("lib-core.inventory-list") + +-- 1. Create widget (assumes backpack_entity is a list-container) +local widget = display.create(backpack_entity, { + title = "Backpack", + widget_id = "backpack", + pause_on_open = true, +}) + +-- 2. Register actions +display.register_action(widget, "Drop", function(item, ctx) + inventory.remove(ctx.container, item) + ctx.close_menu() +end) + +-- 3. Override label if template names are available +display.set_label_resolver(widget, function(item) + return item:get_property("display_name") or "Item" +end) + +-- 4. Register with panel + bind toggle key +panel.register(widget.widget_id, widget) +panel.bind_default_trigger("i", widget.widget_id) + +-- 5. Wire into update + render +function M.update(dt) panel.update(dt) end +function M.render() panel.render() end +``` diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..fd69336 --- /dev/null +++ b/init.lua @@ -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 diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..757bc5e --- /dev/null +++ b/manifest.lib @@ -0,0 +1 @@ +{"id":"lib-core.inventory-list-display","version":"0.1.0","api_min":"0.1","deps":[{"id":"lib-core.panel","version":"0.1.0"},{"id":"lib-core.inventory-list","version":"0.1.0"},{"id":"lib-core.composition","version":"0.2.0"}]}