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.
306 lines
11 KiB
C++
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;
|
|
}
|