commit 94d2a54a1c71dec125af523b007cc3996b2d7cfa Author: Calic Date: Sun Jun 14 01:36:20 2026 +0200 initial: notify-display v0.1.0 — screen-space displays on lib-core.panel 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..e7889cc --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# lib-core.notify-display + +Screen-space display layer for `lib-core.notify`. Routes channel messages into three +UI modes: log (panel-widget list), toast (always-on HUD overlay), detail (modal +panel that auto-opens on new message). + +**Version:** 0.1.0 +**Lib-ID:** lib-core.notify-display +**Requires:** lib-core.notify v0.1.0, lib-core.panel v0.1.0 +**Tags:** notify, display, ui, toast, log, detail, hud, overlay + +## Topology + + +```mermaid +graph LR + this["lib-core.notify-display"] + lib_notify["lib-core.notify"] + lib_panel["lib-core.panel"] + engine_render["engine.render.*"] + this --> lib_notify + this --> lib_panel + this --> engine_render +``` + + +## Scope (v0.1.0) + +v0.1 ships three display modes driven by `notify` channel `display_mode` declarations: + +- **log** — panel widget that renders channel history as a sorted list (newest first). + Plugs into `lib-core.panel` via `create_log_widget` + `panel.register`. +- **toast** — always-on HUD overlay with fade-in/hold/fade-out animation and + configurable position, max-visible cap, and per-message TTL. +- **detail** — subscribes to `display_mode="detail"` channels; on each new message + sets `current_detail` and auto-opens a registered panel widget. + +**Intentional non-goals (deferred to v0.2+):** +- Scroll/pagination in log widget +- Multiple simultaneous toast columns +- Toast queue persistence (survives mode detach/re-attach) +- Per-severity toast theming overrides +- Keyboard dismiss for detail panel + +## API + +### `display.attach_mode(mode, opts)` + +Subscribes to existing `notify` channels that match `mode`. Must be called +after `notify.create_channel` for those channels (channels created later are +not automatically picked up in v0.1). + +| mode | required opts | optional opts | +|------|---------------|---------------| +| `"log"` | `panel_widget_id: string` | — | +| `"toast"` | — | `position`, `max_visible`, `default_ttl` | +| `"detail"` | `panel_widget_id: string` | — | + +**Toast opts schema:** +```lua +{ + position = "top-right", -- default; also: top-center, top-left, + -- bottom-right, bottom-center, bottom-left + max_visible = 5, -- oldest evicted when limit exceeded + default_ttl = 3.0, -- seconds; overridden per-msg by msg.ttl +} +``` + +Loud-error on unknown `mode` or invalid `position`. Missing `panel_widget_id` for +log/detail is a loud-error. Nil/missing `position` in toast silently defaults to +`"top-right"`. + +--- + +### `display.detach_mode(mode)` + +Unsubscribes all subscriptions held for channels whose `display_mode == mode`, +sets `mode_opts[mode] = nil`, and clears mode-specific state: +- toast → empties `active_toasts` +- detail → clears `current_detail` +- log → clears `widget_channels` + +--- + +### `display.create_log_widget(opts) → widget_def` + +Returns a widget definition table compatible with `panel.register`. + +```lua +opts = { + widget_id = "game-log", -- default + title = "Game Log", -- default + pause_on_open = false, -- default +} +``` + +The returned `widget_def.render(ctx)` reads from `notify.get_history` for all +channels mapped to this `widget_id` (via `attach_mode("log", {panel_widget_id=...})`), +sorts by timestamp descending, and renders up to `floor(h / row_height)` rows. +Severity tinting: warn = `0xFFD000FF`, error = `0xFF4040FF`. + +--- + +### `display.create_detail_widget(opts) → widget_def` + +Returns a widget definition table compatible with `panel.register`. + +```lua +opts = { + widget_id = "detail", -- default + title = "Detail", -- default + pause_on_open = false, -- default +} +``` + +Renders `current_detail` (the last message posted to any `display_mode="detail"` +channel). Shows `msg.text` as title, `msg.data.description` as body, remaining +`msg.data.*` keys as key-value rows. No-op if no detail message has arrived yet. + +**Note:** Register the widget with `panel.register` **before** calling +`attach_mode("detail", ...)` so that the auto-open `panel.open` call succeeds +on the first message. If registered after attach, the first message silently +fails to open the panel (pcall-guarded), but `current_detail` is still set. + +--- + +### `display.update(dt)` + +Must be called each game-update frame. Removes expired toasts from `active_toasts` +(compares `engine.time.now()` against `spawn_time + ttl`). No-op if toast mode +is not attached. + +--- + +### `display.render()` + +Must be called each render frame (typically after `panel.render()`). Draws the +toast HUD overlay. No-op if toast mode is not attached or no toasts are active. + +Toast geometry: 280 × 32 px, 8 px padding from screen edge. Positions stack in +the growth direction of the chosen anchor (top-* grows down, bottom-* grows up). + +Alpha animation per toast: +- 0–10% of TTL: fade in +- 10–85% of TTL: fully opaque +- 85–100% of TTL: fade out + +Screen dimensions: 1280 × 720 fallback (engine.render does not expose +`get_screen_size` to Lua in v0.1 — same limitation as lib-core.panel). + +--- + +## Theme keys consumed + +`notify-display` renders toasts using hardcoded colors (no own theme keys in v0.1). +Log and detail widgets render via `ctx.theme` supplied by `lib-core.panel`, consuming +the standard panel theme keys: `text_color`, `text_color_dim`, `font_size_title`, +`font_size_body`, `padding`, `row_height`. + +--- + +## Glue pattern example + +```lua +local notify = require("lib-core.notify") +local panel = require("lib-core.panel") +local display = require("lib-core.notify-display") + +-- 1. Declare channels +notify.create_channel{ id = "game-events", filter = {"event"}, display_mode = "log" } +notify.create_channel{ id = "alerts", filter = {"alert"}, display_mode = "toast" } +notify.create_channel{ id = "item-detail", filter = {"item"}, display_mode = "detail" } + +-- 2. Create and register widgets +local log_def = display.create_log_widget { widget_id = "game-log" } +local detail_def = display.create_detail_widget{ widget_id = "item-detail" } +panel.register("game-log", log_def) +panel.register("item-detail", detail_def) + +-- 3. Attach modes (after register so detail auto-open works immediately) +display.attach_mode("log", { panel_widget_id = "game-log" }) +display.attach_mode("toast", { position = "bottom-right", default_ttl = 4.0 }) +display.attach_mode("detail", { panel_widget_id = "item-detail" }) + +-- 4. Per-frame calls +function game_update(dt) + panel.update(dt) + display.update(dt) +end + +function game_render() + panel.render() + display.render() -- toast overlay on top +end + +-- 5. Posting messages +notify.post{ tags = {"event"}, text = "Player entered the forest" } +notify.post{ tags = {"alert"}, text = "Low health!", ttl = 2.0, severity = "warn" } +notify.post{ tags = {"item"}, text = "Iron Sword", + data = { description = "A sturdy blade.", damage = 12 } } +``` diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..ef30ecf --- /dev/null +++ b/init.lua @@ -0,0 +1,366 @@ +-- ===================================================================== +-- lib-core.notify-display v0.1.0 — Screen-Space Message Displays +-- log (panel-widget) + toast (always-on HUD) + detail (modal panel) +-- ===================================================================== + +local M = {} +local notify = require("lib-core.notify") +local panel = require("lib-core.panel") + +-- Screen-size fallback (engine.render does NOT expose get_screen_size to +-- Lua; must match the values in lib-core.panel which uses the same fallback). +local FALLBACK_SCREEN_W = 1280 +local FALLBACK_SCREEN_H = 720 + +-- Toast background opacity is dimmer than text so the text reads cleanly +-- against the dark backdrop while still fading with the toast. +local TOAST_BG_ALPHA_FACTOR = 0.75 + +-- ---------- state ---------- +local mode_opts = { + log = nil, -- { panel_widget_id } + toast = nil, -- { position, max_visible, default_ttl } + detail = nil, -- { panel_widget_id } +} +local subscriptions = {} -- channel_id → subscribe-handle +local active_toasts = {} -- array of {msg, spawn_time, ttl} +local current_detail = nil -- last detail-message (or nil) +local widget_channels = {} -- panel_widget_id → array of channel_ids + +local DEFAULT_TOAST_OPTS = { + position = "top-right", + max_visible = 5, + default_ttl = 3.0, +} +local VALID_TOAST_POSITIONS = { + ["top-right"] = true, ["top-center"] = true, ["top-left"] = true, + ["bottom-right"] = true, ["bottom-center"] = true, ["bottom-left"] = true, +} + +-- ----------------------------------------------------------------------- +-- Render helpers (defined BEFORE M.create_log_widget so closures +-- reference them correctly) +-- ----------------------------------------------------------------------- + +local function render_log_widget(widget_def, ctx, widget_id) + local channels_for_widget = widget_channels[widget_id] or {} + local rows = {} + for _, ch_id in ipairs(channels_for_widget) do + local h = notify.get_history(ch_id) + for _, msg in ipairs(h) do + table.insert(rows, msg) + end + end + -- Sort by timestamp (newest first) + table.sort(rows, function(a, b) + return (a.timestamp or 0) > (b.timestamp or 0) + end) + + local row_h = ctx.theme.row_height + local max_rows = math.floor(ctx.bounds.h / row_h) + for i = 1, math.min(#rows, max_rows) do + local msg = rows[i] + local row_y = ctx.bounds.y + (i - 1) * row_h + local color = ctx.theme.text_color + if msg.severity == "warn" then color = 0xFFD000FF end + if msg.severity == "error" then color = 0xFF4040FF end + engine.render.draw_text(msg.text, + ctx.bounds.x + ctx.theme.padding, + row_y + 4, + ctx.theme.font_size_body, color) + end +end + +local function render_detail_widget(widget_def, ctx) + if not current_detail then return end + local msg = current_detail + local x = ctx.bounds.x + ctx.theme.padding + local y = ctx.bounds.y + ctx.theme.padding + engine.render.draw_text(msg.text, x, y, + ctx.theme.font_size_title, ctx.theme.text_color) + y = y + ctx.theme.font_size_title + ctx.theme.padding + + if msg.data and msg.data.description then + engine.render.draw_text(msg.data.description, x, y, + ctx.theme.font_size_body, ctx.theme.text_color) + y = y + ctx.theme.font_size_body * 2 + end + + if msg.data then + for k, v in pairs(msg.data) do + if k ~= "description" then + local line = string.format("%s: %s", k, tostring(v)) + engine.render.draw_text(line, x, y, + ctx.theme.font_size_body, ctx.theme.text_color_dim) + y = y + ctx.theme.row_height + end + end + end +end + +-- ----------------------------------------------------------------------- +-- attach_mode / detach_mode +-- ----------------------------------------------------------------------- + +function M.attach_mode(mode, opts) + opts = opts or {} + + if mode == "log" then + if type(opts.panel_widget_id) ~= "string" then + error("notify-display.attach_mode: log mode requires opts.panel_widget_id (string)") + end + mode_opts.log = opts + local widget_id = opts.panel_widget_id + if not widget_channels[widget_id] then + widget_channels[widget_id] = {} + end + -- Subscribe to all existing log-mode channels not yet subscribed + for _, ch in ipairs(notify.list_channels()) do + if ch.display_mode == "log" and not subscriptions[ch.id] then + -- No-op callback: log reads history at render time + subscriptions[ch.id] = notify.subscribe(ch.id, function(_msg) end) + -- Track channel → widget mapping + local already = false + for _, existing_id in ipairs(widget_channels[widget_id]) do + if existing_id == ch.id then already = true; break end + end + if not already then + table.insert(widget_channels[widget_id], ch.id) + end + end + end + + elseif mode == "toast" then + -- Validate position (default to "top-right" if nil) + local pos = opts.position + if pos == nil then + pos = DEFAULT_TOAST_OPTS.position + elseif not VALID_TOAST_POSITIONS[pos] then + error("notify-display.attach_mode: toast position '" .. tostring(pos) + .. "' is invalid; valid: top-right, top-center, top-left, " + .. "bottom-right, bottom-center, bottom-left") + end + mode_opts.toast = { + position = pos, + max_visible = opts.max_visible or DEFAULT_TOAST_OPTS.max_visible, + default_ttl = opts.default_ttl or DEFAULT_TOAST_OPTS.default_ttl, + } + -- Subscribe to all existing toast-mode channels not yet subscribed + for _, ch in ipairs(notify.list_channels()) do + if ch.display_mode == "toast" and not subscriptions[ch.id] then + subscriptions[ch.id] = notify.subscribe(ch.id, function(msg) + local now = (engine and engine.time and engine.time.now + and engine.time.now()) or 0 + table.insert(active_toasts, { + msg = msg, + spawn_time = now, + ttl = msg.ttl or mode_opts.toast.default_ttl, + }) + while #active_toasts > mode_opts.toast.max_visible do + table.remove(active_toasts, 1) + end + end) + end + end + + elseif mode == "detail" then + if type(opts.panel_widget_id) ~= "string" then + error("notify-display.attach_mode: detail mode requires opts.panel_widget_id (string)") + end + mode_opts.detail = opts + -- Subscribe to all existing detail-mode channels not yet subscribed + for _, ch in ipairs(notify.list_channels()) do + if ch.display_mode == "detail" and not subscriptions[ch.id] then + subscriptions[ch.id] = notify.subscribe(ch.id, function(msg) + current_detail = msg + -- pcall: silent-fail if widget not registered yet + pcall(panel.open, opts.panel_widget_id) + end) + end + end + + else + error("notify-display.attach_mode: unknown mode '" .. tostring(mode) + .. "'; valid: log, toast, detail") + end +end + +function M.detach_mode(mode) + -- Unsubscribe all subscriptions for channels whose display_mode == mode + for _, ch in ipairs(notify.list_channels()) do + if ch.display_mode == mode and subscriptions[ch.id] then + notify.unsubscribe(subscriptions[ch.id]) + subscriptions[ch.id] = nil + end + end + mode_opts[mode] = nil + -- Clear mode-specific state + if mode == "toast" then + active_toasts = {} + elseif mode == "detail" then + current_detail = nil + elseif mode == "log" then + widget_channels = {} + end +end + +-- ----------------------------------------------------------------------- +-- Widget factories +-- ----------------------------------------------------------------------- + +function M.create_log_widget(opts) + opts = opts or {} + local widget_id = opts.widget_id or "game-log" + local title = opts.title or "Game Log" + local widget_def = { + title = title, + widget_id = widget_id, + pause_on_open = opts.pause_on_open or false, + } + widget_def.render = function(ctx) + render_log_widget(widget_def, ctx, widget_id) + end + widget_def.handle_input = function(ctx, event) + -- Wheel scroll deferred to v0.2 + end + return widget_def +end + +function M.create_detail_widget(opts) + opts = opts or {} + local widget_id = opts.widget_id or "detail" + local title = opts.title or "Detail" + local widget_def = { + title = title, + widget_id = widget_id, + pause_on_open = opts.pause_on_open or false, + } + widget_def.render = function(ctx) + render_detail_widget(widget_def, ctx) + end + widget_def.handle_input = function(ctx, event) + -- Detail-content read-only in v0.1 + end + return widget_def +end + +-- ----------------------------------------------------------------------- +-- Per-frame update (toast lifecycle) +-- ----------------------------------------------------------------------- + +function M.update(dt) + if not mode_opts.toast then return end + local now = (engine and engine.time and engine.time.now + and engine.time.now()) or 0 + -- Filter: keep only toasts that have not yet expired + local kept = {} + for _, t in ipairs(active_toasts) do + if (now - t.spawn_time) < t.ttl then + table.insert(kept, t) + end + end + active_toasts = kept +end + +-- ----------------------------------------------------------------------- +-- Render (toast overlay — always-on HUD) +-- ----------------------------------------------------------------------- + +function M.render() + if not mode_opts.toast then return end + if #active_toasts == 0 then return end + + -- Screen size: fallback (engine.render has no Lua get_screen_size) + local screen_w, screen_h = FALLBACK_SCREEN_W, FALLBACK_SCREEN_H + + local toast_w = 280 + local toast_h = 32 + local padding = 8 + local pos = mode_opts.toast.position + local now = (engine and engine.time and engine.time.now + and engine.time.now()) or 0 + + -- Determine base x and y, and direction (dy positive = down, negative = up) + local base_x, base_y, dy + + -- X position + if pos == "top-right" or pos == "bottom-right" then + base_x = screen_w - toast_w - padding + elseif pos == "top-center" or pos == "bottom-center" then + base_x = (screen_w - toast_w) / 2 + else -- top-left / bottom-left + base_x = padding + end + + -- Y position and growth direction + if pos == "top-right" or pos == "top-center" or pos == "top-left" then + base_y = padding + dy = toast_h + 4 + else -- bottom-* + base_y = screen_h - toast_h - padding + dy = -(toast_h + 4) + end + + for i, t in ipairs(active_toasts) do + local elapsed = now - t.spawn_time + local progress = elapsed / t.ttl -- 0..1 + + -- Alpha curve: fade-in [0..0.1], hold [0.1..0.85], fade-out [0.85..1.0] + local alpha + if progress < 0.1 then + alpha = progress / 0.1 + elseif progress < 0.85 then + alpha = 1.0 + else + alpha = (1.0 - progress) / (1.0 - 0.85) + end + alpha = math.max(0, math.min(1, alpha)) + local alpha_byte = math.floor(alpha * 255 + 0.5) + + -- Apply alpha to colors (replace lowest byte = alpha channel) + local bg_color = 0x202020FF + local text_color = 0xE0E0E0FF + bg_color = (bg_color & 0xFFFFFF00) | (math.floor(alpha_byte * TOAST_BG_ALPHA_FACTOR) & 0xFF) + text_color = (text_color & 0xFFFFFF00) | (alpha_byte & 0xFF) + + local toast_x = base_x + local toast_y = base_y + (i - 1) * dy + + -- Background rect + if engine and engine.render and engine.render.draw_rect then + engine.render.draw_rect(toast_x, toast_y, toast_w, toast_h, bg_color) + end + -- Message text + if engine and engine.render and engine.render.draw_text then + engine.render.draw_text(t.msg.text, + toast_x + padding, + toast_y + (toast_h - 14) / 2, + 14, text_color) + end + end +end + +-- ----------------------------------------------------------------------- +-- Test backdoors (for use by notify-display-test only) +-- ----------------------------------------------------------------------- + +function M._test_get_active_toasts() return active_toasts end +function M._test_get_last_detail() return current_detail end +function M._test_get_widget_channels(widget_id) + return widget_channels[widget_id] or {} +end +function M._test_subscriber_count() + local n = 0 + for _ in pairs(subscriptions) do n = n + 1 end + return n +end +function M._test_reset() + mode_opts = { log = nil, toast = nil, detail = nil } + for _, h in pairs(subscriptions) do notify.unsubscribe(h) end + subscriptions = {} + active_toasts = {} + current_detail = nil + widget_channels = {} +end + +return M diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..18e3c81 --- /dev/null +++ b/manifest.lib @@ -0,0 +1 @@ +{"id":"lib-core.notify-display","version":"0.1.0","api_min":"0.1","deps":[{"id":"lib-core.notify","version":"0.1.0"},{"id":"lib-core.panel","version":"0.1.0"}]} \ No newline at end of file