Files
sporel-lib-core.notify-display/init.lua

367 lines
13 KiB
Lua

-- =====================================================================
-- 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