Files
sporel-lib-core.crafting-di…/README.md
Calic b42b707a8e fix: align widget contract with lib-core.panel API
The widget contract was implemented as render(theme, x, y, w, h) and
handle_input(input_state, theme, x, y, w, h), but lib-core.panel
dispatches widgets as render(ctx) and handle_input(ctx, event) where
ctx = {bounds = {x,y,w,h}, theme, is_focused} and event carries
{kind, x, y, button} for clicks or {kind, dy} for wheel.

The mismatch would have surfaced as a crash on the first render frame
(theme.row_height read on a nil first arg) and as a permanently dead
right-click (no field matched input_state.right_clicked because the
real signature passes an event table). Both bugs were masked by the
existing tests, which exercise the public registration surface but
never drove render or handle_input headless.

Changes:
- widget.render and widget.handle_input now match panel's contract.
- _render_widget consumes ctx.bounds + ctx.theme; reads packed-RGBA
  text colours directly instead of falling back to synthetic float
  arrays (panel theme stores 0xRRGGBBAA integers).
- _handle_input_widget dispatches on event.kind == "click" and
  event.button == "right", iterating _render_rows for hit-testing.
- draw_text now passes theme.font_size_body so the engine receives
  the full (text, x, y, size, color) signature.
- Side-effect requires for lib-core.inventory-list and
  lib-core.composition replace the unused-local sentinels, dropping
  the underscore-shadowing.
- _invoke_context_menu trusts mx/my as preconditions and no longer
  defends with `or 0` defaults — the entry-point guards nil.

README documents the widget contract explicitly and captures four
v0.2 hardening notes (is_known cache, WARN rate-limit, empty-action
diagnostic, defensive nil-guard) so the deferral is traceable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 13:36:29 +02:00

