// ***************************************************************************** // * This file is part of the FreeFileSync project. It is distributed under * // * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0 * // * Copyright (C) Zenju (zenju AT freefilesync DOT org) - All Rights Reserved * // ***************************************************************************** #include "log_panel.h" #include #include #include #include #include using namespace zen; using namespace fff; namespace { inline wxColor getColorGridLine() { return {192, 192, 192}; } //light grey inline wxImage getImageButtonPressed(const char* imageName) { return layOver(loadImage("msg_button_pressed"), loadImage(imageName)); } inline wxImage getImageButtonReleased(const char* imageName) { return greyScale(loadImage(imageName)); //loadImage(utfTo(imageName)).ConvertToGreyscale(1.0/3, 1.0/3, 1.0/3); //treat all channels equally! //brighten(output, 30); } enum class ColumnTypeLog { time, severity, text, }; } //a vector-view on ErrorLog considering multi-line messages: prepare consumption by Grid class fff::MessageView { public: explicit MessageView(const SharedRef& log) : log_(log) {} size_t rowsOnView() const { return viewRef_.size(); } struct LogEntryView { time_t time = 0; MessageType type = MSG_TYPE_INFO; std::string_view messageLine; bool firstLine = false; //if LogEntry::message spans multiple rows }; std::optional getEntry(size_t row) const { if (row < viewRef_.size()) { const Line& line = viewRef_[row]; LogEntryView output; output.time = line.logIt->time; output.type = line.logIt->type; output.messageLine = extractLine(line.logIt->message, line.row); output.firstLine = line.row == 0; //this is virtually always correct, unless first line of the original message is empty! return output; } return {}; } void updateView(int includedTypes) //MSG_TYPE_INFO | MSG_TYPE_WARNING, etc. see error_log.h { viewRef_.clear(); for (auto it = log_.ref().begin(); it != log_.ref().end(); ++it) if (it->type & includedTypes) { assert(!startsWith(it->message, '\n')); size_t rowNumber = 0; bool lastCharNewline = true; for (const char c : it->message) if (c == '\n') { if (!lastCharNewline) //do not reference empty lines! viewRef_.push_back({it, rowNumber}); ++rowNumber; lastCharNewline = true; } else lastCharNewline = false; if (!lastCharNewline) viewRef_.push_back({it, rowNumber}); } } private: static std::string_view extractLine(const Zstringc& message, size_t textRow) { auto it1 = message.begin(); for (;;) { auto it2 = std::find_if(it1, message.end(), [](const char c) { return c == '\n'; }); if (textRow == 0) return makeStringView(it1, it2 - it1); if (it2 == message.end()) { assert(false); return makeStringView(it1, 0); } it1 = it2 + 1; //skip newline --textRow; } } struct Line { ErrorLog::const_iterator logIt; //always bound! size_t row; //LogEntry::message may span multiple rows }; std::vector viewRef_; //partial view on log_ /* /|\ | updateView() | */ const SharedRef log_; }; //----------------------------------------------------------------------------- namespace { //Grid data implementation referencing MessageView class GridDataMessages : public GridData { public: explicit GridDataMessages(const SharedRef& log) : msgView_(log) {} MessageView& getDataView() { return msgView_; } size_t getRowCount() const override { return msgView_.rowsOnView(); } std::wstring getValue(size_t row, ColumnType colType) const override { if (const std::optional entry = msgView_.getEntry(row)) switch (static_cast(colType)) { case ColumnTypeLog::time: if (entry->firstLine) return utfTo(formatTime(formatTimeTag, getLocalTime(entry->time))); //empty string on error break; case ColumnTypeLog::severity: if (entry->firstLine) switch (entry->type) { case MSG_TYPE_INFO: return _("Info"); case MSG_TYPE_WARNING: return _("Warning"); case MSG_TYPE_ERROR: return _("Error"); } break; case ColumnTypeLog::text: return utfTo(entry->messageLine); } return std::wstring(); } void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override { if (!enabled || !selected) ; //clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default else GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); //-------------- draw item separation line ----------------- const bool drawBottomLine = [&] //don't separate multi-line messages { if (std::optional nextEntry = msgView_.getEntry(row + 1)) return nextEntry->firstLine; return true; }(); if (drawBottomLine) clearArea(dc, {rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)}, getColorGridLine()); //-------------------------------------------------------- } void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override { wxDCTextColourChanger textColor(dc); if (enabled && selected) //accessibility: always set *both* foreground AND background colors! textColor.Set(*wxBLACK); wxRect rectTmp = rect; if (std::optional entry = msgView_.getEntry(row)) switch (static_cast(colType)) { case ColumnTypeLog::time: drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_CENTER); break; case ColumnTypeLog::severity: if (entry->firstLine) { wxImage msgTypeIcon = [&] { switch (entry->type) { case MSG_TYPE_INFO: return loadImage("msg_info", dipToScreen(getMenuIconDipSize())); case MSG_TYPE_WARNING: return loadImage("msg_warning", dipToScreen(getMenuIconDipSize())); case MSG_TYPE_ERROR: return loadImage("msg_error", dipToScreen(getMenuIconDipSize())); } assert(false); return wxNullImage; }(); drawBitmapRtlNoMirror(dc, enabled ? msgTypeIcon : msgTypeIcon.ConvertToDisabled(), rectTmp, wxALIGN_CENTER); } break; case ColumnTypeLog::text: rectTmp.x += getColumnGapLeft(); rectTmp.width -= getColumnGapLeft(); drawCellText(dc, rectTmp, getValue(row, colType)); break; } } int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override { // -> synchronize renderCell() <-> getBestSize() if (msgView_.getEntry(row)) switch (static_cast(colType)) { case ColumnTypeLog::time: return 2 * getColumnGapLeft() + dc.GetTextExtent(getValue(row, colType)).GetWidth(); case ColumnTypeLog::severity: return dipToWxsize(getMenuIconDipSize()); case ColumnTypeLog::text: return getColumnGapLeft() + dc.GetTextExtent(getValue(row, colType)).GetWidth(); } return 0; } static int getColumnTimeDefaultWidth(Grid& grid) { wxInfoDC dc(&grid.getMainWin()); dc.SetFont(grid.getMainWin().GetFont()); return 2 * getColumnGapLeft() + dc.GetTextExtent(utfTo(formatTime(formatTimeTag))).GetWidth(); } static int getColumnSeverityDefaultWidth() { return dipToWxsize(getMenuIconDipSize()); } static int getRowDefaultHeight(const Grid& grid) { return std::max(dipToWxsize(getMenuIconDipSize()), grid.getMainWin().GetCharHeight() + dipToWxsize(2) /*extra space*/) + dipToWxsize(1) /*bottom border*/; } std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override { switch (static_cast(colType)) { case ColumnTypeLog::time: case ColumnTypeLog::text: break; case ColumnTypeLog::severity: return getValue(row, colType); } return std::wstring(); } std::wstring getColumnLabel(ColumnType colType) const override { return std::wstring(); } private: MessageView msgView_; }; } //######################################################################################## LogPanel::LogPanel(wxWindow* parent) : LogPanelGenerated(parent) { const int rowHeight = GridDataMessages::getRowDefaultHeight(*m_gridMessages); const int colMsgTimeWidth = GridDataMessages::getColumnTimeDefaultWidth(*m_gridMessages); const int colMsgSeverityWidth = GridDataMessages::getColumnSeverityDefaultWidth(); m_gridMessages->setColumnLabelHeight(0); m_gridMessages->showRowLabel(false); m_gridMessages->setRowHeight(rowHeight); m_gridMessages->setColumnConfig( { {static_cast(ColumnTypeLog::time ), colMsgTimeWidth, 0, true}, {static_cast(ColumnTypeLog::severity), colMsgSeverityWidth, 0, true}, {static_cast(ColumnTypeLog::text ), -colMsgTimeWidth - colMsgSeverityWidth, 1, true}, }); //support for CTRL + C m_gridMessages->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onGridKeyEvent(event); }); m_gridMessages->Bind(EVENT_GRID_CONTEXT_MENU, [this](GridContextMenuEvent& event) { onMsgGridContext(event); }); Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events setLog(nullptr); } void LogPanel::setLog(const std::shared_ptr& log) { SharedRef newLog = [&] { if (log) return SharedRef(log); ErrorLog dummyLog; logMsg(dummyLog, _("No log entries"), MSG_TYPE_INFO); return makeSharedRef(std::move(dummyLog)); }(); const ErrorLogStats logCount = getStats(newLog.ref()); auto initButton = [](ToggleButton& btn, const char* imgName, const wxString& tooltip) { btn.init(getImageButtonPressed(imgName), getImageButtonReleased(imgName)); btn.SetToolTip(tooltip); }; initButton(*m_bpButtonErrors, "msg_error", _("Error" ) + L" (" + formatNumber(logCount.errors) + L')'); initButton(*m_bpButtonWarnings, "msg_warning", _("Warning") + L" (" + formatNumber(logCount.warnings) + L')'); initButton(*m_bpButtonInfo, "msg_info", _("Info" ) + L" (" + formatNumber(logCount.infos) + L')'); m_bpButtonErrors ->setActive(true); m_bpButtonWarnings->setActive(true); m_bpButtonInfo ->setActive(logCount.warnings + logCount.errors == 0); m_bpButtonErrors ->Show(logCount.errors != 0); m_bpButtonWarnings->Show(logCount.warnings != 0); m_bpButtonInfo ->Show(logCount.infos != 0); m_gridMessages->setDataProvider(std::make_shared(newLog)); updateGrid(); } MessageView& LogPanel::getDataView() { if (auto* prov = dynamic_cast(m_gridMessages->getDataProvider())) return prov->getDataView(); throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] m_gridMessages was not initialized."); } void LogPanel::updateGrid() { int includedTypes = 0; if (m_bpButtonErrors->isActive()) includedTypes |= MSG_TYPE_ERROR; if (m_bpButtonWarnings->isActive()) includedTypes |= MSG_TYPE_WARNING; if (m_bpButtonInfo->isActive()) includedTypes |= MSG_TYPE_INFO; getDataView().updateView(includedTypes); //update MVC "model" m_gridMessages->Refresh(); //update MVC "view" } void LogPanel::onErrors(wxCommandEvent& event) { m_bpButtonErrors->toggle(); updateGrid(); } void LogPanel::onWarnings(wxCommandEvent& event) { m_bpButtonWarnings->toggle(); updateGrid(); } void LogPanel::onInfo(wxCommandEvent& event) { m_bpButtonInfo->toggle(); updateGrid(); } void LogPanel::onMsgGridContext(GridContextMenuEvent& event) { const std::vector selection = m_gridMessages->getSelectedRows(); const size_t rowCount = [&]() -> size_t { if (auto prov = m_gridMessages->getDataProvider()) return prov->getRowCount(); return 0; }(); ContextMenu menu; menu.addItem(_("&Copy") + L"\tCtrl+C", [this] { copySelectionToClipboard(); }, loadImage("item_copy_sicon"), !selection.empty()); menu.addSeparator(); menu.addItem(_("Select all") + L"\tCtrl+A", [this] { m_gridMessages->selectAllRows(GridEventPolicy::allow); }, wxNullImage, rowCount > 0); menu.popup(*m_gridMessages, event.mousePos_); } void LogPanel::onGridKeyEvent(wxKeyEvent& event) { int keyCode = event.GetKeyCode(); if (event.ControlDown()) switch (keyCode) { case 'C': case WXK_INSERT: //CTRL + C || CTRL + INS case WXK_NUMPAD_INSERT: copySelectionToClipboard(); return; // -> swallow event! don't allow default grid commands! } //else //switch (keyCode) //{ // case WXK_RETURN: // case WXK_NUMPAD_ENTER: // return; //} event.Skip(); //unknown keypress: propagate } void LogPanel::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) { if (!processingKeyEventHandler_) //avoid recursion { processingKeyEventHandler_ = true; ZEN_ON_SCOPE_EXIT(processingKeyEventHandler_ = false); const int keyCode = event.GetKeyCode(); if (event.ControlDown()) switch (keyCode) { case 'A': m_gridMessages->SetFocus(); m_gridMessages->selectAllRows(GridEventPolicy::allow); return; // -> swallow event! don't allow default grid commands! } else switch (keyCode) { //redirect certain (unhandled) keys directly to grid! case WXK_UP: case WXK_DOWN: case WXK_LEFT: case WXK_RIGHT: case WXK_PAGEUP: case WXK_PAGEDOWN: case WXK_HOME: case WXK_END: case WXK_NUMPAD_UP: case WXK_NUMPAD_DOWN: case WXK_NUMPAD_LEFT: case WXK_NUMPAD_RIGHT: case WXK_NUMPAD_PAGEUP: case WXK_NUMPAD_PAGEDOWN: case WXK_NUMPAD_HOME: case WXK_NUMPAD_END: if (!isComponentOf(wxWindow::FindFocus(), m_gridMessages) && //don't propagate keyboard commands if grid is already in focus m_gridMessages->IsEnabled()) { m_gridMessages->SetFocus(); event.SetEventType(wxEVT_KEY_DOWN); //the grid event handler doesn't expect wxEVT_CHAR_HOOK! m_gridMessages->getMainWin().GetEventHandler()->ProcessEvent(event); //propagating event catched at wxTheApp to child leads to recursion, but we prevented it... event.Skip(false); //definitively handled now! return; } break; } } event.Skip(); } void LogPanel::copySelectionToClipboard() { try { wxString clipBuf; //perf: old wxString didn't model exponential growth, but now it's std::string-based: static_assert(std::is_same_v); if (auto prov = m_gridMessages->getDataProvider()) { std::vector colAttr = m_gridMessages->getColumnConfig(); std::erase_if(colAttr, [](const Grid::ColAttributes& ca) { return !ca.visible; }); for (size_t row : m_gridMessages->getSelectedRows()) for (auto it = colAttr.begin(); it != colAttr.end(); ++it) { clipBuf += prov->getValue(row, it->type); clipBuf += it == colAttr.end() - 1 ? L'\n' : L'\t'; } } setClipboardText(clipBuf); } catch (const std::bad_alloc& e) { showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setMainInstructions(_("Out of memory.") + L' ' + utfTo(e.what()))); } }