initial: notify-display v0.1.0 — screen-space displays on lib-core.panel
This commit is contained in:
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -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
|
||||||
201
README.md
Normal file
201
README.md
Normal file
@@ -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
|
||||||
|
|
||||||
|
<!-- topology:start (auto-generated; do not edit) -->
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
<!-- topology:end -->
|
||||||
|
|
||||||
|
## 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 } }
|
||||||
|
```
|
||||||
366
init.lua
Normal file
366
init.lua
Normal file
@@ -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
|
||||||
1
manifest.lib
Normal file
1
manifest.lib
Normal file
@@ -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"}]}
|
||||||
Reference in New Issue
Block a user