273 lines
9.5 KiB
Markdown
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.crafting-display
Recipe panel-widget. Reads recipes via `lib-core.crafting` and renders
the known-subset as a vertical row-list inside a panel managed by
`lib-core.panel`. Each row shows label + summary; row colour reflects
per-row availability (full-colour when craftable, dim when blocked).
Right-clicking a row opens a context-menu populated from actions
registered via `register_action`.
**Version:** 0.1.0
**Lib-ID:** lib-core.crafting-display
**Requires:** lib-core.crafting v0.1.0, lib-core.panel v0.1.1, lib-core.inventory-list v0.1.0, lib-core.composition v0.3.0
**Tags:** crafting, ui, panel, widget, recipes
## Topology
<!-- topology:start (auto-generated; do not edit) -->
```mermaid
graph LR
this["lib-core.crafting-display"]
crafting["lib-core.crafting"]
panel["lib-core.panel"]
inv["lib-core.inventory-list"]
comp["lib-core.composition"]
this --> crafting
this --> panel
this --> inv
this --> comp
```
<!-- topology:end -->
`inventory-list` and `composition` are declared as direct deps for the
engine's per-module non-transitive resolver: this lib does not call
them directly, but `crafting` does, and modules consuming this widget
must satisfy them.
## Scope (v0.1.0)
v0.1 ships a minimal recipe-list widget:
- `create(container_entity, opts)` — builds a panel-compatible widget_def
- `register_action` / `unregister_action` — context-menu actions (right-click on row)
- `set_icon_resolver` / `set_label_resolver` / `set_summary_resolver` — override default row content
- Render: label (left-aligned) + summary (right-aligned)
- Row colour: full when `can_craft.ok==true`, dim otherwise
- Input: right-click row opens context-menu via `panel.show_context_menu`
**Intentional non-goals (deferred):**
- Row scrolling
- Icon-column rendering (resolver returns nil by default)
- Tooltip / hover-detail
- Stack-count display
**v0.2 hardening notes (deferred):**
- `is_known(ctx)` is called per recipe per frame; no per-frame memoization.
Large recipe-registries may want a cache layer.
- `is_known(ctx)` errors emit one `[WARN]` per failure per frame; no rate-
limit (spammy if a recipe is permanently broken).
- Right-click on a row with an empty action-set is silently dropped; a
diagnostic warning would help modders catch missing `register_action`
calls.
## Widget Contract
The widget conforms to `lib-core.panel`'s widget-lifecycle contract (see
`lib-core/panel/README.md` §Widget-Lifecycle-Contract):
```lua
widget.render(ctx)
widget.handle_input(ctx, event)
```
where `ctx = {bounds = {x,y,w,h}, theme = table, is_focused = bool}` and
the panel dispatches input events as `{kind = "click", x, y, button}` or
`{kind = "wheel", dy}`. Theme colours are packed-RGBA integers (e.g.
`0xE0E0E0FF`), matching the `panel.DEFAULT_THEME` schema.
Right-click on a row invokes `panel.show_context_menu(x, y, actions)`
with the registered actions; left-click and wheel are ignored in v0.1.
## Row Visibility + Availability rules
For every frame, the widget rebuilds its row-list by walking
`crafting.list_recipes()` and applying two independent gates:
| Gate | Source | Effect on row |
|-----------------|------------------------------------------|----------------------------------------------|
| **Visibility** | `recipe.is_known(ctx)` | `false` → row is **hidden** entirely |
| **Availability**| `crafting.can_craft(id, container, ctx)` | `ok=false` → row visible but **dim colour** |
`ctx` is built from `opts.ctx_factory()` (default `function() return {} end`).
Override `ctx_factory` to wire in actor-state, skill-level, faction-membership,
etc. — whatever the consuming module needs `is_known` and `can_craft` to see.
If `recipe.is_known(ctx)` raises, the row is hidden and a `[WARN]`
message is emitted via `engine.print` (defensive — visibility predicate
must not crash UI). No equivalent guard around `can_craft`: errors
there propagate (consistent with rest of the lib stack).
## API
### `display.create(container_entity, opts)`
**Syntax:** `display.create(container_entity: entity, opts: table|nil) -> widget_def`
**Example:**
```lua
local display = require("lib-core.crafting-display")
local panel = require("lib-core.panel")
local widget = display.create(workbench_entity, {
title = "Workbench",
widget_id = "crafting",
pause_on_open = true,
ctx_factory = function()
return { actor = current_actor() }
end,
})
panel.register(widget.widget_id, widget)
panel.bind_default_trigger("c", widget.widget_id)
```
Creates a widget_def bound to `container_entity` (used as the input
source for `can_craft` availability checks). `opts` keys:
- `title` (string, default `"Crafting"`) — panel title bar text
- `widget_id` (string, default `"crafting"`) — key for `panel.register`
- `pause_on_open` (bool, default `false`) — passed to panel for `is_pausing()`
- `ctx_factory` (function, default `function() return {} end`) — per-frame ctx builder
- `icon_resolver` (function) — overrides default icon resolver at creation time
- `label_resolver` (function) — overrides default label resolver at creation time
- `summary_resolver` (function) — overrides default summary resolver at creation time
Loud-error if `container_entity` is `nil`.
---
### `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, "Craft", function(recipe_id, ctx)
crafting.craft(recipe_id, ctx.container, {})
ctx.close_menu()
end)
```
Registers a context-menu action shown on right-click of any row. `callback`
receives `(recipe_id, 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.
Registration order is preserved (entries are kept in an array). Identical
order is reflected in the context-menu rendering.
---
### `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_icon_resolver(widget_def, fn)`
**Syntax:** `display.set_icon_resolver(widget_def: table, fn: function) -> void`
Replaces the icon resolver. `fn(recipe) -> any|nil` — return value is
opaque to v0.1 (default icon column not yet rendered; resolver is wired
in for v0.2 atlas-icon support and for tests).
---
### `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(recipe)
return localize("recipe." .. recipe.id) or recipe.name or recipe.id
end)
```
Replaces the label resolver. `fn(recipe) -> string`. Called each render frame per row.
Default returns `recipe.name or recipe.id`.
---
### `display.set_summary_resolver(widget_def, fn)`
**Syntax:** `display.set_summary_resolver(widget_def: table, fn: function) -> void`
**Example:**
```lua
display.set_summary_resolver(widget, function(recipe)
return string.format("%d ingredients", #recipe.inputs)
end)
```
Replaces the summary resolver. `fn(recipe) -> string`. Called each render
frame per row. Default joins `recipe.inputs` template-ids with `" + "`,
suffixing `"×N"` when `count>1`.
---
## Default Resolvers
| Resolver | Default behavior |
|--------------------|-------------------------------------------------------------|
| `label_resolver` | `recipe.name or recipe.id` |
| `summary_resolver` | `inputs[*].template` joined with `" + "`, `"×N"` when `count>1` |
| `icon_resolver` | returns `nil` (no icon in v0.1) |
| `ctx_factory` | returns `{}` (empty table; replace to feed actor/skill ctx) |
## Test backdoors
- `display._test_get_rows(widget_def)` — returns the per-frame row list
(`{recipe, available}` pairs after is_known filtering)
- `display._test_resolve_icon(widget_def, recipe)` — invokes current icon resolver
- `display._test_resolve_label(widget_def, recipe)` — invokes current label resolver
- `display._test_resolve_summary(widget_def, recipe)` — invokes current summary resolver
All four are for test modules only and should not be called in
production code.
## Glue-Pattern
Minimal module setup:
```lua
local display = require("lib-core.crafting-display")
local crafting = require("lib-core.crafting")
local panel = require("lib-core.panel")
-- 1. Define recipes
crafting.define_recipe{
id = "rock_pick",
inputs = {
{ template = "items.rock", count = 1 },
{ template = "items.stick", count = 1 },
},
output = { template = "items.rock_pick", count = 1 },
name = "Rock Pick",
}
-- 2. Create widget (workbench_entity is a list-container)
local widget = display.create(workbench_entity, {
title = "Workbench",
widget_id = "crafting",
pause_on_open = true,
})
-- 3. Register actions
display.register_action(widget, "Craft", function(recipe_id, ctx)
crafting.craft(recipe_id, ctx.container, {})
ctx.close_menu()
end)
-- 4. Register with panel + bind toggle key
panel.register(widget.widget_id, widget)
panel.bind_default_trigger("c", widget.widget_id)
-- 5. Wire into update + render
function M.update(dt) panel.update(dt) end
function M.render() panel.render() end
```