diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index a8b7cfeac2..8601128cd1 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -19,17 +19,6 @@ local s_gsub = string.gsub local s_byte = string.byte local dkjson = require "dkjson" --- Helper function to find toast index by content pattern --- TODO: remove this when when we can control toast notifications better -local function findToastIndex(pattern) - for i, msg in ipairs(main.toastMessages) do - if msg:match(pattern) then - return i - end - end - return nil -end - local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) self.ControlHost() @@ -290,7 +279,7 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) end) self.controls.powerReportList.shown = false -- Progress callback from the CalcsTab power builder coroutine - self.powerBuilderToastActive = false + self.powerBuilderToastId = nil self.lastProgressToastUpdate = 0 self.build.powerBuilderProgressCallback = function(percent) local now = GetTime() @@ -301,13 +290,16 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) local message = percent and string.format("Building Power Report... (%d%%)", percent) or "Building Power Report..." self.controls.powerReportList.label = message + + if self.powerBuilderToastId and ToastNotification:WasDismissed(self.powerBuilderToastId) then + return + end self.lastProgressToastUpdate = now - local toastIndex = findToastIndex("^Building Power Report") - if toastIndex then - main.toastMessages[toastIndex] = message + + if self.powerBuilderToastId and ToastNotification:Exists(self.powerBuilderToastId) then + ToastNotification:Update(self.powerBuilderToastId, message) else - t_insert(main.toastMessages, message) - self.powerBuilderToastActive = true + self.powerBuilderToastId = ToastNotification:Add(message) end end -- Completion callback from the CalcsTab power builder coroutine @@ -315,19 +307,12 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) local powerStat = self.build.calcsTab.powerStat or data.powerStatList[1] local report = self:BuildPowerReportList(powerStat) self.controls.powerReportList:SetReport(powerStat, report) - local toastIndex = findToastIndex("^Building Power Report") - if self.powerBuilderToastActive and toastIndex then - -- Remove the toast from the queue instead of triggering hide animation - -- This prevents issues when the toast is not currently displayed (queued behind another toast) - -- TODO: look into allowing toast notifications to stack and have UUID's we can control them better - if toastIndex == 1 then - main.toastMode = "HIDING" - main.toastStart = GetTime() - else - t_remove(main.toastMessages, toastIndex) - end + + if self.powerBuilderToastId then + ToastNotification:ClearDismissed(self.powerBuilderToastId) + ToastNotification:Remove(self.powerBuilderToastId) + self.powerBuilderToastId = nil end - self.powerBuilderToastActive = false end self.controls.specConvertText = new("LabelControl", { "BOTTOMLEFT", self.controls.specSelect, "TOPLEFT" }, { 0, -14, 0, 16 }, "^7This is an older tree version, which may not be fully compatible with the current game version.") @@ -1029,6 +1014,12 @@ function TreeTabClass:SetPowerCalc(powerStat) self.build.calcsTab.powerBuildFlag = true self.build.calcsTab.powerStat = powerStat self.controls.powerReportList:SetReport(powerStat, nil) + -- Remove old toast and clear dismissed state so toast can show for new power report + if self.powerBuilderToastId then + ToastNotification:ClearDismissed(self.powerBuilderToastId) + ToastNotification:Remove(self.powerBuilderToastId, true) + self.powerBuilderToastId = nil + end end function TreeTabClass:BuildPowerReportList(currentStat) diff --git a/src/Modules/Main.lua b/src/Modules/Main.lua index c329acc5ec..019ad5f5db 100644 --- a/src/Modules/Main.lua +++ b/src/Modules/Main.lua @@ -23,6 +23,9 @@ LoadModule("Modules/CalcTools") LoadModule("Modules/PantheonTools") LoadModule("Modules/BuildSiteTools") +-- Load as global so other modules can access the same instance +ToastNotification = LoadModule("Modules/ToastNotification") + --[[if launch.devMode then for skillName, skill in pairs(data.enchantments.Helmet) do for _, mod in ipairs(skill.ENDGAME) do @@ -233,19 +236,14 @@ function main:Init() self.controls.devMode.shown = function() return launch.devMode end - self.controls.dismissToast = new("ButtonControl", {"BOTTOMLEFT",self.anchorMain,"BOTTOMLEFT"}, {0, function() return -self.mainBarHeight + self.toastHeight end, 80, 20}, "Dismiss", function() - self.toastMode = "HIDING" - self.toastStart = GetTime() - end) - self.controls.dismissToast.shown = function() - return self.toastMode == "SHOWN" - end self.mainBarHeight = 58 - self.toastMessages = { } + + -- Initialize toast notification system + ToastNotification:Init(self.anchorMain) if launch.devMode and GetTime() >= 0 and GetTime() < 15000 then - t_insert(self.toastMessages, [[ + ToastNotification:Add([[ ^xFF7700Warning: ^7Developer Mode active! The program is currently running in developer mode, which is not intended for normal use. @@ -371,56 +369,23 @@ function main:OnFrame() self:CallMode("OnFrame", self.inputEvents, self.viewPort) if launch.updateErrMsg then - t_insert(self.toastMessages, string.format("Update check failed!\n%s", launch.updateErrMsg)) + ToastNotification:Add(string.format("Update check failed!\n%s", launch.updateErrMsg)) launch.updateErrMsg = nil end if launch.updateAvailable then if launch.updateAvailable == "none" then - t_insert(self.toastMessages, "No update available\nYou are running the latest version.") + ToastNotification:Add("No update available\nYou are running the latest version.") launch.updateAvailable = nil elseif not self.updateAvailableShown then - t_insert(self.toastMessages, "Update Available\nAn update has been downloaded and is ready\nto be applied.") + ToastNotification:Add("Update Available\nAn update has been downloaded and is ready\nto be applied.") self.updateAvailableShown = true end end - -- Run toasts - if self.toastMessages[1] then - if not self.toastMode then - self.toastMode = "SHOWING" - self.toastStart = GetTime() - self.toastHeight = #self.toastMessages[1]:gsub("[^\n]","") * 16 + 20 + 40 - end - if self.toastMode == "SHOWING" then - local now = GetTime() - if now >= self.toastStart + 250 then - self.toastMode = "SHOWN" - else - self.mainBarHeight = 58 + self.toastHeight * (now - self.toastStart) / 250 - end - end - if self.toastMode == "SHOWN" then - self.mainBarHeight = 58 + self.toastHeight - elseif self.toastMode == "HIDING" then - local now = GetTime() - if now >= self.toastStart + 75 then - self.toastMode = nil - self.mainBarHeight = 58 - t_remove(self.toastMessages, 1) - else - self.mainBarHeight = 58 + self.toastHeight * (1 - (now - self.toastStart) / 75) - end - end - if self.toastMode then - SetDrawColor(0.85, 0.85, 0.85) - DrawImage(nil, 0, self.screenH - self.mainBarHeight, 312, self.mainBarHeight) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, 0, self.screenH - self.mainBarHeight + 4, 308, self.mainBarHeight - 4) - SetDrawColor(1, 1, 1) - DrawString(4, self.screenH - self.mainBarHeight + 8, "LEFT", 20, "VAR", self.toastMessages[1]:gsub("\n.*","")) - DrawString(4, self.screenH - self.mainBarHeight + 28, "LEFT", 16, "VAR", self.toastMessages[1]:gsub("^[^\n]*\n?","")) - end - end + -- Update and render toasts + ToastNotification:UpdateFrame(self.inputEvents, self.viewPort, self.screenH) + local totalToastHeight = ToastNotification:Render() + self.mainBarHeight = 58 + totalToastHeight -- Draw main controls SetDrawColor(0.85, 0.85, 0.85) diff --git a/src/Modules/ToastNotification.lua b/src/Modules/ToastNotification.lua new file mode 100644 index 0000000000..e934dfcca4 --- /dev/null +++ b/src/Modules/ToastNotification.lua @@ -0,0 +1,257 @@ +-- Path of Building +-- +-- Module: Toast Notification +-- Manages toast notifications +-- + +local t_insert = table.insert +local t_remove = table.remove + +local ToastNotification = {} + +local toasts = {} +local dismissedIds = {} +local nextId = 1 +local anchorMain = nil +local inputEvents = nil +local viewPort = nil +local screenH = 0 + +-- Animation durations (ms) +local SHOW_DURATION = 250 +local HIDE_DURATION = 75 + +-- Generate a unique ID for a toast +local function generateId() + local id = "toast_" .. nextId + nextId = nextId + 1 + + return id +end + +-- Calculate toast height based on message content +local function calculateHeight(message) + local lineCount = #message:gsub("[^\n]", "") + + return lineCount * 16 + 20 + 40 +end + +-- Initialize the toast system with required references +function ToastNotification:Init(anchor, events, vp, height) + anchorMain = anchor + inputEvents = events + viewPort = vp + screenH = height +end + +-- Update references that change each frame +function ToastNotification:UpdateFrame(events, vp, height) + inputEvents = events + viewPort = vp + screenH = height +end + +-- Add a new toast notification +-- Returns the toast ID for later reference +function ToastNotification:Add(message) + local id = generateId() + local toast = { + id = id, + message = message, + mode = nil, -- Will be set to "SHOWING" on first render + start = nil, + height = calculateHeight(message), + currentHeight = 0, + dismissButton = nil, + } + t_insert(toasts, toast) + + return id +end + +-- Update an existing toast's message by ID +-- Returns true if toast was found and updated +function ToastNotification:Update(id, message) + for _, toast in ipairs(toasts) do + if toast.id == id then + toast.message = message + toast.height = calculateHeight(message) + + return true + end + end + + return false +end + +-- Remove/dismiss a toast by ID +-- If immediate is true, removes instantly; otherwise triggers hide animation +function ToastNotification:Remove(id, immediate) + for i, toast in ipairs(toasts) do + if toast.id == id then + if immediate then + t_remove(toasts, i) + else + toast.mode = "HIDING" + toast.start = GetTime() + end + + return true + end + end + + return false +end + +-- Check if a toast with the given ID exists +function ToastNotification:Exists(id) + for _, toast in ipairs(toasts) do + if toast.id == id then + return true + end + end + + return false +end + +-- Get a toast by ID +function ToastNotification:Get(id) + for _, toast in ipairs(toasts) do + if toast.id == id then + return toast + end + end + + return nil +end + +-- Clear all toasts +function ToastNotification:Clear(immediate) + if immediate then + toasts = {} + else + for _, toast in ipairs(toasts) do + toast.mode = "HIDING" + toast.start = GetTime() + end + end +end + +-- Check if a toast ID was manually dismissed +function ToastNotification:WasDismissed(id) + return dismissedIds[id] == true +end + +-- Clear the dismissed state for a toast ID (or all if no id provided) +function ToastNotification:ClearDismissed(id) + if id then + dismissedIds[id] = nil + else + dismissedIds = {} + end +end + +-- Get total height of all visible toasts +function ToastNotification:GetTotalHeight() + local total = 0 + for _, toast in ipairs(toasts) do + total = total + (toast.currentHeight or 0) + end + + return total +end + +-- Process toast animations and render +-- Returns total toast height for mainBarHeight calculation +function ToastNotification:Render() + local totalToastHeight = 0 + local toastsToRemove = {} + + for i, toast in ipairs(toasts) do + if not toast.mode then + toast.mode = "SHOWING" + toast.start = GetTime() + toast.dismissButton = new( + "ButtonControl", + { "BOTTOMLEFT", anchorMain, "BOTTOMLEFT" }, + { 4, 0, 80, 20 }, + "Dismiss", + function() + dismissedIds[toast.id] = true + toast.mode = "HIDING" + toast.start = GetTime() + end + ) + end + + local now = GetTime() + if toast.mode == "SHOWING" then + if now >= toast.start + SHOW_DURATION then + toast.mode = "SHOWN" + toast.currentHeight = toast.height + else + toast.currentHeight = toast.height * (now - toast.start) / SHOW_DURATION + end + elseif toast.mode == "SHOWN" then + toast.currentHeight = toast.height + elseif toast.mode == "HIDING" then + if now >= toast.start + HIDE_DURATION then + t_insert(toastsToRemove, i) + toast.currentHeight = 0 + else + toast.currentHeight = toast.height * (1 - (now - toast.start) / HIDE_DURATION) + end + end + + totalToastHeight = totalToastHeight + (toast.currentHeight or 0) + end + + -- Remove finished toasts (in reverse order to preserve indices) + for i = #toastsToRemove, 1, -1 do + t_remove(toasts, toastsToRemove[i]) + end + + local yOffset = 58 + for _, toast in ipairs(toasts) do + if toast.currentHeight and toast.currentHeight > 0 then + local toastY = screenH - yOffset - toast.currentHeight + + -- Toast background + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, 0, toastY, 312, toast.currentHeight) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, 0, toastY + 4, 308, toast.currentHeight - 4) + + -- Toast text + SetDrawColor(1, 1, 1) + DrawString(4, toastY + 8, "LEFT", 20, "VAR", toast.message:gsub("\n.*", "")) + DrawString(4, toastY + 28, "LEFT", 16, "VAR", toast.message:gsub("^[^\n]*\n?", "")) + + -- Position and draw dismiss button for fully shown toasts + if toast.mode == "SHOWN" and toast.dismissButton then + -- y is relative to anchor (screenH - 4), negative goes up + toast.dismissButton.y = -(yOffset + 4) + + -- Handle input for the button + if inputEvents then + for _, event in ipairs(inputEvents) do + if toast.dismissButton:IsMouseOver() then + if event.type == "KeyDown" then + toast.dismissButton:OnKeyDown(event.key, event.doubleClick) + elseif event.type == "KeyUp" then + toast.dismissButton:OnKeyUp(event.key) + end + end + end + end + toast.dismissButton:Draw(viewPort) + end + + yOffset = yOffset + toast.currentHeight + end + end + + return totalToastHeight +end + +return ToastNotification