Skip to main content

LogWidget.ixx File

ImGui widget for displaying log messages in a scrollable text area. More...

Included Headers

#include <string> #include <vector> #include <unordered_map> #include <mutex> #include <cstdint> #include <chrono> #include <iomanip> #include <sstream> #include <atomic> #include <functional> #include "imgui.h" #include <helios.imgui.ImGuiWidget>

Namespaces Index

namespacehelios
namespaceimgui
namespacewidgets

Classes Index

structLogEntry

Represents a single log entry with level, scope, and message. More...

classLogWidget

Debug widget for displaying log output in a scrollable ImGui panel. More...

Description

ImGui widget for displaying log messages in a scrollable text area.

File Listing

The file content with the documentation metadata removed is:

1
5module;
6
7#include <string>
8#include <vector>
9#include <unordered_map>
10#include <mutex>
11#include <cstdint>
12#include <chrono>
13#include <iomanip>
14#include <sstream>
15#include <atomic>
16#include <functional>
17#include "imgui.h"
18
19export module helios.imgui.widgets.LogWidget;
20
21import helios.imgui.ImGuiWidget;
22
23export namespace helios::imgui::widgets {
24
28 enum class LogLevel : std::uint8_t {
29 Debug = 0,
30 Info = 1,
31 Warn = 2,
32 Error = 3
33 };
34
38 struct LogEntry {
43
47 std::string scope;
48
52 std::string message;
53
57 std::string timestamp;
58 };
59
73 class LogWidget : public ImGuiWidget {
74
75 private:
79 static constexpr const char* ALL_SCOPES_KEY = "__all__";
80
84 static constexpr const char* NONE_SCOPES_KEY = "__none__";
85
91 std::unordered_map<std::string, std::vector<LogEntry>> scopeBuffers_;
92
96 std::size_t maxEntries_ = 1000;
97
101 mutable std::mutex bufferMutex_;
102
106 bool autoScroll_ = true;
107
111 bool prevAutoScroll_ = true;
112
116 bool scrollUpOneEntry_ = false;
117
121 bool scrollToBottom_ = false;
122
126 bool wasAtBottom_ = true;
127
131 std::atomic<bool> acceptNewEntries_{true};
132
136 std::atomic<std::size_t> skippedEntries_{0};
137
141 LogLevel filterLevel_ = LogLevel::Debug;
142
146 ImGuiTextFilter textFilter_;
147
151 int filterLevelIndex_ = 0;
152
156 std::vector<std::string> collectedScopes_;
157
161 int selectedScopeIndex_ = -1;
162
166 std::string activeScopeFilter_;
167
171 bool loggingDisabled_ = true;
172
179 std::function<void(const std::string&)> onScopeFilterChanged_;
180
186 void collectScope(const std::string& scope) {
187 for (const auto& existing : collectedScopes_) {
188 if (existing == scope) return;
189 }
190 collectedScopes_.push_back(scope);
191 }
192
196 void addToBuffer(std::vector<LogEntry>& buffer, LogEntry entry) {
197 buffer.push_back(std::move(entry));
198 if (maxEntries_ > 0 && buffer.size() > maxEntries_) {
199 const std::size_t overflow = buffer.size() - maxEntries_;
200 buffer.erase(buffer.begin(),
201 buffer.begin() + static_cast<std::ptrdiff_t>(overflow));
202 }
203 }
204
208 [[nodiscard]] const std::vector<LogEntry>& activeBuffer() const {
209 if (activeScopeFilter_.empty()) {
210 auto it = scopeBuffers_.find(ALL_SCOPES_KEY);
211 if (it != scopeBuffers_.end()) {
212 return it->second;
213 }
214 } else {
215 auto it = scopeBuffers_.find(activeScopeFilter_);
216 if (it != scopeBuffers_.end()) {
217 return it->second;
218 }
219 }
220 static const std::vector<LogEntry> empty;
221 return empty;
222 }
223
231 [[nodiscard]] static ImVec4 colorForLevel(LogLevel level) noexcept {
232 switch (level) {
233 case LogLevel::Debug: return ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Gray
234 case LogLevel::Info: return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White
235 case LogLevel::Warn: return ImVec4(1.0f, 0.8f, 0.0f, 1.0f); // Yellow
236 case LogLevel::Error: return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red
237 default: return ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
238 }
239 }
240
248 [[nodiscard]] static const char* labelForLevel(LogLevel level) noexcept {
249 switch (level) {
250 case LogLevel::Debug: return "[DEBUG]";
251 case LogLevel::Info: return "[INFO] ";
252 case LogLevel::Warn: return "[WARN] ";
253 case LogLevel::Error: return "[ERROR]";
254 default: return "[?????]";
255 }
256 }
257
263 [[nodiscard]] static std::string currentTimestamp() noexcept {
264 auto now = std::chrono::system_clock::now();
265 auto time_t_now = std::chrono::system_clock::to_time_t(now);
266 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
267 now.time_since_epoch()) % 1000;
268
269 std::tm tm_buf{};
270#ifdef _WIN32
271 localtime_s(&tm_buf, &time_t_now);
272#else
273 localtime_r(&time_t_now, &tm_buf);
274#endif
275 std::ostringstream oss;
276 oss << std::put_time(&tm_buf, "%H:%M:%S")
277 << '.' << std::setfill('0') << std::setw(3) << ms.count();
278 return oss.str();
279 }
280
281 public:
282 LogWidget() = default;
283 ~LogWidget() override = default;
284
290 void addLog(LogLevel level, const std::string& scope, const std::string& message) {
291 // If logging is completely disabled, skip
292 if (loggingDisabled_) {
293 return;
294 }
295
296 // If user scrolls up and AutoScroll is off, we do not accept new entries
297 // to make sure memory is not flooded with new log entries in the background.
298 if (!acceptNewEntries_.load(std::memory_order_relaxed)) {
299 skippedEntries_.fetch_add(1, std::memory_order_relaxed);
300 return;
301 }
302
303 std::lock_guard<std::mutex> lock(bufferMutex_);
304
305 // Collect unique scopes for the filter dropdown
306 collectScope(scope);
307
308 LogEntry entry;
309 entry.level = level;
310 entry.scope = scope;
311 entry.message = message;
312 entry.timestamp = currentTimestamp();
313
314 // Add to scope-specific buffer
315 addToBuffer(scopeBuffers_[scope], entry);
316
317 // Add to "all scopes" buffer
318 addToBuffer(scopeBuffers_[ALL_SCOPES_KEY], entry);
319
320 // Always signal new content - scroll decision happens in draw()
321 scrollToBottom_ = true;
322 }
323
330 void debug(const std::string& scope, const std::string& message) {
331 addLog(LogLevel::Debug, scope, message);
332 }
333
340 void info(const std::string& scope, const std::string& message) {
341 addLog(LogLevel::Info, scope, message);
342 }
343
350 void warn(const std::string& scope, const std::string& message) {
351 addLog(LogLevel::Warn, scope, message);
352 }
353
360 void error(const std::string& scope, const std::string& message) {
361 addLog(LogLevel::Error, scope, message);
362 }
363
367 void clear() noexcept {
368 std::lock_guard<std::mutex> lock(bufferMutex_);
369 scopeBuffers_.clear();
370 skippedEntries_.store(0, std::memory_order_relaxed);
371 }
372
378 void setMaxEntries(std::size_t max) noexcept {
379 std::lock_guard<std::mutex> lock(bufferMutex_);
380 maxEntries_ = max;
381 // Trim all existing buffers
382 for (auto& [scope, buffer] : scopeBuffers_) {
383 if (maxEntries_ > 0 && buffer.size() > maxEntries_) {
384 const std::size_t overflow = buffer.size() - maxEntries_;
385 buffer.erase(buffer.begin(),
386 buffer.begin() + static_cast<std::ptrdiff_t>(overflow));
387 }
388 }
389 }
390
396 void setAutoScroll(bool enabled) noexcept {
397 autoScroll_ = enabled;
398 }
399
405 void setFilterLevel(LogLevel level) noexcept {
406 filterLevel_ = level;
407 filterLevelIndex_ = static_cast<int>(level);
408 }
409
418 void setScopeFilterCallback(std::function<void(const std::string&)> callback) {
419 onScopeFilterChanged_ = std::move(callback);
420 }
421
427 [[nodiscard]] std::size_t entryCount() const noexcept {
428 std::lock_guard<std::mutex> lock(bufferMutex_);
429 return activeBuffer().size();
430 }
431
435 void draw() override {
436 ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
437
438 // Window is dockable by default when ImGuiConfigFlags_DockingEnable is set
439 if (ImGui::Begin("Log Console", nullptr)) {
440
441 // Toolbar row
442 if (ImGui::Button("Clear")) {
443 clear();
444 }
445 ImGui::SameLine();
446
447 if (ImGui::Checkbox("Auto-Scroll", &autoScroll_)) {
448 // Detect transition from enabled to disabled
449 if (prevAutoScroll_ && !autoScroll_) {
450 scrollUpOneEntry_ = true;
451 }
452 }
453 prevAutoScroll_ = autoScroll_;
454 ImGui::SameLine();
455
456 // Level filter combo
457 const char* levelItems[] = { "Debug", "Info", "Warn", "Error" };
458 ImGui::SetNextItemWidth(80);
459 if (ImGui::Combo("Level", &filterLevelIndex_, levelItems, IM_ARRAYSIZE(levelItems))) {
460 filterLevel_ = static_cast<LogLevel>(filterLevelIndex_);
461 }
462 ImGui::SameLine();
463
464 // Scope filter combo
465 {
466 std::lock_guard<std::mutex> lock(bufferMutex_);
467
468 std::string scopePreview;
469 if (loggingDisabled_) {
470 scopePreview = "None";
471 } else if (selectedScopeIndex_ == 0) {
472 scopePreview = "All Scopes";
473 } else if (selectedScopeIndex_ <= static_cast<int>(collectedScopes_.size())) {
474 scopePreview = collectedScopes_[selectedScopeIndex_ - 1];
475 } else {
476 scopePreview = "All Scopes";
477 }
478
479 ImGui::SetNextItemWidth(150);
480 if (ImGui::BeginCombo("Scope", scopePreview.c_str())) {
481 // "None" option - disables logging completely
482 bool isNoneSelected = loggingDisabled_;
483 if (ImGui::Selectable("None", isNoneSelected)) {
484 loggingDisabled_ = true;
485 selectedScopeIndex_ = -1;
486 activeScopeFilter_.clear();
487 if (onScopeFilterChanged_) {
488 onScopeFilterChanged_(NONE_SCOPES_KEY);
489 }
490 }
491 if (isNoneSelected) {
492 ImGui::SetItemDefaultFocus();
493 }
494
495 // "All Scopes" option
496 bool isSelected = (!loggingDisabled_ && selectedScopeIndex_ == 0);
497 if (ImGui::Selectable("All Scopes", isSelected)) {
498 loggingDisabled_ = false;
499 selectedScopeIndex_ = 0;
500 activeScopeFilter_.clear();
501 if (onScopeFilterChanged_) {
502 onScopeFilterChanged_("");
503 }
504 }
505 if (isSelected) {
506 ImGui::SetItemDefaultFocus();
507 }
508
509 for (std::size_t i = 0; i < collectedScopes_.size(); ++i) {
510 isSelected = (!loggingDisabled_ && selectedScopeIndex_ == static_cast<int>(i + 1));
511 if (ImGui::Selectable(collectedScopes_[i].c_str(), isSelected)) {
512 loggingDisabled_ = false;
513 selectedScopeIndex_ = static_cast<int>(i + 1);
514 activeScopeFilter_ = collectedScopes_[i];
515 if (onScopeFilterChanged_) {
516 onScopeFilterChanged_(activeScopeFilter_);
517 }
518 }
519 if (isSelected) {
520 ImGui::SetItemDefaultFocus();
521 }
522 }
523 ImGui::EndCombo();
524 }
525 }
526 ImGui::SameLine();
527
528 textFilter_.Draw("Filter", 150);
529
530 ImGui::Separator();
531
532 // Log area
533 const float footerHeight =
534 ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeightWithSpacing();
535 if (ImGui::BeginChild("LogScrollRegion", ImVec2(0, -footerHeight),
536 ImGuiChildFlags_Borders,
537 ImGuiWindowFlags_HorizontalScrollbar)) {
538
539 // Copy active buffer
540 std::vector<LogEntry> bufferCopy;
541 bool hasNewContent = false;
542 {
543 std::lock_guard<std::mutex> lock(bufferMutex_);
544 bufferCopy = activeBuffer();
545 hasNewContent = scrollToBottom_;
546 scrollToBottom_ = false;
547 }
548
549 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 1));
550
551 for (const auto& entry : bufferCopy) {
552 // Filter by level
553 if (static_cast<int>(entry.level) < static_cast<int>(filterLevel_)) {
554 continue;
555 }
556
557 // Build display string for text filter
558 // If a specific scope is selected, omit scope from display (already visible in combo)
559 std::string displayLine;
560 if (activeScopeFilter_.empty()) {
561 // "All Scopes" selected - show scope in each line
562 displayLine = entry.timestamp + " " +
563 labelForLevel(entry.level) + " [" +
564 entry.scope + "] " + entry.message;
565 } else {
566 // Specific scope filtered - omit scope from display
567 displayLine = entry.timestamp + " " +
568 labelForLevel(entry.level) + " " + entry.message;
569 }
570
571 // Apply text filter
572 if (!textFilter_.PassFilter(displayLine.c_str())) {
573 continue;
574 }
575
576 // Render with color
577 ImVec4 color = colorForLevel(entry.level);
578 ImGui::PushStyleColor(ImGuiCol_Text, color);
579 ImGui::TextUnformatted(displayLine.c_str());
580 ImGui::PopStyleColor();
581 }
582
583 ImGui::PopStyleVar();
584
585 // Scroll state
586 float scrollY = ImGui::GetScrollY();
587 float scrollMaxY = ImGui::GetScrollMaxY();
588 bool atBottomNow = (scrollMaxY <= 0.0f) || (scrollY >= scrollMaxY - 5.0f);
589
590 // Nudge scroll up when auto-scroll was just disabled
591 // This prevents showing that new entries are being added
592 if (scrollUpOneEntry_) {
593 // Calculate offset for 2 log entries
594 float lineHeight = ImGui::GetTextLineHeightWithSpacing();
595 float nudgeAmount = lineHeight * 2.0f;
596
597 // Set scroll position relative to maximum (scroll up from bottom)
598 float targetScrollY = scrollMaxY - nudgeAmount;
599 if (targetScrollY < 0.0f) targetScrollY = 0.0f;
600 ImGui::SetScrollY(targetScrollY);
601 scrollUpOneEntry_ = false;
602
603 // Update atBottomNow since we just scrolled
604 atBottomNow = false;
605 }
606 // Auto-scroll decision
607 else if (hasNewContent && (autoScroll_ || wasAtBottom_)) {
608 ImGui::SetScrollHereY(1.0f);
609 }
610
611 // Pause logging if:
612 // - AutoScroll is off AND user has not scrolled to lower bottom
613 bool pauseLogging = (!autoScroll_ && !atBottomNow);
614 acceptNewEntries_.store(!pauseLogging, std::memory_order_relaxed);
615 wasAtBottom_ = atBottomNow;
616 }
617 ImGui::EndChild();
618
619 // Footer
620 {
621 std::lock_guard<std::mutex> lock(bufferMutex_);
622 ImGui::Text("Entries: %zu / %zu", activeBuffer().size(), maxEntries_);
623 }
624
625 bool loggingPaused = !acceptNewEntries_.load(std::memory_order_relaxed);
626 auto skipped = skippedEntries_.load(std::memory_order_relaxed);
627
628 if (loggingPaused) {
629 ImGui::SameLine();
630 if (skipped > 0) {
631 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f),
632 "Logging paused (%zu entries skipped)", skipped);
633 } else {
634 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Logging paused");
635 }
636 } else if (skipped > 0) {
637 ImGui::SameLine();
638 ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f),
639 "%zu entries were skipped", skipped);
640 skippedEntries_.store(0, std::memory_order_relaxed);
641 }
642 }
643 ImGui::End();
644 }
645 };
646
647}

Generated via doxygen2docusaurus 2.0.0 by Doxygen 1.9.8.