diff --git a/api/logic/CMakeLists.txt b/api/logic/CMakeLists.txt index 4514d8c3..8a1f2f41 100644 --- a/api/logic/CMakeLists.txt +++ b/api/logic/CMakeLists.txt @@ -119,6 +119,8 @@ set(LAUNCH_SOURCES launch/LaunchTask.h launch/LoggedProcess.cpp launch/LoggedProcess.h + launch/LogModel.cpp + launch/LogModel.h launch/MessageLevel.cpp launch/MessageLevel.h ) diff --git a/api/logic/launch/LaunchTask.cpp b/api/logic/launch/LaunchTask.cpp index 5b7ff182..9c09caf4 100644 --- a/api/logic/launch/LaunchTask.cpp +++ b/api/logic/launch/LaunchTask.cpp @@ -167,6 +167,15 @@ bool LaunchTask::abort() return false; } +shared_qobject_ptr LaunchTask::getLogModel() +{ + if(!m_logModel) + { + m_logModel.reset(new LogModel()); + } + return m_logModel; +} + void LaunchTask::onLogLines(const QStringList &lines, MessageLevel::Enum defaultLevel) { for (auto & line: lines) @@ -193,7 +202,8 @@ void LaunchTask::onLogLine(QString line, MessageLevel::Enum level) // censor private user info line = censorPrivateInfo(line); - emit log(line, level); + auto &model = *getLogModel(); + model.append(level, line); } void LaunchTask::emitSucceeded() diff --git a/api/logic/launch/LaunchTask.h b/api/logic/launch/LaunchTask.h index 447445ca..f2fb3a2c 100644 --- a/api/logic/launch/LaunchTask.h +++ b/api/logic/launch/LaunchTask.h @@ -17,6 +17,8 @@ #pragma once #include +#include +#include "LogModel.h" #include "BaseInstance.h" #include "MessageLevel.h" #include "LoggedProcess.h" @@ -80,6 +82,8 @@ public: /* methods */ */ virtual bool abort() override; + shared_qobject_ptr getLogModel(); + public: QString substituteVariables(const QString &cmd) const; QString censorPrivateInfo(QString in); @@ -98,13 +102,6 @@ signals: void requestLogging(); - /** - * @brief emitted when we want to log something - * @param text the text to log - * @param level the level to log at - */ - void log(QString text, MessageLevel::Enum level = MessageLevel::MultiMC); - public slots: void onLogLines(const QStringList& lines, MessageLevel::Enum defaultLevel = MessageLevel::MultiMC); void onLogLine(QString line, MessageLevel::Enum defaultLevel = MessageLevel::MultiMC); @@ -114,6 +111,7 @@ public slots: protected: /* data */ InstancePtr m_instance; + shared_qobject_ptr m_logModel; QList > m_steps; QMap m_censorFilter; int currentStep = -1; diff --git a/api/logic/launch/LogModel.cpp b/api/logic/launch/LogModel.cpp new file mode 100644 index 00000000..c12a0488 --- /dev/null +++ b/api/logic/launch/LogModel.cpp @@ -0,0 +1,135 @@ +#include "LogModel.h" + +LogModel::LogModel(QObject *parent):QAbstractListModel(parent) +{ + m_content.resize(m_maxLines); +} + +int LogModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + + return m_numLines; +} + +QVariant LogModel::data(const QModelIndex &index, int role) const +{ + if (index.row() < 0 || index.row() >= m_numLines) + return QVariant(); + + auto row = index.row(); + auto realRow = (row + m_firstLine) % m_maxLines; + if (role == Qt::DisplayRole || role == Qt::EditRole) + { + return m_content[realRow].line; + } + if(role == LevelRole) + { + return m_content[realRow].level; + } + + return QVariant(); +} + +void LogModel::append(MessageLevel::Enum level, QString line) +{ + int lineNum = (m_firstLine + m_numLines) % m_maxLines; + // overflow + if(m_numLines == m_maxLines) + { + if(m_stopOnOverflow) + { + // nothing more to do, the buffer is full + return; + } + beginRemoveRows(QModelIndex(), 0, 0); + m_firstLine = (m_firstLine + 1) % m_maxLines; + m_numLines --; + endRemoveRows(); + } + else if (m_numLines == m_maxLines - 1 && m_stopOnOverflow) + { + level = MessageLevel::Fatal; + line = m_overflowMessage; + } + beginInsertRows(QModelIndex(), m_numLines, m_numLines); + m_numLines ++; + m_content[lineNum].level = level; + m_content[lineNum].line = line; + endInsertRows(); +} + +void LogModel::clear() +{ + beginResetModel(); + m_firstLine = 0; + m_numLines = 0; + endResetModel(); +} + +QString LogModel::toPlainText() +{ + QString out; + out.reserve(m_numLines * 80); + for(int i = 0; i < m_numLines; i++) + { + QString & line = m_content[(m_firstLine + i) % m_maxLines].line; + out.append(line + '\n'); + } + out.squeeze(); + return out; +} + +void LogModel::setMaxLines(int maxLines) +{ + // no-op + if(maxLines == m_maxLines) + { + return; + } + // if it all still fits in the buffer, just resize it + if(m_firstLine + m_numLines < maxLines) + { + m_maxLines = maxLines; + m_content.resize(maxLines); + return; + } + // otherwise, we need to reorganize the data because it crosses the wrap boundary + QVector newContent; + newContent.resize(maxLines); + if(m_numLines <= maxLines) + { + // if it all fits in the new buffer, just copy it over + for(int i = 0; i < m_numLines; i++) + { + newContent[i] = m_content[(m_firstLine + i) % m_maxLines]; + } + m_content.swap(newContent); + } + else + { + // if it doesn't fit, part of the data needs to be thrown away (the oldest log messages) + int lead = m_numLines - maxLines; + beginRemoveRows(QModelIndex(), 0, lead - 1); + for(int i = 0; i < maxLines; i++) + { + newContent[i] = m_content[(m_firstLine + lead + i) % m_maxLines]; + } + m_numLines = m_maxLines; + m_content.swap(newContent); + endRemoveRows(); + } + m_firstLine = 0; + m_maxLines = maxLines; +} + +void LogModel::setStopOnOverflow(bool stop) +{ + m_stopOnOverflow = stop; +} + +void LogModel::setOverflowMessage(const QString& overflowMessage) +{ + m_overflowMessage = overflowMessage; +} diff --git a/api/logic/launch/LogModel.h b/api/logic/launch/LogModel.h new file mode 100644 index 00000000..87e6b583 --- /dev/null +++ b/api/logic/launch/LogModel.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include "MessageLevel.h" + +#include + +class MULTIMC_LOGIC_EXPORT LogModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit LogModel(QObject *parent = 0); + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + QVariant data(const QModelIndex &index, int role) const; + + void append(MessageLevel::Enum, QString line); + void clear(); + + QString toPlainText(); + + void setMaxLines(int maxLines); + void setStopOnOverflow(bool stop); + void setOverflowMessage(const QString & overflowMessage); + + enum Roles + { + LevelRole = Qt::UserRole + }; + +private /* types */: + struct entry + { + MessageLevel::Enum level; + QString line; + }; + +private: /* data */ + QVector m_content; + int m_maxLines = 1000; + // first line in the circular buffer + int m_firstLine = 0; + // number of lines occupied in the circular buffer + int m_numLines = 0; + bool m_stopOnOverflow = false; + QString m_overflowMessage = "OVERFLOW"; + +private: + Q_DISABLE_COPY(LogModel) +}; diff --git a/application/pages/LogPage.cpp b/application/pages/LogPage.cpp index de4ed4f3..0a76f8b3 100644 --- a/application/pages/LogPage.cpp +++ b/application/pages/LogPage.cpp @@ -12,39 +12,113 @@ #include "GuiUtil.h" #include +class LogFormatProxyModel : public QIdentityProxyModel +{ +public: + LogFormatProxyModel(QObject* parent = nullptr) : QIdentityProxyModel(parent) + { + } + QVariant data(const QModelIndex &index, int role) const override + { + switch(role) + { + case Qt::FontRole: + return m_font; + case Qt::TextColorRole: + { + MessageLevel::Enum level = (MessageLevel::Enum) QIdentityProxyModel::data(index, LogModel::LevelRole).toInt(); + return m_colors->getFront(level); + } + case Qt::BackgroundRole: + { + MessageLevel::Enum level = (MessageLevel::Enum) QIdentityProxyModel::data(index, LogModel::LevelRole).toInt(); + return m_colors->getBack(level); + } + default: + return QIdentityProxyModel::data(index, role); + } + } + + void setFont(QFont font) + { + m_font = font; + } + + void setColors(LogColorCache* colors) + { + m_colors.reset(colors); + } + + QModelIndex find(const QModelIndex &start, const QString &value, bool reverse) const + { + QModelIndex parentIndex = parent(start); + auto compare = [&](int r) -> QModelIndex + { + QModelIndex idx = index(r, start.column(), parentIndex); + if (!idx.isValid() || idx == start) + { + return QModelIndex(); + } + QVariant v = data(idx, Qt::DisplayRole); + QString t = v.toString(); + if (t.contains(value, Qt::CaseInsensitive)) + return idx; + return QModelIndex(); + }; + if(reverse) + { + int from = start.row(); + int to = 0; + + for (int i = 0; i < 2; ++i) + { + for (int r = from; (r >= to); --r) + { + auto idx = compare(r); + if(idx.isValid()) + return idx; + } + // prepare for the next iteration + from = rowCount() - 1; + to = start.row(); + } + } + else + { + int from = start.row(); + int to = rowCount(parentIndex); + + for (int i = 0; i < 2; ++i) + { + for (int r = from; (r < to); ++r) + { + auto idx = compare(r); + if(idx.isValid()) + return idx; + } + // prepare for the next iteration + from = 0; + to = start.row(); + } + } + return QModelIndex(); + } +private: + QFont m_font; + std::unique_ptr m_colors; +}; + LogPage::LogPage(InstancePtr instance, QWidget *parent) : QWidget(parent), ui(new Ui::LogPage), m_instance(instance) { ui->setupUi(this); ui->tabWidget->tabBar()->hide(); - // create the format and set its font - { - defaultFormat = new QTextCharFormat(ui->text->currentCharFormat()); - QString fontFamily = MMC->settings()->get("ConsoleFont").toString(); - bool conversionOk = false; - int fontSize = MMC->settings()->get("ConsoleFontSize").toInt(&conversionOk); - if(!conversionOk) - { - fontSize = 11; - } - defaultFormat->setFont(QFont(fontFamily, fontSize)); - } + m_proxy = new LogFormatProxyModel(this); + connect(m_proxy, &QAbstractItemModel::rowsAboutToBeInserted, this, &LogPage::rowsAboutToBeInserted); + connect(m_proxy, &QAbstractItemModel::rowsInserted, this, &LogPage::rowsInserted); - // ensure we don't eat all the RAM - { - auto lineSetting = MMC->settings()->getSetting("ConsoleMaxLines"); - bool conversionOk = false; - int maxLines = lineSetting->get().toInt(&conversionOk); - if(!conversionOk) - { - maxLines = lineSetting->defValue().toInt(); - qWarning() << "ConsoleMaxLines has nonsensical value, defaulting to" << maxLines; - } - ui->text->setMaximumBlockCount(maxLines); - - m_stopOnOverflow = MMC->settings()->get("ConsoleOverflowStop").toBool(); - } + ui->textView->setModel(m_proxy); // set up instance and launch process recognition { @@ -57,13 +131,28 @@ LogPage::LogPage(InstancePtr instance, QWidget *parent) this, &LogPage::on_InstanceLaunchTask_changed); } - // set up text colors and adapt them to the current theme foreground and background + // set up text colors in the log proxy and adapt them to the current theme foreground and background { - auto origForeground = ui->text->palette().color(ui->text->foregroundRole()); - auto origBackground = ui->text->palette().color(ui->text->backgroundRole()); - m_colors.reset(new LogColorCache(origForeground, origBackground)); + auto origForeground = ui->textView->palette().color(ui->textView->foregroundRole()); + auto origBackground = ui->textView->palette().color(ui->textView->backgroundRole()); + m_proxy->setColors(new LogColorCache(origForeground, origBackground)); } + // set up fonts in the log proxy + { + QString fontFamily = MMC->settings()->get("ConsoleFont").toString(); + bool conversionOk = false; + int fontSize = MMC->settings()->get("ConsoleFontSize").toInt(&conversionOk); + if(!conversionOk) + { + fontSize = 11; + } + m_proxy->setFont(QFont(fontFamily, fontSize)); + } + + ui->textView->setWordWrap(true); + ui->textView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto findShortcut = new QShortcut(QKeySequence(QKeySequence::Find), this); connect(findShortcut, SIGNAL(activated()), SLOT(findActivated())); auto findNextShortcut = new QShortcut(QKeySequence(QKeySequence::FindNext), this); @@ -76,20 +165,33 @@ LogPage::LogPage(InstancePtr instance, QWidget *parent) LogPage::~LogPage() { delete ui; - delete defaultFormat; } void LogPage::on_InstanceLaunchTask_changed(std::shared_ptr proc) { - if(m_process) - { - disconnect(m_process.get(), &LaunchTask::log, this, &LogPage::write); - } m_process = proc; if(m_process) { - ui->text->clear(); - connect(m_process.get(), &LaunchTask::log, this, &LogPage::write); + m_model = proc->getLogModel(); + auto lineSetting = MMC->settings()->getSetting("ConsoleMaxLines"); + bool conversionOk = false; + int maxLines = lineSetting->get().toInt(&conversionOk); + if(!conversionOk) + { + maxLines = lineSetting->defValue().toInt(); + qWarning() << "ConsoleMaxLines has nonsensical value, defaulting to" << maxLines; + } + m_model->setMaxLines(maxLines); + m_model->setStopOnOverflow(MMC->settings()->get("ConsoleOverflowStop").toBool()); + m_model->setOverflowMessage(tr("MultiMC stopped watching the game log because the log length surpassed %1 lines.\n" + "You may have to fix your mods because the game is still loggging to files and" + " likely wasting harddrive space at an alarming rate!").arg(maxLines)); + m_proxy->setSourceModel(m_model.get()); + } + else + { + m_proxy->setSourceModel(nullptr); + m_model.reset(); } } @@ -100,38 +202,50 @@ bool LogPage::apply() bool LogPage::shouldDisplay() const { - return m_instance->isRunning() || ui->text->blockCount() > 1; + return m_instance->isRunning() || m_proxy->rowCount() > 0; } void LogPage::on_btnPaste_clicked() { + if(!m_model) + return; + //FIXME: turn this into a proper task and move the upload logic out of GuiUtil! - write(tr("MultiMC: Log upload triggered at: %1").arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date)), MessageLevel::MultiMC); - auto url = GuiUtil::uploadPaste(ui->text->toPlainText(), this); + m_model->append(MessageLevel::MultiMC, tr("MultiMC: Log upload triggered at: %1").arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date))); + auto url = GuiUtil::uploadPaste(m_model->toPlainText(), this); if(!url.isEmpty()) { - write(tr("MultiMC: Log uploaded to: %1").arg(url), MessageLevel::MultiMC); + m_model->append(MessageLevel::MultiMC, tr("MultiMC: Log uploaded to: %1").arg(url)); } else { - write(tr("MultiMC: Log upload failed!"), MessageLevel::Error); + m_model->append(MessageLevel::Error, tr("MultiMC: Log upload failed!")); } } void LogPage::on_btnCopy_clicked() { - write(QString("Clipboard copy at: %1").arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date)), MessageLevel::MultiMC); - GuiUtil::setClipboardText(ui->text->toPlainText()); + if(!m_model) + return; + m_model->append(MessageLevel::MultiMC, QString("Clipboard copy at: %1").arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date))); + GuiUtil::setClipboardText(m_model->toPlainText()); } void LogPage::on_btnClear_clicked() { - ui->text->clear(); + if(!m_model) + return; + m_model->clear(); } void LogPage::on_btnBottom_clicked() { - ui->text->verticalScrollBar()->setSliderPosition(ui->text->verticalScrollBar()->maximum()); + /* + ui->textView->verticalScrollBar()->setSliderPosition(ui->textView->verticalScrollBar()->maximum()); + */ + auto numRows = m_proxy->rowCount(QModelIndex()); + auto lastIndex = m_proxy->index(numRows - 1, 0 , QModelIndex()); + ui->textView->scrollTo(lastIndex, QAbstractItemView::ScrollHint::EnsureVisible); } void LogPage::on_trackLogCheckbox_clicked(bool checked) @@ -143,25 +257,52 @@ void LogPage::on_wrapCheckbox_clicked(bool checked) { if(checked) { - ui->text->setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + ui->textView->setWordWrap(true); + ui->textView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); } else { - ui->text->setWordWrapMode(QTextOption::WrapMode::NoWrap); + ui->textView->setWordWrap(false); + ui->textView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + } +} + +void LogPage::findImpl(bool reverse) +{ + auto toSearch = ui->searchBar->text(); + if (toSearch.size()) + { + auto index = ui->textView->currentIndex(); + if(!index.isValid()) + { + index = m_proxy->index(0,0); + } + if(!index.isValid()) + { + // just give up + return; + } + auto found = m_proxy->find(index, toSearch, reverse); + if(found.isValid()) + ui->textView->setCurrentIndex(found); } } void LogPage::on_findButton_clicked() { auto modifiers = QApplication::keyboardModifiers(); - if (modifiers & Qt::ShiftModifier) - { - findPreviousActivated(); - } - else - { - findNextActivated(); - } + bool reverse = modifiers & Qt::ShiftModifier; + findImpl(reverse); +} + +void LogPage::findNextActivated() +{ + findImpl(false); +} + +void LogPage::findPreviousActivated() +{ + findImpl(true); } void LogPage::findActivated() @@ -169,118 +310,29 @@ void LogPage::findActivated() // focus the search bar if it doesn't have focus if (!ui->searchBar->hasFocus()) { - auto searchForCursor = ui->text->textCursor(); - auto searchForString = searchForCursor.selectedText(); - if (searchForString.size()) - { - ui->searchBar->setText(searchForString); - } ui->searchBar->setFocus(); ui->searchBar->selectAll(); } } -void LogPage::findNextActivated() -{ - auto toSearch = ui->searchBar->text(); - if (toSearch.size()) - { - ui->text->find(toSearch); - } -} - -void LogPage::findPreviousActivated() -{ - auto toSearch = ui->searchBar->text(); - if (toSearch.size()) - { - ui->text->find(toSearch, QTextDocument::FindBackward); - } -} - void LogPage::setParentContainer(BasePageContainer * container) { m_parentContainer = container; } -void LogPage::write(QString data, MessageLevel::Enum mode) +void LogPage::rowsAboutToBeInserted(const QModelIndex& parent, int first, int last) { - if (!m_write_active) - { - if (mode != MessageLevel::MultiMC) - { - return; - } - } - if(m_stopOnOverflow && m_write_active) - { - if(mode != MessageLevel::MultiMC) - { - if(ui->text->blockCount() >= ui->text->maximumBlockCount()) - { - m_write_active = false; - data = tr("MultiMC stopped watching the game log because the log length surpassed %1 lines.\n" - "You may have to fix your mods because the game is still loggging to files and" - " likely wasting harddrive space at an alarming rate!") - .arg(ui->text->maximumBlockCount()); - mode = MessageLevel::Fatal; - ui->trackLogCheckbox->setCheckState(Qt::Unchecked); - if(!isVisible()) - { - m_parentContainer->selectPage(id()); - } - } - } - } - - // save the cursor so it can be restored. - auto savedCursor = ui->text->cursor(); - - QScrollBar *bar = ui->text->verticalScrollBar(); - int max_bar = bar->maximum(); - int val_bar = bar->value(); - if (isVisible()) - { - if (m_scroll_active) - { - m_scroll_active = (max_bar - val_bar) <= 1; - } - else - { - m_scroll_active = val_bar == max_bar; - } - } - if (data.endsWith('\n')) - data = data.left(data.length() - 1); - QStringList paragraphs = data.split('\n'); - QStringList filtered; - for (QString ¶graph : paragraphs) - { - //TODO: implement filtering here. - filtered.append(paragraph); - } - QListIterator iter(filtered); - QTextCharFormat format(*defaultFormat); - - format.setForeground(m_colors->getFront(mode)); - format.setBackground(m_colors->getBack(mode)); - - while (iter.hasNext()) - { - // append a paragraph/line - auto workCursor = ui->text->textCursor(); - workCursor.movePosition(QTextCursor::End); - workCursor.insertText(iter.next(), format); - workCursor.insertBlock(); - } - - if (isVisible()) - { - if (m_scroll_active) - { - bar->setValue(bar->maximum()); - } - m_last_scroll_value = bar->value(); - } - ui->text->setCursor(savedCursor); + auto numRows = m_proxy->rowCount(QModelIndex()); + auto lastIndex = m_proxy->index(numRows - 1, 0 , QModelIndex()); + auto rect = ui->textView->visualRect(lastIndex); + auto viewPortRect = ui->textView->viewport()->rect(); + m_autoScroll = rect.intersects(viewPortRect); +} + +void LogPage::rowsInserted(const QModelIndex& parent, int first, int last) +{ + if(m_autoScroll) + { + QMetaObject::invokeMethod(this, "on_btnBottom_clicked", Qt::QueuedConnection); + } } diff --git a/application/pages/LogPage.h b/application/pages/LogPage.h index e902ad13..71e23977 100644 --- a/application/pages/LogPage.h +++ b/application/pages/LogPage.h @@ -21,13 +21,13 @@ #include "launch/LaunchTask.h" #include "BasePage.h" #include -#include namespace Ui { class LogPage; } class QTextCharFormat; +class LogFormatProxyModel; class LogPage : public QWidget, public BasePage { @@ -57,13 +57,6 @@ public: virtual void setParentContainer(BasePageContainer *) override; private slots: - /** - * @brief write a string - * @param data the string - * @param level the @MessageLevel the string should be written under - * lines have to be put through this as a whole! - */ - void write(QString data, MessageLevel::Enum level = MessageLevel::MultiMC); void on_btnPaste_clicked(); void on_btnCopy_clicked(); void on_btnClear_clicked(); @@ -79,6 +72,12 @@ private slots: void on_InstanceLaunchTask_changed(std::shared_ptr proc); + void rowsAboutToBeInserted(const QModelIndex &parent, int first, int last); + void rowsInserted(const QModelIndex &parent, int first, int last); + +private: /* methods */ + void findImpl(bool reverse); + private: Ui::LogPage *ui; InstancePtr m_instance; @@ -88,8 +87,9 @@ private: int m_saved_offset = 0; bool m_write_active = true; bool m_stopOnOverflow = true; + bool m_autoScroll = false; - QTextCharFormat * defaultFormat; BasePageContainer * m_parentContainer; - std::unique_ptr m_colors; + LogFormatProxyModel * m_proxy; + shared_qobject_ptr m_model; }; diff --git a/application/pages/LogPage.ui b/application/pages/LogPage.ui index bf5ec004..18fb654b 100644 --- a/application/pages/LogPage.ui +++ b/application/pages/LogPage.ui @@ -33,25 +33,6 @@ Tab 1 - - - - false - - - true - - - - - - Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse - - - false - - - @@ -153,6 +134,34 @@ + + + + Qt::ScrollBarAlwaysOn + + + Qt::ScrollBarAlwaysOff + + + false + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + QAbstractItemView::ScrollPerPixel + + + true + + + @@ -166,7 +175,6 @@ btnCopy btnPaste btnClear - text searchBar findButton