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.ext.imgui.ImGuiWidget>

Namespaces Index

namespacehelios
namespaceext

Platform-specific extensions and backend implementations. More...

namespaceimgui
namespacewidgets

Debug and developer widgets for ImGui overlays. More...

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/**
2 * @file LogWidget.ixx
3 * @brief ImGui widget for displaying log messages in a scrollable text area.
4 */
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.ext.imgui.widgets.LogWidget;
20
21import helios.ext.imgui.ImGuiWidget;
22
23export namespace helios::ext::imgui::widgets {
24
25 /**
26 * @brief Log severity level for categorizing and filtering messages.
27 */
28 enum class LogLevel : std::uint8_t {
29 Debug = 0,
30 Info = 1,
31 Warn = 2,
32 Error = 3
33 };
34
35 /**
36 * @brief Represents a single log entry with level, scope, and message.
37 */
38 struct LogEntry {
39 /**
40 * @brief Severity level of this log entry.
41 */
43
44 /**
45 * @brief Source scope/module that generated this entry.
46 */
47 std::string scope;
48
49 /**
50 * @brief The log message text.
51 */
52 std::string message;
53
54 /**
55 * @brief Formatted timestamp when this entry was created.
56 */
57 std::string timestamp;
58 };
59
60 /**
61 * @class LogWidget
62 * @brief Debug widget for displaying log output in a scrollable ImGui panel.
63 *
64 * This widget maintains separate buffers for each log scope and renders them
65 * in a scrollable text area. It supports filtering by log level, clearing
66 * the buffer, and auto-scrolling to the latest messages.
67 *
68 * Each scope has its own buffer with a maximum of 1000 entries, allowing
69 * efficient scope-based filtering without losing messages from other scopes.
70 *
71 * @note This widget uses internal locking for adding log entries from multiple threads.
72 */
73 class LogWidget : public ImGuiWidget {
74
75 private:
76 /**
77 * @brief Key for the "all scopes" combined view.
78 */
79 static constexpr const char* ALL_SCOPES_KEY = "__all__";
80
81 /**
82 * @brief Key for the "none" option that disables logging.
83 */
84 static constexpr const char* NONE_SCOPES_KEY = "__none__";
85
86 /**
87 * @brief Per-scope log buffers. Key is scope name, value is entry vector.
88 *
89 * Special key "__all__" contains all entries (for "All Scopes" view).
90 */
91 std::unordered_map<std::string, std::vector<LogEntry>> scopeBuffers_;
92
93 /**
94 * @brief Maximum number of entries to retain per scope buffer.
95 */
96 std::size_t maxEntries_ = 1000;
97
98 /**
99 * @brief Mutex for thread-safe access to the log buffers.
100 */
101 mutable std::mutex bufferMutex_;
102
103 /**
104 * @brief Whether to automatically scroll to the bottom on new entries.
105 */
106 bool autoScroll_ = true;
107
108 /**
109 * @brief Previous autoScroll state to detect toggle events.
110 */
111 bool prevAutoScroll_ = true;
112
113 /**
114 * @brief Flag to nudge scroll up one entry when auto-scroll is disabled.
115 */
116 bool scrollUpOneEntry_ = false;
117
118 /**
119 * @brief Flag to indicate that new content was added and scroll is needed.
120 */
121 bool scrollToBottom_ = false;
122
123 /**
124 * @brief Tracks if user was at bottom in the previous frame.
125 */
126 bool wasAtBottom_ = true;
127
128 /**
129 * @brief Whether new entries are currently accepted into the buffer.
130 */
131 std::atomic<bool> acceptNewEntries_{true};
132
133 /**
134 * @brief Counts how many log entries were skipped while logging was paused.
135 */
136 std::atomic<std::size_t> skippedEntries_{0};
137
138 /**
139 * @brief Minimum log level to display (filters out lower levels).
140 */
141 LogLevel filterLevel_ = LogLevel::Debug;
142
143 /**
144 * @brief Text filter for searching within log messages.
145 */
146 ImGuiTextFilter textFilter_;
147
148 /**
149 * @brief Current filter level selection index for the combo box.
150 */
151 int filterLevelIndex_ = 0;
152
153 /**
154 * @brief Collection of all unique scopes seen in log entries.
155 */
156 std::vector<std::string> collectedScopes_;
157
158 /**
159 * @brief Currently selected scope index in the combo box (0 = "All", -1 = "None").
160 */
161 int selectedScopeIndex_ = -1;
162
163 /**
164 * @brief Currently active scope filter (empty = show all).
165 */
166 std::string activeScopeFilter_;
167
168 /**
169 * @brief Whether logging is completely disabled (None selected).
170 */
171 bool loggingDisabled_ = true;
172
173 /**
174 * @brief Callback function to notify external systems of scope filter changes.
175 *
176 * Called when user selects a scope in the combo box.
177 * Signature: void(const std::string& scope) where empty string means "all".
178 */
179 std::function<void(const std::string&)> onScopeFilterChanged_;
180
181 /**
182 * @brief Adds a scope to the collection if not already present.
183 *
184 * @param scope The scope to add.
185 */
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
193 /**
194 * @brief Adds an entry to a specific buffer, trimming if necessary.
195 */
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
205 /**
206 * @brief Returns the currently active buffer based on scope selection.
207 */
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
224 /**
225 * @brief Returns an ImVec4 color for the given log level.
226 *
227 * @param level The log level to get a color for.
228 *
229 * @return The color corresponding to the log level.
230 */
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
241 /**
242 * @brief Returns a string label for the given log level.
243 *
244 * @param level The log level to get a label for.
245 *
246 * @return The label string (e.g., "[DEBUG]", "[INFO]").
247 */
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
258 /**
259 * @brief Generates a simple timestamp string (HH:MM:SS.mmm).
260 *
261 * @return The current time formatted as "HH:MM:SS.mmm".
262 */
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
285 /**
286 * @brief Adds a log entry to the appropriate scope buffer(s).
287 *
288 * Thread-safe. Adds to both the scope-specific buffer and the "all" buffer.
289 */
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
324 /**
325 * @brief Convenience method to add a debug-level log entry.
326 *
327 * @param scope The source scope or module name.
328 * @param message The log message text.
329 */
330 void debug(const std::string& scope, const std::string& message) {
331 addLog(LogLevel::Debug, scope, message);
332 }
333
334 /**
335 * @brief Convenience method to add an info-level log entry.
336 *
337 * @param scope The source scope or module name.
338 * @param message The log message text.
339 */
340 void info(const std::string& scope, const std::string& message) {
341 addLog(LogLevel::Info, scope, message);
342 }
343
344 /**
345 * @brief Convenience method to add a warning-level log entry.
346 *
347 * @param scope The source scope or module name.
348 * @param message The log message text.
349 */
350 void warn(const std::string& scope, const std::string& message) {
351 addLog(LogLevel::Warn, scope, message);
352 }
353
354 /**
355 * @brief Convenience method to add an error-level log entry.
356 *
357 * @param scope The source scope or module name.
358 * @param message The log message text.
359 */
360 void error(const std::string& scope, const std::string& message) {
361 addLog(LogLevel::Error, scope, message);
362 }
363
364 /**
365 * @brief Clears all log entries from all buffers.
366 */
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
373 /**
374 * @brief Sets the maximum number of entries to retain per scope.
375 *
376 * @param max The maximum buffer size.
377 */
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
391 /**
392 * @brief Enables or disables auto-scrolling to the latest entry.
393 *
394 * @param enabled True to enable auto-scroll.
395 */
396 void setAutoScroll(bool enabled) noexcept {
397 autoScroll_ = enabled;
398 }
399
400 /**
401 * @brief Sets the minimum log level to display.
402 *
403 * @param level Entries below this level are hidden.
404 */
405 void setFilterLevel(LogLevel level) noexcept {
406 filterLevel_ = level;
407 filterLevelIndex_ = static_cast<int>(level);
408 }
409
410 /**
411 * @brief Sets a callback to be invoked when the scope filter changes.
412 *
413 * The callback receives the selected scope string, or an empty string
414 * when "All" is selected. Use this to integrate with LogManager::setScopeFilter().
415 *
416 * @param callback Function to call on scope filter change.
417 */
418 void setScopeFilterCallback(std::function<void(const std::string&)> callback) {
419 onScopeFilterChanged_ = std::move(callback);
420 }
421
422 /**
423 * @brief Returns the current number of log entries in the active buffer.
424 *
425 * @return The number of entries currently stored.
426 */
427 [[nodiscard]] std::size_t entryCount() const noexcept {
428 std::lock_guard<std::mutex> lock(bufferMutex_);
429 return activeBuffer().size();
430 }
431
432 /**
433 * @brief Renders the log widget using ImGui.
434 */
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.15.0.