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