logwindow.cpp (12743B)
1 // SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin <stenzek@gmail.com> 2 // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) 3 4 #include "logwindow.h" 5 #include "mainwindow.h" 6 #include "qthost.h" 7 #include "settingwidgetbinder.h" 8 9 #include "util/host.h" 10 11 #include <QtCore/QLatin1StringView> 12 #include <QtCore/QUtf8StringView> 13 #include <QtGui/QIcon> 14 #include <QtWidgets/QMenuBar> 15 #include <QtWidgets/QScrollBar> 16 17 // TODO: Since log callbacks are synchronized, no mutex is needed here. 18 // But once I get rid of that, there will be. 19 LogWindow* g_log_window; 20 21 LogWindow::LogWindow(bool attach_to_main) 22 : QMainWindow(), m_filter_names(Settings::GetLogFilters()), m_attached_to_main_window(attach_to_main) 23 { 24 restoreSize(); 25 createUi(); 26 27 Log::RegisterCallback(&LogWindow::logCallback, this); 28 } 29 30 LogWindow::~LogWindow() = default; 31 32 void LogWindow::updateSettings() 33 { 34 const bool new_enabled = Host::GetBoolSettingValue("Logging", "LogToWindow", false); 35 const bool attach_to_main = Host::GetBoolSettingValue("Logging", "AttachLogWindowToMainWindow", true); 36 const bool curr_enabled = (g_log_window != nullptr); 37 if (new_enabled == curr_enabled) 38 { 39 if (g_log_window && g_log_window->m_attached_to_main_window != attach_to_main) 40 { 41 g_log_window->m_attached_to_main_window = attach_to_main; 42 if (attach_to_main) 43 g_log_window->reattachToMainWindow(); 44 } 45 46 return; 47 } 48 49 if (new_enabled) 50 { 51 g_log_window = new LogWindow(attach_to_main); 52 if (attach_to_main && g_main_window && g_main_window->isVisible()) 53 g_log_window->reattachToMainWindow(); 54 55 g_log_window->show(); 56 } 57 else if (g_log_window) 58 { 59 g_log_window->m_destroying = true; 60 g_log_window->close(); 61 g_log_window->deleteLater(); 62 g_log_window = nullptr; 63 } 64 } 65 66 void LogWindow::destroy() 67 { 68 if (!g_log_window) 69 return; 70 71 g_log_window->m_destroying = true; 72 g_log_window->close(); 73 g_log_window->deleteLater(); 74 g_log_window = nullptr; 75 } 76 77 void LogWindow::reattachToMainWindow() 78 { 79 // Skip when maximized. 80 if (g_main_window->windowState() & (Qt::WindowMaximized | Qt::WindowFullScreen)) 81 return; 82 83 resize(width(), g_main_window->height()); 84 85 const QPoint new_pos = g_main_window->pos() + QPoint(g_main_window->width() + 10, 0); 86 if (pos() != new_pos) 87 move(new_pos); 88 } 89 90 void LogWindow::updateWindowTitle() 91 { 92 QString title; 93 94 const QString& serial = QtHost::GetCurrentGameSerial(); 95 96 if (QtHost::IsSystemValid() && !serial.isEmpty()) 97 { 98 const QFileInfo fi(QtHost::GetCurrentGamePath()); 99 title = tr("Log Window - %1 [%2]").arg(serial).arg(fi.fileName()); 100 } 101 else 102 { 103 title = tr("Log Window"); 104 } 105 106 setWindowTitle(title); 107 } 108 109 void LogWindow::createUi() 110 { 111 QIcon icon; 112 icon.addFile(QString::fromUtf8(":/icons/duck.png"), QSize(), QIcon::Normal, QIcon::Off); 113 setWindowIcon(icon); 114 setWindowFlag(Qt::WindowCloseButtonHint, false); 115 updateWindowTitle(); 116 117 QAction* action; 118 119 QMenuBar* menu = new QMenuBar(this); 120 setMenuBar(menu); 121 122 QMenu* log_menu = menu->addMenu("&Log"); 123 action = log_menu->addAction(tr("&Clear")); 124 connect(action, &QAction::triggered, this, &LogWindow::onClearTriggered); 125 action = log_menu->addAction(tr("&Save...")); 126 connect(action, &QAction::triggered, this, &LogWindow::onSaveTriggered); 127 128 log_menu->addSeparator(); 129 130 action = log_menu->addAction(tr("Cl&ose")); 131 connect(action, &QAction::triggered, this, &LogWindow::close); 132 133 QMenu* settings_menu = menu->addMenu(tr("&Settings")); 134 135 action = settings_menu->addAction(tr("Log To &System Console")); 136 action->setCheckable(true); 137 SettingWidgetBinder::BindWidgetToBoolSetting(nullptr, action, "Logging", "LogToConsole", false); 138 139 action = settings_menu->addAction(tr("Log To &Debug Console")); 140 action->setCheckable(true); 141 SettingWidgetBinder::BindWidgetToBoolSetting(nullptr, action, "Logging", "LogToDebug", false); 142 143 action = settings_menu->addAction(tr("Log To &File")); 144 action->setCheckable(true); 145 SettingWidgetBinder::BindWidgetToBoolSetting(nullptr, action, "Logging", "LogToFile", false); 146 147 settings_menu->addSeparator(); 148 149 action = settings_menu->addAction(tr("Attach To &Main Window")); 150 action->setCheckable(true); 151 SettingWidgetBinder::BindWidgetToBoolSetting(nullptr, action, "Logging", "AttachLogWindowToMainWindow", true); 152 153 action = settings_menu->addAction(tr("Show &Timestamps")); 154 action->setCheckable(true); 155 SettingWidgetBinder::BindWidgetToBoolSetting(nullptr, action, "Logging", "LogTimestamps", true); 156 157 settings_menu->addSeparator(); 158 159 m_level_menu = settings_menu->addMenu(tr("&Log Level")); 160 for (u32 i = 0; i < static_cast<u32>(LOGLEVEL_COUNT); i++) 161 { 162 action = m_level_menu->addAction(QString::fromUtf8(Settings::GetLogLevelDisplayName(static_cast<LOGLEVEL>(i)))); 163 action->setCheckable(true); 164 connect(action, &QAction::triggered, this, [this, i]() { setLogLevel(static_cast<LOGLEVEL>(i)); }); 165 } 166 updateLogLevelUi(); 167 168 QMenu* filters_menu = menu->addMenu(tr("&Filters")); 169 populateFilters(filters_menu); 170 171 m_text = new QPlainTextEdit(this); 172 m_text->setReadOnly(true); 173 m_text->setUndoRedoEnabled(false); 174 m_text->setTextInteractionFlags(Qt::TextSelectableByKeyboard | Qt::TextSelectableByMouse); 175 m_text->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); 176 177 #if defined(_WIN32) 178 QFont font("Consolas"); 179 font.setPointSize(10); 180 #elif defined(__APPLE__) 181 QFont font("Monaco"); 182 font.setPointSize(11); 183 #else 184 QFont font("Monospace"); 185 font.setStyleHint(QFont::TypeWriter); 186 #endif 187 m_text->setFont(font); 188 189 setCentralWidget(m_text); 190 } 191 192 void LogWindow::updateLogLevelUi() 193 { 194 const u32 level = Settings::ParseLogLevelName(Host::GetBaseStringSettingValue("Logging", "LogLevel", "").c_str()) 195 .value_or(Settings::DEFAULT_LOG_LEVEL); 196 197 const QList<QAction*> actions = m_level_menu->actions(); 198 for (u32 i = 0; i < actions.size(); i++) 199 actions[i]->setChecked(i == level); 200 } 201 202 void LogWindow::setLogLevel(LOGLEVEL level) 203 { 204 Host::SetBaseStringSettingValue("Logging", "LogLevel", Settings::GetLogLevelName(level)); 205 Host::CommitBaseSettingChanges(); 206 g_emu_thread->applySettings(false); 207 } 208 209 void LogWindow::populateFilters(QMenu* filter_menu) 210 { 211 const std::string filters = Host::GetBaseStringSettingValue("Logging", "LogFilter", ""); 212 for (size_t i = 0; i < m_filter_names.size(); i++) 213 { 214 const char* filter = m_filter_names[i]; 215 const bool is_currently_filtered = (filters.find(filter) == std::string::npos); 216 QAction* action = filter_menu->addAction(QString::fromUtf8(filter)); 217 action->setCheckable(action); 218 action->setChecked(is_currently_filtered); 219 connect(action, &QAction::triggered, this, [this, i](bool checked) { setChannelFiltered(i, !checked); }); 220 } 221 } 222 223 void LogWindow::setChannelFiltered(size_t index, bool enabled) 224 { 225 const char* filter = m_filter_names[index]; 226 const size_t filter_len = std::strlen(filter); 227 228 std::string filters = Host::GetBaseStringSettingValue("Logging", "LogFilter", ""); 229 const std::string::size_type pos = filters.find(filter); 230 231 if (!enabled) 232 { 233 if (pos == std::string::npos) 234 return; 235 236 const size_t erase_count = 237 filter_len + (((pos + filter_len) < filters.length() && filters[pos + filter_len] == ' ') ? 1 : 0); 238 filters.erase(pos, erase_count); 239 } 240 else 241 { 242 if (pos != std::string::npos) 243 return; 244 245 if (!filters.empty() && filters.back() != ' ') 246 filters.push_back(' '); 247 filters.append(filter); 248 } 249 250 Host::SetBaseStringSettingValue("Logging", "LogFilter", filters.c_str()); 251 Host::CommitBaseSettingChanges(); 252 g_emu_thread->applySettings(false); 253 } 254 255 void LogWindow::onClearTriggered() 256 { 257 m_text->clear(); 258 } 259 260 void LogWindow::onSaveTriggered() 261 { 262 const QString path = QFileDialog::getSaveFileName(this, tr("Select Log File"), QString(), tr("Log Files (*.txt)")); 263 if (path.isEmpty()) 264 return; 265 266 QFile file(path); 267 if (!file.open(QFile::WriteOnly | QFile::Text)) 268 { 269 QMessageBox::critical(this, tr("Error"), tr("Failed to open file for writing.")); 270 return; 271 } 272 273 file.write(m_text->toPlainText().toUtf8()); 274 file.close(); 275 276 appendMessage(QLatin1StringView("LogWindow"), LOGLEVEL_INFO, tr("Log was written to %1.\n").arg(path)); 277 } 278 279 void LogWindow::logCallback(void* pUserParam, const char* channelName, const char* functionName, LOGLEVEL level, 280 std::string_view message) 281 { 282 LogWindow* this_ptr = static_cast<LogWindow*>(pUserParam); 283 284 // TODO: Split message based on lines. 285 // I don't like the memory allocations here either... 286 287 QString qmessage; 288 qmessage.reserve(message.length() + 1); 289 qmessage.append(QUtf8StringView(message.data(), message.length())); 290 qmessage.append(QChar('\n')); 291 292 const QLatin1StringView qchannel((level <= LOGLEVEL_WARNING) ? functionName : channelName); 293 294 if (g_emu_thread->isOnUIThread()) 295 { 296 this_ptr->appendMessage(qchannel, level, qmessage); 297 } 298 else 299 { 300 QMetaObject::invokeMethod(this_ptr, "appendMessage", Qt::QueuedConnection, 301 Q_ARG(const QLatin1StringView&, qchannel), Q_ARG(quint32, static_cast<u32>(level)), 302 Q_ARG(const QString&, qmessage)); 303 } 304 } 305 306 void LogWindow::closeEvent(QCloseEvent* event) 307 { 308 if (!m_destroying) 309 { 310 event->ignore(); 311 return; 312 } 313 314 Log::UnregisterCallback(&LogWindow::logCallback, this); 315 316 saveSize(); 317 318 QMainWindow::closeEvent(event); 319 } 320 321 void LogWindow::appendMessage(const QLatin1StringView& channel, quint32 level, const QString& message) 322 { 323 QTextCursor temp_cursor = m_text->textCursor(); 324 QScrollBar* scrollbar = m_text->verticalScrollBar(); 325 const bool cursor_at_end = temp_cursor.atEnd(); 326 const bool scroll_at_end = scrollbar->sliderPosition() == scrollbar->maximum(); 327 328 temp_cursor.movePosition(QTextCursor::End); 329 330 { 331 static constexpr const QChar level_characters[LOGLEVEL_COUNT] = {'X', 'E', 'W', 'I', 'V', 'D', 'B', 'T'}; 332 static constexpr const QColor level_colors[LOGLEVEL_COUNT] = { 333 QColor(255, 255, 255), // NONE 334 QColor(0xE7, 0x48, 0x56), // ERROR, Red Intensity 335 QColor(0xF9, 0xF1, 0xA5), // WARNING, Yellow Intensity 336 QColor(0xF2, 0xF2, 0xF2), // INFO, White Intensity 337 QColor(0x16, 0xC6, 0x0C), // VERBOSE, Green Intensity 338 QColor(0xCC, 0xCC, 0xCC), // DEV, White 339 QColor(0x13, 0xA1, 0x0E), // DEBUG, Green 340 QColor(0x00, 0x37, 0xDA), // TRACE, Blue 341 }; 342 static constexpr const QColor timestamp_color = QColor(0xcc, 0xcc, 0xcc); 343 static constexpr const QColor channel_color = QColor(0xf2, 0xf2, 0xf2); 344 345 QTextCharFormat format = temp_cursor.charFormat(); 346 347 if (g_settings.log_timestamps) 348 { 349 const float message_time = Log::GetCurrentMessageTime(); 350 const QString qtimestamp = QStringLiteral("[%1] ").arg(message_time, 10, 'f', 4); 351 format.setForeground(QBrush(timestamp_color)); 352 temp_cursor.setCharFormat(format); 353 temp_cursor.insertText(qtimestamp); 354 } 355 356 const QString qchannel = (level <= LOGLEVEL_WARNING) ? 357 QStringLiteral("%1(%2): ").arg(level_characters[level]).arg(channel) : 358 QStringLiteral("%1/%2: ").arg(level_characters[level]).arg(channel); 359 format.setForeground(QBrush(channel_color)); 360 temp_cursor.setCharFormat(format); 361 temp_cursor.insertText(qchannel); 362 363 // message has \n already 364 format.setForeground(QBrush(level_colors[level])); 365 temp_cursor.setCharFormat(format); 366 temp_cursor.insertText(message); 367 } 368 369 if (cursor_at_end) 370 { 371 if (scroll_at_end) 372 { 373 m_text->setTextCursor(temp_cursor); 374 scrollbar->setSliderPosition(scrollbar->maximum()); 375 } 376 else 377 { 378 // Can't let changing the cursor affect the scroll bar... 379 const int pos = scrollbar->sliderPosition(); 380 m_text->setTextCursor(temp_cursor); 381 scrollbar->setSliderPosition(pos); 382 } 383 } 384 } 385 386 void LogWindow::saveSize() 387 { 388 const int current_width = Host::GetBaseIntSettingValue("UI", "LogWindowWidth", DEFAULT_WIDTH); 389 const int current_height = Host::GetBaseIntSettingValue("UI", "LogWindowHeight", DEFAULT_HEIGHT); 390 const QSize wsize = size(); 391 392 bool changed = false; 393 if (current_width != wsize.width()) 394 { 395 Host::SetBaseIntSettingValue("UI", "LogWindowWidth", wsize.width()); 396 changed = true; 397 } 398 if (current_height != wsize.height()) 399 { 400 Host::SetBaseIntSettingValue("UI", "LogWindowHeight", wsize.height()); 401 changed = true; 402 } 403 404 if (changed) 405 Host::CommitBaseSettingChanges(); 406 } 407 408 void LogWindow::restoreSize() 409 { 410 const int width = Host::GetBaseIntSettingValue("UI", "LogWindowWidth", DEFAULT_WIDTH); 411 const int height = Host::GetBaseIntSettingValue("UI", "LogWindowHeight", DEFAULT_HEIGHT); 412 resize(width, height); 413 }