Files
radiantwave/src/log_viewer_page.cpp
T
NeilClack 59b499037b Kiosk: show all user-visible times as 12-hour AM/PM
Add datetime::formatHm12h/formatTime12h and use them for the Home footer
clock, the Timezone page's local-time sample, and the log viewer
timestamps. Storage and parsing remain 24-hour HH:MM. Extends the
date-format tests with the midnight/noon edge cases.
2026-09-29 08:43:59 -05:00

306 lines
11 KiB
C++

#include "log_viewer_page.h"
#include "app_state.h"
#include "date_format.h"
#include "timezone_helper.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <sstream>
namespace {
struct WrappedLogLine {
std::string text;
SDL_Color color;
};
std::vector<std::string> wrapText(TTF_Font* font, const std::string& text, float maxW) {
std::vector<std::string> 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<float>(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<FilterLevel>((static_cast<int>(filterLevel_) + 1) % 5);
scrollOffset_ = 0;
refreshLogs();
} else if (key == SDLK_LEFT) {
filterLevel_ = static_cast<FilterLevel>((static_cast<int>(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<float>(winW);
float fh = static_cast<float>(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<int>((fh - currentY - 50.0f) / 26.0f);
int maxScroll = std::max(0, static_cast<int>(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;
}