Files
sporel-lib-core.crafting-di…/README.md
Calic eb8f72bcbd feat: lib-core.crafting-display v0.1.0 — Recipe Panel-Widget
UI layer for recipe-based crafting. Reads recipes from
lib-core.crafting, renders the known-subset as a vertical row-list
inside a panel managed by lib-core.panel, and dispatches right-click
context-menu actions registered by the consuming module.

Surface:
  create(container_entity, opts) -> widget_def
  register_action(widget, label, callback)
  unregister_action(widget, label)
  set_icon_resolver(widget, fn)
  set_label_resolver(widget, fn)
  set_summary_resolver(widget, fn)

Row-build is rebuilt per render frame from crafting.list_recipes():
  - Visibility gate: recipe.is_known(ctx); errors hide row + emit [WARN]
  - Availability gate: crafting.can_craft(id, container, ctx).ok
    drives row colour (full vs dim text_color).

ctx comes from opts.ctx_factory() — default returns {}; override to
feed actor/skill/faction state into both predicates. label_resolver
defaults to recipe.name or recipe.id; summary_resolver joins input
template-ids with " + " and suffixes "xN" for count>1.

Right-click hit-tests row-rects captured during render, then opens
the menu via panel.show_context_menu(x, y, actions). Action
callbacks receive (recipe_id, {container, close_menu, refresh}).
Registration order is preserved (array-based, not pairs-based) so
context-menu rendering matches registration sequence.

Depends on lib-core.crafting 0.1.0, lib-core.panel 0.1.1,
lib-core.inventory-list 0.1.0, lib-core.composition 0.3.0. The last
two are not called directly but are required as direct deps so
modules consuming this widget satisfy crafting's transitive needs
(engine resolver is per-module non-transitive).

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

246 lines
8.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
## 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
```