#include "log_viewer_page.h" #include "app_state.h" #include "date_format.h" #include "timezone_helper.h" #include "logical_size.h" #include #include #include namespace { struct WrappedLogLine { std::string text; SDL_Color color; }; std::vector wrapText(TTF_Font* font, const std::string& text, float maxW) { std::vector result; if (!font || text.empty() || maxW <= 0) return result; std::stringstream ss(text); std::string word; std::string currentLine; while (ss >> word) { std::string testLine = currentLine.empty() ? word : currentLine + " " + word; int w = 0, h = 0; TTF_GetStringSize(font, testLine.c_str(), 0, &w, &h); if (static_cast(w) <= maxW) { currentLine = testLine; } else { if (!currentLine.empty()) { result.push_back(currentLine); } currentLine = word; } } if (!currentLine.empty()) { result.push_back(currentLine); } return result; } const char* filterLevelToString(LogViewerPage::FilterLevel lvl) { switch (lvl) { case LogViewerPage::FilterLevel::All: return "ALL"; case LogViewerPage::FilterLevel::Info: return "INFO"; case LogViewerPage::FilterLevel::Warning: return "WARNING"; case LogViewerPage::FilterLevel::Error: return "ERROR"; case LogViewerPage::FilterLevel::Fatal: return "FATAL"; } return "ALL"; } } // anonymous namespace void LogViewerPage::onPush(AppState& state) { state_ = &state; TTF_Init(); createFonts(); currentTzCode_ = state.config.get().timezone; refreshLogs(); } void LogViewerPage::onResume(AppState& state) { state_ = &state; createFonts(); currentTzCode_ = state.config.get().timezone; refreshLogs(); } void LogViewerPage::onPause(AppState& state) { destroyFonts(); destroyTextures(); } void LogViewerPage::onPop(AppState& state) { destroyFonts(); destroyTextures(); TTF_Quit(); } void LogViewerPage::refreshLogs() { using namespace radiantwave::logger; if (filterLevel_ == FilterLevel::All) { // Debug logs are stored but never displayed; show everything else. rawEntries_ = Logger::instance().getEntriesFiltered(LogLevel::Info, false); } else { LogLevel targetLvl = LogLevel::Info; if (filterLevel_ == FilterLevel::Info) targetLvl = LogLevel::Info; else if (filterLevel_ == FilterLevel::Warning) targetLvl = LogLevel::Warning; else if (filterLevel_ == FilterLevel::Error) targetLvl = LogLevel::Error; else if (filterLevel_ == FilterLevel::Fatal) targetLvl = LogLevel::Fatal; rawEntries_ = Logger::instance().getEntriesFiltered(targetLvl, true); } // Defense in depth: Debug entries must never reach the viewer. rawEntries_.erase(std::remove_if(rawEntries_.begin(), rawEntries_.end(), [](const LogEntry& e) { return e.level == LogLevel::Debug; }), rawEntries_.end()); if (sortOrder_ == SortOrder::NewestFirst) { std::sort(rawEntries_.begin(), rawEntries_.end(), [](const LogEntry& a, const LogEntry& b) { return a.timestamp > b.timestamp; }); } else { std::sort(rawEntries_.begin(), rawEntries_.end(), [](const LogEntry& a, const LogEntry& b) { return a.timestamp < b.timestamp; }); } wrappedDirty_ = true; } void LogViewerPage::handleEvent(const SDL_Event& event) { if (!state_) return; if (event.type == SDL_EVENT_KEY_DOWN) { SDL_Keycode key = event.key.key; if (key == SDLK_L || key == SDLK_F2 || key == SDLK_RIGHT) { filterLevel_ = static_cast((static_cast(filterLevel_) + 1) % 5); scrollOffset_ = 0; refreshLogs(); } else if (key == SDLK_LEFT) { filterLevel_ = static_cast((static_cast(filterLevel_) - 1 + 5) % 5); scrollOffset_ = 0; refreshLogs(); } else if (key == SDLK_S || key == SDLK_F3) { sortOrder_ = (sortOrder_ == SortOrder::NewestFirst) ? SortOrder::OldestFirst : SortOrder::NewestFirst; scrollOffset_ = 0; refreshLogs(); } else if (key == SDLK_C) { radiantwave::logger::Logger::instance().clear(); scrollOffset_ = 0; refreshLogs(); } else if (key == SDLK_UP) { if (scrollOffset_ > 0) scrollOffset_--; } else if (key == SDLK_DOWN) { scrollOffset_++; } else if (key == SDLK_PAGEUP) { scrollOffset_ = std::max(0, scrollOffset_ - 10); } else if (key == SDLK_PAGEDOWN) { scrollOffset_ += 10; } else if (key == SDLK_ESCAPE) { state_->pages.pop(*state_); } } } void LogViewerPage::update(float dt) { updateTimer_ += dt; if (updateTimer_ >= 0.25f) { updateTimer_ = 0.0f; refreshLogs(); } } void LogViewerPage::render(SDL_Renderer* renderer) { int winW, winH; radiantwave::getLayoutSize(renderer, &winW, &winH); float fw = static_cast(winW); float fh = static_cast(winH); // Background SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 15, 18, 25, 255); SDL_FRect bgRect{0, 0, fw, fh}; SDL_RenderFillRect(renderer, &bgRect); if (!fontTitle_ || !fontHeader_ || !fontLog_ || !fontSmall_) return; SDL_Color white = {255, 255, 255, 255}; SDL_Color cyan = {100, 220, 255, 255}; SDL_Color gray = {160, 160, 160, 255}; float currentY = 25.0f; // 1. Title titleText_.ensure(renderer, fontTitle_, "System Event Log Viewer", white); if (titleText_.texture) { float x = (fw - titleText_.width) / 2.0f; SDL_FRect dst{x, currentY, titleText_.width, titleText_.height}; SDL_RenderTexture(renderer, titleText_.texture, nullptr, &dst); currentY += 55.0f; } // 2. Control & Status Header Bar std::string filterStr = "Filter: " + std::string(filterLevelToString(filterLevel_)); std::string sortStr = "Sort: " + std::string((sortOrder_ == SortOrder::NewestFirst) ? "Newest First" : "Oldest First"); std::string countStr = "Entries: " + std::to_string(rawEntries_.size()); std::string headerInfo = filterStr + " | " + sortStr + " | " + countStr + " | TZ: " + currentTzCode_; SDL_SetRenderDrawColor(renderer, 35, 42, 60, 255); SDL_FRect barRect{40.0f, currentY, fw - 80.0f, 40.0f}; SDL_RenderFillRect(renderer, &barRect); headerText_.ensure(renderer, fontHeader_, headerInfo, cyan); if (headerText_.texture) { SDL_FRect dst{55.0f, currentY + (40.0f - headerText_.height) / 2.0f, headerText_.width, headerText_.height}; SDL_RenderTexture(renderer, headerText_.texture, nullptr, &dst); } currentY += 55.0f; // 3. Render Log Entries with Full Screen Word-Wrapping. The wrapped text // is recomputed only when the entries or the window width change; the // per-line textures are cached and re-rasterized only when a line's text // or color changes. float maxLineWidth = fw - 80.0f; if (wrappedDirty_ || maxLineWidth != lastMaxLineWidth_) { rebuildWrapped(maxLineWidth); lastMaxLineWidth_ = maxLineWidth; wrappedDirty_ = false; } int visibleLineCount = static_cast((fh - currentY - 50.0f) / 26.0f); int maxScroll = std::max(0, static_cast(wrappedTexts_.size()) - visibleLineCount); if (scrollOffset_ > maxScroll) scrollOffset_ = maxScroll; if (logLineCache_.size() != wrappedTexts_.size()) logLineCache_.resize(wrappedTexts_.size()); float lineY = currentY; for (size_t i = scrollOffset_; i < wrappedTexts_.size(); ++i) { if (lineY + 26.0f > fh - 45.0f) break; const auto& wline = wrappedTexts_[i]; CachedText& cached = logLineCache_[i]; cached.ensure(renderer, fontLog_, wline.text, wline.color); if (cached.texture) { SDL_FRect dst{40.0f, lineY, cached.width, cached.height}; SDL_RenderTexture(renderer, cached.texture, nullptr, &dst); } lineY += 26.0f; } // 4. Footer Help Line footerText_.ensure(renderer, fontSmall_, "[UP/DOWN] Scroll [L/F2] Filter Level [S/F3] Sort Order [C] Clear Logs [ESC] Back", gray); if (footerText_.texture) { float x = (fw - footerText_.width) / 2.0f; SDL_FRect dst{x, fh - 35.0f, footerText_.width, footerText_.height}; SDL_RenderTexture(renderer, footerText_.texture, nullptr, &dst); } } void LogViewerPage::rebuildWrapped(float maxLineWidth) { wrappedTexts_.clear(); if (!fontLog_) return; using namespace radiantwave::logger; for (const auto& entry : rawEntries_) { std::string timeStr = radiantwave::timezone::formatUtcToTimezone(entry.timestamp, currentTzCode_, "%Y-%m-%d") + " " + radiantwave::datetime::formatHm12h( radiantwave::timezone::formatUtcToTimezone(entry.timestamp, currentTzCode_, "%H:%M")); std::string fullLineStr = "[" + timeStr + "] [" + std::string(logLevelToString(entry.level)) + "] [" + entry.component + "] " + entry.message; SDL_Color levelColor = {255, 255, 255, 255}; if (entry.level == LogLevel::Info) levelColor = {255, 255, 255, 255}; else if (entry.level == LogLevel::Warning) levelColor = {255, 200, 0, 255}; else if (entry.level == LogLevel::Error) levelColor = {255, 80, 80, 255}; else if (entry.level == LogLevel::Fatal) levelColor = {255, 60, 180, 255}; auto subLines = wrapText(fontLog_, fullLineStr, maxLineWidth); for (const auto& sub : subLines) { wrappedTexts_.push_back({sub, levelColor}); } } } void LogViewerPage::createFonts() { if (fontTitle_) return; fontTitle_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48); fontHeader_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 26); fontLog_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 22); fontSmall_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 20); } void LogViewerPage::destroyFonts() { if (fontTitle_) { TTF_CloseFont(fontTitle_); fontTitle_ = nullptr; } if (fontHeader_) { TTF_CloseFont(fontHeader_); fontHeader_ = nullptr; } if (fontLog_) { TTF_CloseFont(fontLog_); fontLog_ = nullptr; } if (fontSmall_) { TTF_CloseFont(fontSmall_); fontSmall_ = nullptr; } } void LogViewerPage::destroyTextures() { titleText_.destroy(); headerText_.destroy(); footerText_.destroy(); for (auto& cached : logLineCache_) cached.destroy(); logLineCache_.clear(); wrappedTexts_.clear(); wrappedDirty_ = true; lastMaxLineWidth_ = -1.0f; }