// ***************************************************************************** // * 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 "file_grid.h" #include #include #include #include #include #include #include #include #include #include #include #include "../base/file_hierarchy.h" using namespace zen; using namespace fff; namespace fff { wxDEFINE_EVENT(EVENT_GRID_CHECK_ROWS, CheckRowsEvent); wxDEFINE_EVENT(EVENT_GRID_SYNC_DIRECTION, SyncDirectionEvent); } namespace { //let's NOT create wxWidgets objects statically: wxColor getColorSyncBlue(bool faint) { if (faint) return {0xed, 0xee, 0xff}; //faint blue return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0x80, 0x94, 0xfe} /*medium blue*/ : wxColor{0xb9, 0xbc, 0xff} /*light blue*/; } wxColor getColorSyncGreen(bool faint) { if (faint) return {0xf1, 0xff, 0xed}; //faint green return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0x6c, 0xfb, 0x53} /*medium green*/ : wxColor{0xc4, 0xff, 0xb9} /*light green*/; } wxColor getColorConflictBackground (bool faint) { if (faint) return {0xfe, 0xfe, 0xda}; return {247, 252, 62}; } //yellow wxColor getColorDifferentBackground(bool faint) { if (faint) return {0xff, 0xed, 0xee}; return {255, 185, 187}; } //red wxColor getColorSymlinkBackground() { return {238, 201, 0}; } //orange wxColor getColorInactiveBack() { return wxSystemSettings::GetAppearance().IsDark() ? 0x6c6c6c /*medium grey*/ : 0xe4e4e4 /*light grey*/; } wxColor getColorInactiveText() { return wxSystemSettings::GetAppearance().IsDark() ? 0xffffff /*white*/ : 0x404040 /*dark grey*/; } wxColor getColorGridLine() { return wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW); } const int FILE_GRID_GAP_SIZE_DIP = 2; const int FILE_GRID_GAP_SIZE_WIDE_DIP = 6; /* class hierarchy: GridDataBase /|\ ___________|____________ | | GridDataRim | /|\ | ______|_______ | | | | GridDataLeft GridDataRight GridDataCenter */ //accessibility, support high-contrast schemes => work with user-defined background color! wxColor getGridAlternateBackgroundColor() { const wxColor backCol = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); /* CAVEAT: macOS uses partially-transparent colors! but probably not for this one: wxSYS_COLOUR_WINDOW RGBA = #171717FF */ const bool isColorLight = relativeLuminance(backCol) > 0.5; //darken or brighten: only a faint gradient to avoid visual distraction auto liftChannel = [diff = isColorLight ? -15 : 15](unsigned char c) { return static_cast(std::clamp(c + diff, 0, 255)); }; return wxColor(liftChannel(backCol.Red ()), liftChannel(backCol.Green()), liftChannel(backCol.Blue ())); } std::pair getCudAction(SyncOperation so) { switch (so) { case SO_CREATE_LEFT: case SO_MOVE_LEFT_TO: return {CudAction::create, SelectSide::left}; case SO_CREATE_RIGHT: case SO_MOVE_RIGHT_TO: return {CudAction::create, SelectSide::right}; case SO_DELETE_LEFT: case SO_MOVE_LEFT_FROM: return {CudAction::delete_, SelectSide::left}; case SO_DELETE_RIGHT: case SO_MOVE_RIGHT_FROM: return {CudAction::delete_, SelectSide::right}; case SO_OVERWRITE_LEFT: case SO_RENAME_LEFT: return {CudAction::update, SelectSide::left}; case SO_OVERWRITE_RIGHT: case SO_RENAME_RIGHT: return {CudAction::update, SelectSide::right}; case SO_DO_NOTHING: case SO_EQUAL: case SO_UNRESOLVED_CONFLICT: return {CudAction::noChange, SelectSide::left}; } assert(false); return {CudAction::noChange, SelectSide::left}; } wxColor getBackGroundColorSyncAction(SyncOperation so) { switch (so) { case SO_CREATE_LEFT: case SO_OVERWRITE_LEFT: case SO_DELETE_LEFT: case SO_MOVE_LEFT_FROM: case SO_MOVE_LEFT_TO: case SO_RENAME_LEFT: return getColorSyncBlue(false /*faint*/); case SO_CREATE_RIGHT: case SO_OVERWRITE_RIGHT: case SO_DELETE_RIGHT: case SO_MOVE_RIGHT_FROM: case SO_MOVE_RIGHT_TO: case SO_RENAME_RIGHT: return getColorSyncGreen(false /*faint*/); case SO_DO_NOTHING: return getColorInactiveBack(); case SO_EQUAL: break; //usually white case SO_UNRESOLVED_CONFLICT: return getColorConflictBackground(false /*faint*/); } return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); } wxColor getBackGroundColorCmpDifference(CompareFileResult cmpResult) { switch (cmpResult) { case FILE_EQUAL: break; //usually white case FILE_LEFT_ONLY: return getColorSyncBlue(false /*faint*/); case FILE_LEFT_NEWER: return getColorSyncBlue(true /*faint*/); case FILE_RIGHT_ONLY: return getColorSyncGreen(false /*faint*/); case FILE_RIGHT_NEWER: return getColorSyncGreen(true /*faint*/); case FILE_DIFFERENT_CONTENT: return getColorDifferentBackground(false /*faint*/); case FILE_RENAMED: //similar to both "equal" and "conflict": give hint via background color case FILE_TIME_INVALID: case FILE_CONFLICT: return getColorConflictBackground(false /*faint*/); } return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); } class GridEventManager; class GridDataLeft; class GridDataRight; class IconUpdater : private wxEvtHandler //update file icons periodically: use SINGLE instance to coordinate left and right grids in parallel { public: IconUpdater(GridDataLeft& provLeft, GridDataRight& provRight, IconBuffer& iconBuffer) : provLeft_(provLeft), provRight_(provRight), iconBuffer_(iconBuffer) { timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { loadIconsAsynchronously(event); }); } void start() { if (!timer_.IsRunning()) timer_.Start(100); } //timer interval in [ms] //don't check too often! give worker thread some time to fetch data private: void stop() { if (timer_.IsRunning()) timer_.Stop(); } void loadIconsAsynchronously(wxEvent& event); //loads all (not yet) drawn icons GridDataLeft& provLeft_; GridDataRight& provRight_; IconBuffer& iconBuffer_; wxTimer timer_; }; struct IconManager { IconManager() {} IconManager(GridDataLeft& provLeft, GridDataRight& provRight, IconBuffer::IconSize sz, bool showFileIcons) : fileIcon_ (IconBuffer::genericFileIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), dirIcon_ (IconBuffer::genericDirIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), linkOverlayIcon_ (IconBuffer::linkOverlayIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), plusOverlayIcon_ (IconBuffer::plusOverlayIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), minusOverlayIcon_(IconBuffer::minusOverlayIcon(showFileIcons ? sz : IconBuffer::IconSize::small)) { if (showFileIcons) { iconBuffer_ .emplace(sz); iconUpdater_.emplace(provLeft, provRight, *iconBuffer_); } } int getIconWxsize() const { return screenToWxsize(iconBuffer_ ? iconBuffer_->getPixSize() : IconBuffer::getPixSize(IconBuffer::IconSize::small)); } IconBuffer* getIconBuffer() { return get(iconBuffer_); } void startIconUpdater() { assert(iconUpdater_); if (iconUpdater_) iconUpdater_->start(); } const wxImage& getGenericFileIcon () const { return fileIcon_; } const wxImage& getGenericDirIcon () const { return dirIcon_; } const wxImage& getLinkOverlayIcon () const { return linkOverlayIcon_; } const wxImage& getPlusOverlayIcon () const { return plusOverlayIcon_; } const wxImage& getMinusOverlayIcon() const { return minusOverlayIcon_; } private: const wxImage fileIcon_; const wxImage dirIcon_; const wxImage linkOverlayIcon_; const wxImage plusOverlayIcon_; const wxImage minusOverlayIcon_; std::optional iconBuffer_; std::optional iconUpdater_; //bind ownership to GridDataRim<>! }; //mark rows selected on overview panel class NavigationMarker { public: NavigationMarker() {} void set(std::unordered_set&& markedFilesAndLinks, std::unordered_set&& markedContainer) { markedFilesAndLinks_.swap(markedFilesAndLinks); markedContainer_ .swap(markedContainer); } bool isMarked(const FileSystemObject& fsObj) const { if (markedFilesAndLinks_.contains(&fsObj)) //mark files/links directly return true; if (auto folder = dynamic_cast(&fsObj)) if (markedContainer_.contains(folder)) //mark folders which *are* the given ContainerObject* return true; //also mark all items with any matching ancestors for (const FileSystemObject* fsObj2 = &fsObj;;) { const ContainerObject& parent = fsObj2->parent(); if (markedContainer_.contains(&parent)) return true; fsObj2 = dynamic_cast(&parent); if (!fsObj2) return false; } } private: std::unordered_set markedFilesAndLinks_; //mark files/symlinks directly within a container std::unordered_set markedContainer_; //mark full container including all child-objects //DO NOT DEREFERENCE!!!! NOT GUARANTEED TO BE VALID!!! }; struct SharedComponents //...between left, center, and right grids { SharedRef gridDataView = makeSharedRef(); SharedRef iconMgr = makeSharedRef(); NavigationMarker navMarker; std::unique_ptr evtMgr; GridViewType gridViewType = GridViewType::action; std::unordered_map compExtentsBuf_; //buffer expensive wxDC::GetTextExtent() calls! //StringHash, StringEqual => heterogenous lookup by std::wstring_view }; //######################################################################################################## class GridDataBase : public GridData { public: GridDataBase(Grid& grid, const SharedRef& sharedComp) : grid_(grid), sharedComp_(sharedComp) {} void setData(FolderComparison& folderCmp) { sharedComp_.ref().gridDataView = makeSharedRef(); //clear old data view first! avoid memory peaks! sharedComp_.ref().gridDataView = makeSharedRef(folderCmp); sharedComp_.ref().compExtentsBuf_.clear(); //doesn't become stale! but still: re-calculate and save some memory... } GridEventManager* getEventManager() { return sharedComp_.ref().evtMgr.get(); } /**/ FileView& getDataView() { return sharedComp_.ref().gridDataView.ref(); } const FileView& getDataView() const { return sharedComp_.ref().gridDataView.ref(); } void setIconManager(const SharedRef& iconMgr) { sharedComp_.ref().iconMgr = iconMgr; } IconManager& getIconManager() { return sharedComp_.ref().iconMgr.ref(); } GridViewType getViewType() const { return sharedComp_.ref().gridViewType; } void setViewType(GridViewType vt) { sharedComp_.ref().gridViewType = vt; } bool isNavMarked(const FileSystemObject& fsObj) const { return sharedComp_.ref().navMarker.isMarked(fsObj); } void setNavigationMarker(std::unordered_set&& markedFilesAndLinks, std::unordered_set&& markedContainer) { sharedComp_.ref().navMarker.set(std::move(markedFilesAndLinks), std::move(markedContainer)); } Grid& refGrid() { return grid_; } const Grid& refGrid() const { return grid_; } const FileSystemObject* getFsObject(size_t row) const { return getDataView().getFsObject(row); } const wxSize& getTextExtentBuffered(const wxReadOnlyDC& dc, const std::wstring_view& text) { auto& compExtentsBuf = sharedComp_.ref().compExtentsBuf_; //- only used for parent path names and file names on view => should not grow "too big" //- cleaned up during GridDataBase::setData() assert(!contains(text, L'\n')); auto it = compExtentsBuf.find(text); if (it == compExtentsBuf.end()) it = compExtentsBuf.emplace(text, dc.GetTextExtent(copyStringTo(text))).first; //GetTextExtent() returns (0, 0) for empty string! return it->second; } //- trim while leaving path components intact //- *always* returns at least one component, even if > maxWidth size_t getPathTrimmedSize(const wxReadOnlyDC& dc, const std::wstring_view& itemPath, int maxWidth) { if (itemPath.size() <= 1) return itemPath.size(); std::vector subComp; //split path by components, but skip slash at beginning or end for (auto it = itemPath.begin() + 1; it != itemPath.end() - 1; ++it) if (*it == L'/' || *it == L'\\') subComp.push_back(makeStringView(itemPath.begin(), it)); subComp.push_back(itemPath); if (maxWidth <= 0) return subComp[0].size(); size_t low = 0; size_t high = subComp.size(); for (;;) { if (high - low == 1) return subComp[low].size(); const size_t middle = (low + high) / 2; //=> never 0 when "high - low > 1" if (getTextExtentBuffered(dc, subComp[middle]).GetWidth() <= maxWidth) low = middle; else high = middle; } } //improve readability (while lacking cell borders) const wxColor& getDefaultBackgroundColorAlternating(bool wantStandardColor) { return wantStandardColor ? gridBackgroundColor_ : gridBackgroundColorAlt_; } private: size_t getRowCount() const override { return getDataView().rowsOnView(); } const wxColor gridBackgroundColor_ = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); const wxColor gridBackgroundColorAlt_ = getGridAlternateBackgroundColor(); Grid& grid_; SharedRef sharedComp_; }; //######################################################################################################## template class GridDataRim : public GridDataBase { public: GridDataRim(Grid& grid, const SharedRef& sharedComp) : GridDataBase(grid, sharedComp) {} void setItemPathForm(ItemPathFormat fmt) { itemPathFormat_ = fmt; groupItemNamesWidthBuf_.clear(); } void getUnbufferedIconsForPreload(std::vector>& newLoad) //return (priority, filepath) list { if (IconBuffer* iconBuf = getIconManager().getIconBuffer()) { const auto& [rowFirst, rowLast] = refGrid().getVisibleRows(refGrid().getMainWin().GetClientSize()); const ptrdiff_t visibleRowCount = rowLast - rowFirst; //preload icons not yet on screen: const int preloadSize = 2 * std::max(20, visibleRowCount); //:= sum of lines above and below of visible range to preload //=> use full visible height to handle "next page" command and a minimum of 20 for excessive mouse wheel scrolls for (ptrdiff_t i = 0; i < preloadSize; ++i) { const ptrdiff_t currentRow = rowFirst - (preloadSize + 1) / 2 + getAlternatingPos(i, visibleRowCount + preloadSize); //for odd preloadSize start one row earlier if (const FileSystemObject* fsObj = getFsObject(currentRow)) if (getIconInfo(*fsObj).type == IconType::standard) if (!iconBuf->readyForRetrieval(fsObj->template getAbstractPath())) newLoad.emplace_back(i, fsObj->template getAbstractPath()); //insert least-important items on outer rim first } } else assert(false); } void updateNewAndGetUnbufferedIcons(std::vector& newLoad) //loads all not yet drawn icons { if (IconBuffer* iconBuf = getIconManager().getIconBuffer()) { const auto& [rowFirst, rowLast] = refGrid().getVisibleRows(refGrid().getMainWin().GetClientSize()); const ptrdiff_t visibleRowCount = rowLast - rowFirst; for (ptrdiff_t i = 0; i < visibleRowCount; ++i) { //alternate when adding rows: first, last, first + 1, last - 1 ... const ptrdiff_t currentRow = rowFirst + getAlternatingPos(i, visibleRowCount); if (isFailedLoad(currentRow)) //find failed attempts to load icon if (const FileSystemObject* fsObj = getFsObject(currentRow)) if (getIconInfo(*fsObj).type == IconType::standard) { //test if they are already loaded in buffer: if (iconBuf->readyForRetrieval(fsObj->template getAbstractPath())) { //do a *full* refresh for *every* failed load to update partial DC updates while scrolling refGrid().refreshCell(currentRow, static_cast(ColumnTypeRim::path)); setFailedLoad(currentRow, false); } else //not yet in buffer: mark for async. loading newLoad.push_back(fsObj->template getAbstractPath()); } } } else assert(false); } private: bool isFailedLoad(size_t row) const { return row < failedLoads_.size() ? failedLoads_[row] != 0 : false; } void setFailedLoad(size_t row, bool failed = true) { if (failedLoads_.size() != refGrid().getRowCount()) failedLoads_.resize(refGrid().getRowCount()); if (row < failedLoads_.size()) failedLoads_[row] = failed; } //icon buffer will load reversely, i.e. if we want to go from inside out, we need to start from outside in static size_t getAlternatingPos(size_t pos, size_t total) { assert(pos < total); return pos % 2 == 0 ? pos / 2 : total - 1 - pos / 2; } private: enum class DisplayType { inactive, normal, symlink, }; static DisplayType getObjectDisplayType(const FileSystemObject& fsObj) { if (!fsObj.isActive()) return DisplayType::inactive; DisplayType output = DisplayType::normal; visitFSObject(fsObj, [](const FolderPair& folder) {}, [](const FilePair& file) {}, [&](const SymlinkPair& symlink) { output = DisplayType::symlink; }); return output; } std::wstring getValue(size_t row, ColumnType colType) const override { if (const FileSystemObject* fsObj = getFsObject(row)) if (!fsObj->isEmpty()) { if (static_cast(colType) == ColumnTypeRim::path) switch (itemPathFormat_) { case ItemPathFormat::name: return utfTo(fsObj->getItemName()); case ItemPathFormat::relative: return utfTo(fsObj->getRelativePath()); case ItemPathFormat::full: return AFS::getDisplayPath(fsObj->getAbstractPath()); } std::wstring value; //dynamically allocates 16 byte memory! but why? shouldn't SSO make this superfluous?! or is it only in debug? switch (static_cast(colType)) { case ColumnTypeRim::path: assert(false); break; case ColumnTypeRim::size: visitFSObject(*fsObj, [&](const FolderPair& folder) { /*value = L'<' + _("Folder") + L'>'; -> redundant!? */ }, [&](const FilePair& file) { value = formatNumber(file.getFileSize()); }, //[&](const FilePair& file) { value = numberTo(file.getFilePrint()); }, // -> test file id [&](const SymlinkPair& symlink) { value = L'<' + _("Symlink") + L'>'; }); break; case ColumnTypeRim::date: visitFSObject(*fsObj, [](const FolderPair& folder) {}, [&](const FilePair& file) { value = formatUtcToLocalTime(file .getLastWriteTime()); }, [&](const SymlinkPair& symlink) { value = formatUtcToLocalTime(symlink.getLastWriteTime()); }); break; case ColumnTypeRim::extension: visitFSObject(*fsObj, [](const FolderPair& folder) {}, [&](const FilePair& file) { value = utfTo(getFileExtension(file .getItemName())); }, [&](const SymlinkPair& symlink) { value = utfTo(getFileExtension(symlink.getItemName())); }); break; } return value; } return {}; } void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override { const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); if (!enabled || !selected) { const wxColor backCol = [&] { if (pdi.fsObj && !pdi.fsObj->isEmpty()) //do we need color indication for *inactive* empty rows? probably not... switch (getObjectDisplayType(*pdi.fsObj)) { case DisplayType::normal: break; case DisplayType::symlink: return getColorSymlinkBackground(); case DisplayType::inactive: return getColorInactiveBack(); } return getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0); }(); if (backCol != wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW) /*already the default!*/) clearArea(dc, rect, backCol); } else GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); //---------------------------------------------------------------------------------- const wxRect rectLine(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)); clearArea(dc, rectLine, row == pdi.groupLastRow - 1 || //last group item (pdi.fsObj == pdi.folderGroupObj && //folder item => distinctive separation color against subsequent file items itemPathFormat_ != ItemPathFormat::name) ? getColorGridLine() : getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 != 0)); } int getGroupItemNamesWidth(const wxReadOnlyDC& dc, const FileView::PathDrawInfo& pdi) { //FileView::updateView() called? => invalidates group item render buffer if (pdi.viewUpdateId != viewUpdateIdLast_) { viewUpdateIdLast_ = pdi.viewUpdateId; groupItemNamesWidthBuf_.clear(); } auto& widthBuf = groupItemNamesWidthBuf_; if (pdi.groupIdx >= widthBuf.size()) widthBuf.resize(pdi.groupIdx + 1, -1 /*sentinel value*/); int& itemNamesWidth = widthBuf[pdi.groupIdx]; if (itemNamesWidth < 0) { itemNamesWidth = 0; //const int ellipsisWidth = getTextExtentBuffered(dc, ELLIPSIS).x; std::vector itemWidths; for (size_t row2 = pdi.groupFirstRow; row2 < pdi.groupLastRow; ++row2) if (const FileSystemObject* fsObj = getDataView().getFsObject(row2)) if (itemPathFormat_ == ItemPathFormat::name || fsObj != pdi.folderGroupObj) #if 0 //render same layout even when items don't exist if (fsObj->isEmpty()) itemNamesWidth = ellipsisWidth; else #endif itemWidths.push_back(getTextExtentBuffered(dc, utfTo(fsObj->getItemName())).x); if (!itemWidths.empty()) { //ignore (small number of) excessive file name widths: auto itPercentile = itemWidths.begin() + itemWidths.size() * 8 / 10; //80th percentile std::nth_element(itemWidths.begin(), itPercentile, itemWidths.end()); //complexity: O(n) itemNamesWidth = std::max(itemNamesWidth, *itPercentile); } assert(itemNamesWidth >= 0); //Note: A better/faster solution would be to get 80th percentile of all std::wstring::size(), then do a *single* getTextExtentBuffered()! // However, we need all the getTextExtentBuffered(itemName) later anyway, so above is fine. } return itemNamesWidth; } struct GroupRowLayout { std::wstring groupParentPart; //... if distributed over multiple rows, otherwise full group parent folder std::wstring groupName; //only filled for first row of a group std::wstring itemName; int groupParentWidth; int groupNameWidth; }; GroupRowLayout getGroupRowLayout(const wxReadOnlyDC& dc, size_t row, const FileView::PathDrawInfo& pdi, int maxWidth) { assert(pdi.fsObj); const bool drawFileIcons = getIconManager().getIconBuffer(); const int iconSize = getIconManager().getIconWxsize(); //-------------------------------------------------------------------- const int ellipsisWidth = getTextExtentBuffered(dc, ELLIPSIS).x; const int arrowRightDownWidth = getTextExtentBuffered(dc, rightArrowDown_).x; const int groupItemNamesWidth = getGroupItemNamesWidth(dc, pdi); //-------------------------------------------------------------------- //exception for readability: top row is always group start! const size_t groupFirstRow = std::max(pdi.groupFirstRow, refGrid().getRowAtWinPos(0)); const size_t groupRowCount = pdi.groupLastRow - groupFirstRow; std::wstring itemName; if (itemPathFormat_ == ItemPathFormat::name || //hack: show folder name in item colum since groupName/groupParentFolder are unused! pdi.fsObj != pdi.folderGroupObj) //=> consider groupItemNamesWidth! itemName = utfTo(pdi.fsObj->getItemName()); //=> doesn't matter if isEmpty()! => only indicates if component should be drawn std::wstring groupName; std::wstring groupParentFolder; switch (itemPathFormat_) { case ItemPathFormat::name: break; case ItemPathFormat::relative: if (pdi.folderGroupObj) { groupName = utfTo(pdi.folderGroupObj ->template getItemName ()); groupParentFolder = utfTo(pdi.folderGroupObj->parent().template getRelativePath()); } break; case ItemPathFormat::full: if (pdi.folderGroupObj) { groupName = utfTo(pdi.folderGroupObj ->template getItemName ()); groupParentFolder = AFS::getDisplayPath(pdi.folderGroupObj->parent().template getAbstractPath()); } else //=> BaseFolderPair groupParentFolder = AFS::getDisplayPath(pdi.fsObj->base().getAbstractPath()); break; } if (!groupParentFolder.empty()) { const wchar_t pathSep = [&] { for (auto it = groupParentFolder.end(); it != groupParentFolder.begin();) //reverse iteration: 1. check 2. decrement 3. evaluate { --it; // if (*it == L'/' || *it == L'\\') return *it; } return static_cast(FILE_NAME_SEPARATOR); }(); if (!endsWith(groupParentFolder, pathSep)) //visual hint that this is a parent folder only groupParentFolder += pathSep; // } /* group details: single row ________________________ ___________________________________ _____________________________________________________ | (gap | group parent) | | (gap | icon | gap | group name) | | (2x gap | vline) | (gap | icon) | gap | item name | ------------------------ ----------------------------------- ----------------------------------------------------- group details: stacked __________________________________ ___________________________________ ___________________________________________________ | gap | group parent, part 1 | ⤵️ | | (gap | icon | gap | group name) | | | (gap | icon) | gap | item name | |-------------------------------------------------------------------------------------| | 2x gap | vline |--------------------------------| | gap | group parent, part n | | | (gap | icon) | gap | item name | --------------------------------------------------------------------------------------- --------------------------------------------------- -> group name on first row -> parent name distributed over multiple rows, if needed */ int groupParentWidth = groupParentFolder.empty() ? 0 : (gapSize_ + getTextExtentBuffered(dc, groupParentFolder).x); int groupNameWidth = groupName.empty() ? 0 : (gapSize_ + iconSize + gapSize_ + getTextExtentBuffered(dc, groupName).x); const int groupNameMinWidth = groupName.empty() ? 0 : (gapSize_ + iconSize + gapSize_ + ellipsisWidth); const int groupSepWidth = (groupParentFolder.empty() && groupName.empty()) ? 0 : (2 * gapSize_ + dipToWxsize(1)); int groupItemsWidth = groupSepWidth + (drawFileIcons ? gapSize_ + iconSize : 0) + gapSize_ + groupItemNamesWidth; const int groupItemsMinWidth = groupSepWidth + (drawFileIcons ? gapSize_ + iconSize : 0) + gapSize_ + ellipsisWidth; std::wstring groupParentPart; //not enough space? => trim or render on multiple rows if (int excessWidth = groupParentWidth + groupNameWidth + groupItemsWidth - maxWidth; excessWidth > 0) { //1. shrink group parent if (!groupParentFolder.empty()) { const int groupParentMinWidth = !groupName.empty() && groupRowCount > 1 ? //group parent details (possibly) on multiple rows 0 : gapSize_ + ellipsisWidth; groupParentWidth = std::max(groupParentWidth - excessWidth, groupParentMinWidth); excessWidth = groupParentWidth + groupNameWidth + groupItemsWidth - maxWidth; } if (excessWidth > 0) { //2. shrink item rendering groupItemsWidth = std::max(groupItemsWidth - excessWidth, groupItemsMinWidth); excessWidth = groupParentWidth + groupNameWidth + groupItemsWidth - maxWidth; if (excessWidth > 0) //3. shrink group name if (!groupName.empty()) groupNameWidth = std::max(groupNameWidth - excessWidth, groupNameMinWidth); } //group parent details on multiple lines if (!groupParentFolder.empty()) { //let's not waste empty row space for medium + large icon sizes: print multiple lines per row! const int linesPerRow = std::max(refGrid().getRowHeight() / charHeight_, 1); size_t compPos = 0; for (size_t i = groupFirstRow; i <= row; ++i) for (int l = 0; l < linesPerRow; ++l) { const size_t compLen = i == pdi.groupLastRow - 1 && l == linesPerRow - 1 ? //not enough rows to show remaining parent folder components? groupParentFolder.size() - compPos : //=> append the rest: will be truncated with ellipsis getPathTrimmedSize(dc, makeStringView(groupParentFolder.begin() + compPos, groupParentFolder.end()), groupParentWidth + (i == groupFirstRow ? 0 : groupNameWidth) - gapSize_ - arrowRightDownWidth); if (i == groupFirstRow && !groupName.empty() && groupRowCount > 1 && getTextExtentBuffered(dc, makeStringView(groupParentFolder.begin() + compPos, compLen)).x > groupParentWidth - gapSize_ - arrowRightDownWidth) { if (i == row && l != 0) groupParentPart.insert(groupParentPart.begin(), linesPerRow - l, L'\n'); //effectively: "align bottom" for first row break; //exception: never truncate parent component on first row, but continue on second row instead } if (i == row) groupParentPart += compPos + compLen == groupParentFolder.size() ? groupParentFolder.substr(compPos) : groupParentFolder.substr(compPos, compLen) + rightArrowDown_ + L'\n'; compPos += compLen; if (compPos == groupParentFolder.size()) goto break2; } break2: if (endsWith(groupParentPart, L'\n')) groupParentPart.pop_back(); } } else { if (row == groupFirstRow) groupParentPart = groupParentFolder; } //path components should follow the app layout direction and are NOT a single piece of text! //caveat: - add Bidi support only during rendering and not in getValue() or AFS::getDisplayPath(): e.g. support "open file in Explorer" // - add *after* getPathTrimmedSize(), otherwise LTR-mark can be confused for path component, e.g. "/home" would be two components! assert(!contains(groupParentPart, slashBidi_) && !contains(groupParentPart, bslashBidi_)); replace(groupParentPart, L'/', slashBidi_); replace(groupParentPart, L'\\', bslashBidi_); return { std::move(groupParentPart), row == groupFirstRow ? std::move(groupName) : std::wstring{}, std::move(itemName), row == groupFirstRow ? groupParentWidth : groupParentWidth + groupNameWidth, row == groupFirstRow ? groupNameWidth : 0, }; } void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override { //----------------------------------------------- //don't forget: harmonize with getBestSize()!!! //----------------------------------------------- if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); pdi.fsObj) { //accessibility: always set both foreground AND background colors! wxDCTextColourChanger textColor(dc); if (enabled && selected) //=> coordinate with renderRowBackgound() textColor.Set(*wxBLACK); else if (!pdi.fsObj->isEmpty()) switch (getObjectDisplayType(*pdi.fsObj)) { case DisplayType::normal: break; case DisplayType::symlink: textColor.Set(*wxBLACK); break; case DisplayType::inactive: textColor.Set(getColorInactiveText()); break; } wxRect rectTmp = rect; switch (static_cast(colType)) { case ColumnTypeRim::path: { auto drawCudHighlight = [&](wxRect rectCud, SyncOperation syncOp) { if (getViewType() == GridViewType::action) if (!enabled || !selected) if (const auto& [cudAction, cudSide] = getCudAction(syncOp); cudAction != CudAction::noChange && side == cudSide) { rectCud.width = gapSize_ + screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small)); //fixed-size looks fine for all icon sizes! use same width even if file icons are disabled! clearArea(dc, rectCud, getBackGroundColorSyncAction(syncOp)); rectCud.x += rectCud.width; rectCud.width = gapSize_ + dipToWxsize(2); #if 0 //wxDC::GetPixel() is broken in GTK3! https://github.com/wxWidgets/wxWidgets/issues/14067 wxColor backCol = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); dc.GetPixel(rectCud.GetTopRight(), &backCol); #else const wxColor backCol = getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0); #endif dc.GradientFillLinear(rectCud, getBackGroundColorSyncAction(syncOp), backCol, wxEAST); } }; bool navMarkerDrawn = false; auto tryDrawNavMarker = [&](wxRect rectNav) { if (!navMarkerDrawn && rectNav.x == rect.x && //draw marker *only* if current render group (group parent, group name, item name) is at beginning of a row! isNavMarked(*pdi.fsObj) && (!enabled || !selected)) { rectNav.width = std::min(rectNav.width, dipToWxsize(10)); if (row == pdi.groupLastRow - 1 /*last group item*/) //preserve the group separation line! rectNav.height -= dipToWxsize(1); dc.GradientFillLinear(rectNav, getColorSelectionGradientFrom(), getColorSelectionGradientTo(), wxEAST); navMarkerDrawn = true; } }; auto drawIcon = [&](wxImage icon, wxRect rectIcon, bool drawActive) { if (!drawActive) icon = icon.ConvertToGreyscale(1.0 / 3, 1.0 / 3, 1.0 / 3); //treat all channels equally! if (!enabled) icon = icon.ConvertToDisabled(); rectIcon.x += gapSize_; rectIcon.width = getIconManager().getIconWxsize(); //center smaller-than-default icons drawBitmapRtlNoMirror(dc, icon, rectIcon, wxALIGN_CENTER); }; auto drawFileIcon = [this, &drawIcon](const wxImage& fileIcon, bool drawAsLink, const wxRect& rectIcon, const FileSystemObject& fsObj) { if (fileIcon.IsOk()) drawIcon(fileIcon, rectIcon, fsObj.isActive()); if (drawAsLink) drawIcon(getIconManager().getLinkOverlayIcon(), rectIcon, fsObj.isActive()); if (getViewType() == GridViewType::action) if (const auto& [cudAction, cudSide] = getCudAction(fsObj.getSyncOperation()); side == cudSide) switch (cudAction) { case CudAction::create: assert(!fileIcon.IsOk() && !drawAsLink); if (const bool isFolder = dynamic_cast(&fsObj) != nullptr) drawIcon(getIconManager().getGenericDirIcon().ConvertToGreyscale(1.0 / 3, 1.0 / 3, 1.0 / 3). //treat all channels equally! ConvertToDisabled(), rectIcon, true /*drawActive: [!]*/); //visual hint to distinguish file/folder creation //too much clutter? => drawIcon(getIconManager().getPlusOverlayIcon(), rectIcon, // true /*drawActive: [!] e.g. disabled folder, exists left only, where child item is copied*/); break; case CudAction::delete_: drawIcon(getIconManager().getMinusOverlayIcon(), rectIcon, true /*drawActive: [!]*/); break; case CudAction::noChange: case CudAction::update: break; }; }; //------------------------------------------------------------------------- const auto& [groupParentPart, groupName, itemName, groupParentWidth, groupNameWidth] = getGroupRowLayout(dc, row, pdi, rectTmp.width); wxRect rectGroup, rectGroupParent, rectGroupName; rectGroup = rectGroupParent = rectGroupName = rectTmp; rectGroup .width = groupParentWidth + groupNameWidth; rectGroupParent.width = groupParentWidth; rectGroupName .width = groupNameWidth; rectGroupName.x += groupParentWidth; rectTmp.x += rectGroup.width; rectTmp.width -= rectGroup.width; wxRect rectGroupItems = rectTmp; if (itemName.empty()) //expand group name to include unused item area (e.g. bigger selection border) { rectGroupName.width += rectGroupItems.width; rectGroupItems.width = 0; } //------------------------------------------------------------------------- { //clear background below parent path => harmonize with renderRowBackgound() wxDCTextColourChanger textColorGroup(dc); if (rectGroup.width > 0 && (!enabled || !selected)) { wxRect rectGroupBack = rectGroup; rectGroupBack.width += 2 * gapSize_; //include gap before vline if (row == pdi.groupLastRow - 1 /*last group item*/) //preserve the group separation line! rectGroupBack.height -= dipToWxsize(1); clearArea(dc, rectGroupBack, getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0)); //clearArea() is surprisingly expensive => call just once! textColorGroup.Set(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); //accessibility: always set *both* foreground AND background colors! } if (!groupParentPart.empty() && (!pdi.folderGroupObj || !pdi.folderGroupObj->isEmpty())) //don't show for missing folders { tryDrawNavMarker(rectGroupParent); wxRect rectGroupParentText = rectGroupParent; rectGroupParentText.x += gapSize_; rectGroupParentText.width -= gapSize_; //let's not waste empty row space for medium + large icon sizes: print multiple lines per row! split(groupParentPart, L'\n', [&, linesPerRow = std::max(refGrid().getRowHeight() / charHeight_, 1), lineNo = 0](const std::wstring_view line) mutable { drawCellText(dc, { rectGroupParentText.x, //distribute lines evenly across multiple rows: rectGroupParentText.y + (rectGroupParentText.height * (1 + lineNo++ * 2) - linesPerRow * charHeight_) / (linesPerRow * 2), rectGroupParentText.width, charHeight_ }, line, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, line)); }); #if 0 drawCellText(dc, rectGroupParentText, groupParentPart, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, groupParentPart)); #endif } if (!groupName.empty()) { wxRect rectGroupNameBack = rectGroupName; if (!itemName.empty()) rectGroupNameBack.width += 2 * gapSize_; //include gap left of item vline rectGroupNameBack.height -= dipToWxsize(1); //harmonize with item separation lines wxDCTextColourChanger textColorGroupName(dc); //folder background: coordinate with renderRowBackgound() if (!enabled || !selected) if (!pdi.folderGroupObj->isEmpty() && !pdi.folderGroupObj->isActive()) { clearArea(dc, rectGroupNameBack, getColorInactiveBack()); textColorGroupName.Set(getColorInactiveText()); } drawCudHighlight(rectGroupNameBack, pdi.folderGroupObj->getSyncOperation()); tryDrawNavMarker(rectGroupName); wxImage folderIcon; bool drawAsLink = false; if (!pdi.folderGroupObj->isEmpty()) { folderIcon = getIconManager().getGenericDirIcon(); drawAsLink = pdi.folderGroupObj->isFollowedSymlink(); } drawFileIcon(folderIcon, drawAsLink, rectGroupName, *pdi.folderGroupObj); rectGroupName.x += gapSize_ + getIconManager().getIconWxsize() + gapSize_; rectGroupName.width -= gapSize_ + getIconManager().getIconWxsize() + gapSize_; //mouse highlight: group name if (static_cast(rowHover) == HoverAreaGroup::groupName || (static_cast(rowHover) == HoverAreaGroup::item && pdi.fsObj == pdi.folderGroupObj /*exception: extend highlight*/)) drawRectangleBorder(dc, rectGroupNameBack, mouseHighlightColor_, dipToWxsize(1)); if (!pdi.folderGroupObj->isEmpty()) drawCellText(dc, rectGroupName, groupName, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, groupName)); } } //------------------------------------------------------------------------- if (!itemName.empty()) { //draw group/items separation line if (rectGroup.width > 0) { rectGroupItems.x += 2 * gapSize_; rectGroupItems.width -= 2 * gapSize_; wxRect rectLine = rectGroupItems; rectLine.width = dipToWxsize(1); clearArea(dc, rectLine, getColorGridLine()); rectGroupItems.x += dipToWxsize(1); rectGroupItems.width -= dipToWxsize(1); } //------------------------------------------------------------------------- wxRect rectItemsBack = rectGroupItems; rectItemsBack.height -= dipToWxsize(1); //preserve item separation lines! drawCudHighlight(rectItemsBack, pdi.fsObj->getSyncOperation()); tryDrawNavMarker(rectGroupItems); if (IconBuffer* iconBuf = getIconManager().getIconBuffer()) //=> draw file icons { /* whenever there's something new to render on screen, start up watching for failed icon drawing: => ideally it would suffice to start watching only when scrolling grid or showing new grid content, but this solution is more robust and the icon updater will stop automatically when finished anyway Note: it's not sufficient to start up on failed icon loads only, since we support prefetching of not yet visible rows!!! */ getIconManager().startIconUpdater(); wxImage fileIcon; const IconInfo ii = getIconInfo(*pdi.fsObj); switch (ii.type) { case IconType::folder: fileIcon = getIconManager().getGenericDirIcon(); break; case IconType::standard: if (std::optional tmpIco = iconBuf->retrieveFileIcon(pdi.fsObj->template getAbstractPath())) fileIcon = *tmpIco; else { setFailedLoad(row); //save status of failed icon load -> used for async. icon loading //falsify only! avoid writing incorrect success status when only partially updating the DC, e.g. during scrolling, //see repaint behavior of ::ScrollWindow() function! fileIcon = iconBuf->getIconByExtension(pdi.fsObj->template getItemName()); //better than nothing } break; case IconType::none: break; } drawFileIcon(fileIcon, ii.drawAsLink, rectGroupItems, *pdi.fsObj); rectGroupItems.x += gapSize_ + getIconManager().getIconWxsize(); rectGroupItems.width -= gapSize_ + getIconManager().getIconWxsize(); } rectGroupItems.x += gapSize_; rectGroupItems.width -= gapSize_; //mouse highlight: item name if (static_cast(rowHover) == HoverAreaGroup::item) drawRectangleBorder(dc, rectItemsBack, mouseHighlightColor_, dipToWxsize(1)); if (!pdi.fsObj->isEmpty()) drawCellText(dc, rectGroupItems, itemName, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, itemName)); } //if not done yet: tryDrawNavMarker(rect); } break; case ColumnTypeRim::size: case ColumnTypeRim::date: case ColumnTypeRim::extension: { if (refGrid().GetLayoutDirection() == wxLayout_RightToLeft || //remain left-justified for RTL languages static_cast(colType) == ColumnTypeRim::extension) { rectTmp.x += gapSize_; rectTmp.width -= gapSize_; drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); } else { rectTmp.width -= gapSize_; drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_RIGHT | wxALIGN_CENTER_VERTICAL); //macOS: wxALIGN_RIGHT also helps mitigate NSDateFormatter not zero-padding dates! } } break; } } } HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) override { if (static_cast(colType) == ColumnTypeRim::path) if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); pdi.fsObj) { const auto& [groupParentPart, groupName, itemName, groupParentWidth, groupNameWidth] = getGroupRowLayout(dc, row, pdi, cellWidth); if (!groupName.empty() && pdi.fsObj != pdi.folderGroupObj) { const int groupNameCellBeginX = groupParentWidth; if (groupNameCellBeginX <= cellRelativePosX && cellRelativePosX < groupNameCellBeginX + groupNameWidth + 2 * gapSize_ /*include gap before vline*/) return static_cast(HoverAreaGroup::groupName); } } return static_cast(HoverAreaGroup::item); } int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override { if (static_cast(colType) == ColumnTypeRim::path) { int bestSize = 0; if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); pdi.fsObj) { const int insanelyHugeWidth = 1000'000'000; //(hopefully) still small enough to avoid integer overflows /* ________________________ ___________________________________ _____________________________________________________ | (gap | group parent) | | (gap | icon | gap | group name) | | (2x gap | vline) | (gap | icon) | gap | item name | ------------------------ ----------------------------------- ----------------------------------------------------- */ const auto& [groupParentPart, groupName, itemName, groupParentWidth, groupNameWidth] = getGroupRowLayout(dc, row, pdi, insanelyHugeWidth); const int groupSepWidth = groupParentWidth + groupNameWidth <= 0 ? 0 : (2 * gapSize_ + dipToWxsize(1)); const int fileIconWidth = getIconManager().getIconBuffer() ? gapSize_ + getIconManager().getIconWxsize() : 0; const int ellipsisWidth = getTextExtentBuffered(dc, ELLIPSIS).x; const int itemWidth = itemName.empty() ? 0 : (groupSepWidth + fileIconWidth + gapSize_ + (pdi.fsObj->isEmpty() ? ellipsisWidth : getTextExtentBuffered(dc, itemName).x)); bestSize += groupParentWidth + groupNameWidth + itemWidth + gapSize_ /*[!]*/; } return bestSize; } else { const wxReadOnlyDC& infoDc = dc; const std::wstring cellValue = getValue(row, colType); return gapSize_ + infoDc.GetTextExtent(cellValue).GetWidth() + gapSize_; } } std::wstring getColumnLabel(ColumnType colType) const override { switch (static_cast(colType)) { case ColumnTypeRim::path: switch (itemPathFormat_) { case ItemPathFormat::name: return _("Item name"); case ItemPathFormat::relative: return _("Relative path"); case ItemPathFormat::full: return _("Full path"); } assert(false); break; case ColumnTypeRim::size: return _("Size"); case ColumnTypeRim::date: return _("Date"); case ColumnTypeRim::extension: return _("Extension"); } //assert(false); may be ColumnType::none return std::wstring(); } void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) override { const wxRect rectInner = drawColumnLabelBackground(dc, rect, highlighted); wxRect rectRemain = rectInner; rectRemain.x += getColumnGapLeft(); rectRemain.width -= getColumnGapLeft(); drawColumnLabelText(dc, rectRemain, getColumnLabel(colType), enabled); //draw sort marker if (auto sortInfo = getDataView().getSortConfig()) if (const ColumnTypeRim* sortType = std::get_if(&sortInfo->sortCol)) if (*sortType == static_cast(colType) && sortInfo->onLeft == (side == SelectSide::left)) { bool ascending = sortInfo->ascending; //work around MSVC 17.4 compiler bug :( "error C2039: 'sortCol': is not a member of 'fff::FileView::SortInfo'" const wxImage sortMarker = loadImage(ascending ? "sort_ascending" : "sort_descending"); drawBitmapRtlNoMirror(dc, enabled ? sortMarker : sortMarker.ConvertToDisabled(), rectInner, wxALIGN_CENTER_HORIZONTAL); } } std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override { const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); std::wstring toolTip; if (const FileSystemObject* tipObj = static_cast(rowHover) == HoverAreaGroup::groupName ? pdi.folderGroupObj : pdi.fsObj) { if (getDataView().getEffectiveFolderPairCount() > 1) toolTip += AFS::getDisplayPath(tipObj->base().getAbstractPath()) + rightArrowDown_ + L"\n\n"; toolTip += utfTo(tipObj->getRelativePath()); //path components should follow the app layout direction and are NOT a single piece of text! //caveat: add Bidi support only during rendering and not in getValue() or AFS::getDisplayPath(): e.g. support "open file in Explorer" assert(!contains(toolTip, slashBidi_) && !contains(toolTip, bslashBidi_)); replace(toolTip, L'/', slashBidi_); replace(toolTip, L'\\', bslashBidi_); if (tipObj->isEmpty()) toolTip += std::wstring(L"\n") + TAB_SPACE + L'<' + _("Item not existing") + L'>'; else visitFSObject(*tipObj, [&](const FolderPair& folder) { //toolTip += std::wstring(L"\n") + TAB_SPACE + '<' + _("Folder") + L'>'; -> redundant!? }, [&](const FilePair& file) { toolTip += std::wstring(L"\n") + TAB_SPACE + _("Size:") + L' ' + formatFilesizeShort (file.getFileSize ()) + /**/ L'\n' + TAB_SPACE + _("Date:") + L' ' + formatUtcToLocalTime(file.getLastWriteTime()); }, [&](const SymlinkPair& symlink) { toolTip += std::wstring(L"\n") + TAB_SPACE + L'<' + _("Symlink") + L'>' + /**/ L'\n' + TAB_SPACE + _("Date:") + L' ' + formatUtcToLocalTime(symlink.getLastWriteTime()); }); } return toolTip; } enum class IconType { none, folder, standard, }; struct IconInfo { IconType type = IconType::none; bool drawAsLink = false; }; static IconInfo getIconInfo(const FileSystemObject& fsObj) { IconInfo out; if (!fsObj.isEmpty()) visitFSObject(fsObj, [&](const FolderPair& folder) { out.type = IconType::folder; out.drawAsLink = folder.isFollowedSymlink(); }, [&](const FilePair& file) { out.type = IconType::standard; out.drawAsLink = file.isFollowedSymlink() || hasLinkExtension(file.getItemName()); }, [&](const SymlinkPair& symlink) { out.type = IconType::standard; out.drawAsLink = true; }); return out; } const int gapSize_ = dipToWxsize(FILE_GRID_GAP_SIZE_DIP); const int gapSizeWide_ = dipToWxsize(FILE_GRID_GAP_SIZE_WIDE_DIP); const wxColor mouseHighlightColor_ = enhanceContrast(*wxBLUE, //primarily needed for dark mode! wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 5 /*contrastRatioMin*/); //W3C recommends >= 4.5 const int charHeight_ = refGrid().getMainWin().GetCharHeight(); ItemPathFormat itemPathFormat_ = ItemPathFormat::full; std::vector failedLoads_; //effectively a vector of size "number of rows" const std::wstring slashBidi_ = (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft ? RTL_MARK : LTR_MARK) + std::wstring(L"/"); const std::wstring bslashBidi_ = (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft ? RTL_MARK : LTR_MARK) + std::wstring(L"\\"); //no need for LTR/RTL marks on both sides: text follows main direction if slash is between two strong characters with different directions const std::wstring rightArrowDown_ = wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft ? std::wstring() + RTL_MARK + LEFT_ARROW_ANTICLOCK : std::wstring() + LTR_MARK + RIGHT_ARROW_CURV_DOWN; //Windows bug: RIGHT_ARROW_CURV_DOWN rendering and extent calculation is buggy (see wx+\tooltip.cpp) => need LTR mark! std::vector groupItemNamesWidthBuf_; //buffer! groupItemNamesWidths essentially only depends on (groupIdx, side) uint64_t viewUpdateIdLast_ = 0; // }; class GridDataLeft : public GridDataRim { public: GridDataLeft(Grid& grid, const SharedRef& sharedComp) : GridDataRim(grid, sharedComp) {} }; class GridDataRight : public GridDataRim { public: GridDataRight(Grid& grid, const SharedRef& sharedComp) : GridDataRim(grid, sharedComp) {} }; //######################################################################################################## class GridDataCenter : public GridDataBase { public: GridDataCenter(Grid& grid, const SharedRef& sharedComp) : GridDataBase(grid, sharedComp), toolTip_(grid) {} //tool tip must not live longer than grid! void onSelectBegin() { selectionInProgress_ = true; refGrid().clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! toolTip_.hide(); //handle custom tooltip } void onSelectEnd(size_t rowFirst, size_t rowLast, HoverArea rowHover, ptrdiff_t clickInitRow) { refGrid().clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! //issue custom event if (selectionInProgress_) //don't process selections initiated by right-click if (rowFirst < rowLast && rowLast <= refGrid().getRowCount()) //empty? probably not in this context switch (static_cast(rowHover)) { case HoverAreaCenter::checkbox: if (const FileSystemObject* fsObj = getFsObject(clickInitRow)) { const bool setIncluded = !fsObj->isActive(); CheckRowsEvent evt(rowFirst, rowLast, setIncluded); refGrid().GetEventHandler()->ProcessEvent(evt); } break; case HoverAreaCenter::dirLeft: { SyncDirectionEvent evt(rowFirst, rowLast, SyncDirection::left); refGrid().GetEventHandler()->ProcessEvent(evt); } break; case HoverAreaCenter::dirNone: { SyncDirectionEvent evt(rowFirst, rowLast, SyncDirection::none); refGrid().GetEventHandler()->ProcessEvent(evt); } break; case HoverAreaCenter::dirRight: { SyncDirectionEvent evt(rowFirst, rowLast, SyncDirection::right); refGrid().GetEventHandler()->ProcessEvent(evt); } break; } selectionInProgress_ = false; //update highlight_ and tooltip: on OS X no mouse movement event is generated after a mouse button click (unlike on Windows) wxPoint clientPos = refGrid().getMainWin().ScreenToClient(wxGetMousePosition()); evalMouseMovement(clientPos); } void evalMouseMovement(const wxPoint& clientPos) { //manage block highlighting and custom tooltip if (!selectionInProgress_) { const size_t row = refGrid().getRowAtWinPos (clientPos.y); //return -1 for invalid position, rowCount if past the end const Grid::ColumnPosInfo cpi = refGrid().getColumnAtWinPos(clientPos.x); //returns ColumnType::none if no column at x position! if (row < refGrid().getRowCount() && cpi.colType != ColumnType::none && refGrid().getMainWin().GetClientRect().Contains(clientPos)) //cursor might have moved outside visible client area showToolTip(row, static_cast(cpi.colType), refGrid().getMainWin().ClientToScreen(clientPos)); else toolTip_.hide(); } } void onMouseLeave() //wxEVT_LEAVE_WINDOW does not respect mouse capture! { toolTip_.hide(); //handle custom tooltip } private: std::wstring getValue(size_t row, ColumnType colType) const override { if (const FileSystemObject* fsObj = getFsObject(row)) switch (static_cast(colType)) { case ColumnTypeCenter::checkbox: break; case ColumnTypeCenter::difference: return getSymbol(fsObj->getCategory()); case ColumnTypeCenter::action: return getSymbol(fsObj->getSyncOperation()); } return std::wstring(); } void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override { const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); if (!enabled || !selected) { const wxColor backCol = [&] { if (!pdi.fsObj) return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); if (!pdi.fsObj->isActive()) return getColorInactiveBack(); return getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0); }(); if (backCol != wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW) /*already the default!*/) clearArea(dc, rect, backCol); } else GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); //---------------------------------------------------------------------------------- const wxRect rectLine(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)); clearArea(dc, rectLine, row == pdi.groupLastRow - 1 /*last group item*/ ? getColorGridLine() : getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 != 0)); } enum class HoverAreaCenter //each cell can be divided into four blocks concerning mouse selections { checkbox, dirLeft, dirNone, dirRight }; void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override { if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); pdi.fsObj) { auto drawHighlightBackground = [&](const wxColor& col) { if ((!enabled || !selected) && pdi.fsObj->isActive()) //coordinate with renderRowBackgound()! { wxRect rectBack = rect; if (row == pdi.groupLastRow - 1 /*last group item*/) //preserve the group separation line! rectBack.height -= dipToWxsize(1); clearArea(dc, rectBack, col); } }; switch (static_cast(colType)) { case ColumnTypeCenter::checkbox: { const bool drawMouseHover = static_cast(rowHover) == HoverAreaCenter::checkbox; wxImage icon = loadImage(pdi.fsObj->isActive() ? (drawMouseHover ? "checkbox_true_hover" : "checkbox_true") : (drawMouseHover ? "checkbox_false_hover" : "checkbox_false")); if (!enabled) icon = icon.ConvertToDisabled(); drawBitmapRtlNoMirror(dc, icon, rect, wxALIGN_CENTER); } break; case ColumnTypeCenter::difference: { if (getViewType() == GridViewType::difference) drawHighlightBackground(getBackGroundColorCmpDifference(pdi.fsObj->getCategory())); wxRect rectTmp = rect; { //draw notch on left side if (notch_.GetHeight() != wxsizeToScreen(rectTmp.height)) notch_ = notch_.Scale(notch_.GetWidth(), wxsizeToScreen(rectTmp.height)); //wxWidgets screws up again and has wxALIGN_RIGHT off by one pixel! -> use wxALIGN_LEFT instead const wxRect rectNotch(rectTmp.x + rectTmp.width - screenToWxsize(notch_.GetWidth()), rectTmp.y, screenToWxsize(notch_.GetWidth()), rectTmp.height); drawBitmapRtlNoMirror(dc, notch_, rectNotch, wxALIGN_LEFT); rectTmp.width -= screenToWxsize(notch_.GetWidth()); } auto drawIcon = [&](wxImage icon, int alignment) { if (!enabled) icon = icon.ConvertToDisabled(); drawBitmapRtlMirror(dc, icon, rectTmp, alignment, renderBufCmp_); }; if (getViewType() == GridViewType::difference) drawIcon(getCmpResultImage(pdi.fsObj->getCategory()), wxALIGN_CENTER); else if (pdi.fsObj->getCategory() != FILE_EQUAL) //don't show = in both middle columns drawIcon(greyScale(getCmpResultImage(pdi.fsObj->getCategory())), wxALIGN_CENTER); } break; case ColumnTypeCenter::action: { if (getViewType() == GridViewType::action) drawHighlightBackground(getBackGroundColorSyncAction(pdi.fsObj->getSyncOperation())); auto drawIcon = [&](wxImage icon, int alignment) { if (!enabled) icon = icon.ConvertToDisabled(); drawBitmapRtlMirror(dc, icon, rect, alignment, renderBufSync_); }; //synchronization preview const auto rowHoverCenter = rowHover == HoverArea::none ? HoverAreaCenter::checkbox : static_cast(rowHover); switch (rowHoverCenter) { case HoverAreaCenter::dirLeft: drawIcon(getSyncOpImage(pdi.fsObj->testSyncOperation(SyncDirection::left)), wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); break; case HoverAreaCenter::dirNone: drawIcon(getSyncOpImage(pdi.fsObj->testSyncOperation(SyncDirection::none)), wxALIGN_CENTER); break; case HoverAreaCenter::dirRight: drawIcon(getSyncOpImage(pdi.fsObj->testSyncOperation(SyncDirection::right)), wxALIGN_RIGHT | wxALIGN_CENTER_VERTICAL); break; case HoverAreaCenter::checkbox: if (getViewType() == GridViewType::action) drawIcon(getSyncOpImage(pdi.fsObj->getSyncOperation()), wxALIGN_CENTER); else if (pdi.fsObj->getSyncOperation() != SO_EQUAL) //don't show = in both middle columns drawIcon(greyScale(getSyncOpImage(pdi.fsObj->getSyncOperation())), wxALIGN_CENTER); break; } } break; } } } HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) override { if (const FileSystemObject* const fsObj = getFsObject(row)) switch (static_cast(colType)) { case ColumnTypeCenter::checkbox: case ColumnTypeCenter::difference: return static_cast(HoverAreaCenter::checkbox); case ColumnTypeCenter::action: if (fsObj->getSyncOperation() == SO_EQUAL) //in sync-preview equal files shall be treated like a checkbox return static_cast(HoverAreaCenter::checkbox); /* cell: ------------------------ | left | middle | right| ------------------------ */ if (0 <= cellRelativePosX) { if (cellRelativePosX < cellWidth / 3) return static_cast(HoverAreaCenter::dirLeft); else if (cellRelativePosX < 2 * cellWidth / 3) return static_cast(HoverAreaCenter::dirNone); else if (cellRelativePosX < cellWidth) return static_cast(HoverAreaCenter::dirRight); } break; } return HoverArea::none; } std::wstring getColumnLabel(ColumnType colType) const override { switch (static_cast(colType)) { case ColumnTypeCenter::checkbox: break; case ColumnTypeCenter::difference: return _("Difference"); case ColumnTypeCenter::action: return _("Action"); } return std::wstring(); } std::wstring getToolTip(ColumnType colType) const override { return getColumnLabel(colType) + L" (F11)"; } void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) override { const auto colTypeCenter = static_cast(colType); const wxRect rectInner = drawColumnLabelBackground(dc, rect, highlighted && colTypeCenter != ColumnTypeCenter::checkbox); wxImage colIcon; switch (colTypeCenter) { case ColumnTypeCenter::checkbox: break; case ColumnTypeCenter::difference: colIcon = greyScaleIfDisabled(loadImage("compare", dipToScreen(getMenuIconDipSize())), getViewType() == GridViewType::difference); break; case ColumnTypeCenter::action: colIcon = greyScaleIfDisabled(loadImage("start_sync", dipToScreen(getMenuIconDipSize())), getViewType() == GridViewType::action); break; } if (colIcon.IsOk()) drawBitmapRtlNoMirror(dc, enabled ? colIcon : colIcon.ConvertToDisabled(), rectInner, wxALIGN_CENTER); //draw sort marker if (auto sortInfo = getDataView().getSortConfig()) if (const ColumnTypeCenter* sortType = std::get_if(&sortInfo->sortCol)) if (*sortType == colTypeCenter) { const int gapLeft = (rectInner.width + screenToWxsize(colIcon.GetWidth())) / 2; wxRect rectRemain = rectInner; rectRemain.x += gapLeft; rectRemain.width -= gapLeft; const wxImage sortMarker = loadImage(sortInfo->ascending ? "sort_ascending" : "sort_descending"); drawBitmapRtlNoMirror(dc, enabled ? sortMarker : sortMarker.ConvertToDisabled(), rectRemain, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); } } void showToolTip(size_t row, ColumnTypeCenter colType, wxPoint posScreen) { if (const FileSystemObject* fsObj = getFsObject(row)) { switch (colType) { case ColumnTypeCenter::checkbox: case ColumnTypeCenter::difference: { const char* imageName = [&] { switch (fsObj->getCategory()) { case FILE_RENAMED: //similar to both "equal" and "conflict" case FILE_EQUAL: return "cat_equal"; case FILE_LEFT_ONLY: return "cat_left_only"; case FILE_RIGHT_ONLY: return "cat_right_only"; case FILE_LEFT_NEWER: return "cat_left_newer"; case FILE_RIGHT_NEWER: return "cat_right_newer"; case FILE_DIFFERENT_CONTENT: return "cat_different"; case FILE_TIME_INVALID: case FILE_CONFLICT: return "cat_conflict"; } assert(false); return ""; }(); const auto& img = mirrorIfRtl(loadImage(imageName)); toolTip_.show(getCategoryDescription(*fsObj), posScreen, &img); } break; case ColumnTypeCenter::action: { const char* imageName = [&] { switch (fsObj->getSyncOperation()) { case SO_CREATE_LEFT: return "so_create_left"; case SO_CREATE_RIGHT: return "so_create_right"; case SO_DELETE_LEFT: return "so_delete_left"; case SO_DELETE_RIGHT: return "so_delete_right"; case SO_MOVE_LEFT_FROM: return "so_move_left_source"; case SO_MOVE_LEFT_TO: return "so_move_left_target"; case SO_MOVE_RIGHT_FROM: return "so_move_right_source"; case SO_MOVE_RIGHT_TO: return "so_move_right_target"; case SO_OVERWRITE_LEFT: return "so_update_left"; case SO_OVERWRITE_RIGHT: return "so_update_right"; case SO_RENAME_LEFT: return "so_move_left"; case SO_RENAME_RIGHT: return "so_move_right"; case SO_DO_NOTHING: return "so_none"; case SO_EQUAL: return "cat_equal"; case SO_UNRESOLVED_CONFLICT: return "cat_conflict"; }; assert(false); return ""; }(); const auto& img = mirrorIfRtl(loadImage(imageName)); toolTip_.show(getSyncOpDescription(*fsObj), posScreen, &img); } break; } } else toolTip_.hide(); //if invalid row... } bool selectionInProgress_ = false; std::optional renderBufCmp_; //avoid costs of recreating this temporary variable std::optional renderBufSync_; Tooltip toolTip_; wxImage notch_ = loadImage("notch"); }; //######################################################################################################## class GridEventManager : private wxEvtHandler { public: GridEventManager(Grid& gridL, Grid& gridC, Grid& gridR, GridDataCenter& provCenter) : gridL_(gridL), gridC_(gridC), gridR_(gridR), provCenter_(provCenter) { gridL_.Bind(EVENT_GRID_COL_RESIZE, [this](GridColumnResizeEvent& event) { onResizeColumn(event, gridL_, gridR_); }); gridR_.Bind(EVENT_GRID_COL_RESIZE, [this](GridColumnResizeEvent& event) { onResizeColumn(event, gridR_, gridL_); }); gridL_.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event, gridL_); }); gridC_.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event, gridC_); }); gridR_.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event, gridR_); }); gridC_.getMainWin().Bind(wxEVT_MOTION, [this](wxMouseEvent& event) { onCenterMouseMovement(event); }); gridC_.getMainWin().Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& event) { onCenterMouseLeave (event); }); gridC_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onCenterSelectBegin(event); }); gridC_.Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onCenterSelectEnd (event); }); gridL_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onGridClickRim(event, gridL_); }); gridR_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onGridClickRim(event, gridR_); }); //clear selection of other grid when selecting on gridL_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this]( GridClickEvent& event) { onGridLeftClick(event, gridR_); }); //clear immediately, gridL_.Bind(EVENT_GRID_MOUSE_RIGHT_DOWN, [this]( GridClickEvent& event) { onGridRightClick(event, gridR_, gridL_); }); //don't wait for GridSelectEvent gridL_.Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onGridSelection(event, gridR_); }); gridR_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this]( GridClickEvent& event) { onGridLeftClick(event, gridL_); }); gridR_.Bind(EVENT_GRID_MOUSE_RIGHT_DOWN, [this]( GridClickEvent& event) { onGridRightClick(event, gridL_, gridR_); }); gridR_.Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onGridSelection(event, gridL_); }); //parallel grid scrolling: do NOT use DoPrepareDC() to align grids! GDI resource leak! Use regular paint event instead: gridL_.getMainWin().Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintGrid(gridL_); event.Skip(); }); gridC_.getMainWin().Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintGrid(gridC_); event.Skip(); }); gridR_.getMainWin().Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintGrid(gridR_); event.Skip(); }); //----------------------------------------------------------------------------------------------------- //scroll master event handling: connect LAST, so that scrollMaster_ is set BEFORE other event handling! //----------------------------------------------------------------------------------------------------- auto connectGridAccess = [&](Grid& grid, std::function handler) { grid.Bind(wxEVT_SCROLLWIN_TOP, handler); grid.Bind(wxEVT_SCROLLWIN_BOTTOM, handler); grid.Bind(wxEVT_SCROLLWIN_LINEUP, handler); grid.Bind(wxEVT_SCROLLWIN_LINEDOWN, handler); grid.Bind(wxEVT_SCROLLWIN_PAGEUP, handler); grid.Bind(wxEVT_SCROLLWIN_PAGEDOWN, handler); grid.Bind(wxEVT_SCROLLWIN_THUMBTRACK, handler); //wxEVT_KILL_FOCUS -> there's no need to reset "scrollMaster" //wxEVT_SET_FOCUS -> not good enough: //e.g.: left grid has input, right grid is "scrollMaster" due to dragging scroll thumb via mouse. //=> Next keyboard input on left does *not* emit focus change event, but still "scrollMaster" needs to change //=> hook keyboard input instead of focus event: grid.getMainWin().Bind(wxEVT_CHAR, handler); grid.Bind(wxEVT_KEY_DOWN, handler); //grid.getMainWin().Bind(wxEVT_KEY_UP, handler); -> superfluous? grid.getMainWin().Bind(wxEVT_LEFT_DOWN, handler); grid.getMainWin().Bind(wxEVT_LEFT_DCLICK, handler); grid.getMainWin().Bind(wxEVT_RIGHT_DOWN, handler); grid.getMainWin().Bind(wxEVT_MOUSEWHEEL, handler); }; connectGridAccess(gridL_, [this](wxEvent& event) { setScrollMaster(gridL_); event.Skip(); }); // connectGridAccess(gridC_, [this](wxEvent& event) { setScrollMaster(gridC_); event.Skip(); }); //connect *after* onKeyDown() in order to receive callback *before*!!! connectGridAccess(gridR_, [this](wxEvent& event) { setScrollMaster(gridR_); event.Skip(); }); // } ~GridEventManager() { //assert(!scrollbarAlignPending_); => false-positives: e.g. start ffs, right-click on grid, close dialog by clicking X } void setScrollMaster(const Grid& grid) { if (&grid != &gridC_ && &grid != &gridL_ && &grid != &gridR_) { assert(false); //does this ever happen? return ; } #if 0 if (const std::string& logtext = "new scroll master: " + printNumber("%llx", reinterpret_cast(&grid)) + "\n"; scrollMaster_ != &grid) std::cerr << logtext; #endif scrollMaster_ = &grid; } private: void onCenterSelectBegin(GridClickEvent& event) { provCenter_.onSelectBegin(); event.Skip(); } void onCenterSelectEnd(GridSelectEvent& event) { if (event.positive_) { if (event.mouseClick_) provCenter_.onSelectEnd(event.rowFirst_, event.rowLast_, event.mouseClick_->hoverArea_, event.mouseClick_->row_); else provCenter_.onSelectEnd(event.rowFirst_, event.rowLast_, HoverArea::none, -1); } event.Skip(); } void onCenterMouseMovement(wxMouseEvent& event) { provCenter_.evalMouseMovement(event.GetPosition()); event.Skip(); } void onCenterMouseLeave(wxMouseEvent& event) { provCenter_.onMouseLeave(); event.Skip(); } void onGridClickRim(GridClickEvent& event, Grid& grid) { if (static_cast(event.hoverArea_) == HoverAreaGroup::groupName) if (const FileView::PathDrawInfo pdi = provCenter_.getDataView().getDrawInfo(event.row_); pdi.fsObj) { const ptrdiff_t topRowOld = grid.getRowAtWinPos(0); grid.makeRowVisible(pdi.groupFirstRow); const ptrdiff_t topRowNew = grid.getRowAtWinPos(0); if (topRowNew != topRowOld) //=> grid was scrolled: prevent AddPendingEvent() recursion! { assert(topRowNew == makeSigned(pdi.groupFirstRow)); assert(topRowNew == grid.getRowAtWinPos((event.mousePos_ - grid.getMainWin().GetPosition()).y)); //don't waste a click: simulate start of new selection at Grid::MainWin-relative position (0/0): grid.getMainWin().GetEventHandler()->AddPendingEvent(wxMouseEvent(wxEVT_LEFT_DOWN)); return; } } event.Skip(); } void onGridLeftClick(GridClickEvent& event, Grid& gridOther) { //see grid.cpp Grid::MainWin::onMouseDown(): if (!wxGetKeyState(WXK_CONTROL) && !wxGetKeyState(WXK_SHIFT)) //clear other grid unless user is holding CTRL, or SHIFT gridOther.clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! event.Skip(); } void onGridRightClick(GridClickEvent& event, Grid& gridOther, Grid& gridThis) { const std::vector& selectedRows = gridThis.getSelectedRows(); const bool rowSelected = std::find(selectedRows.begin(), selectedRows.end(), makeUnsigned(event.row_)) != selectedRows.end(); //clear other grid unless GridContextMenuEvent is about to happen, or user is holding CTRL, or SHIFT if (!rowSelected && !wxGetKeyState(WXK_CONTROL) && !wxGetKeyState(WXK_SHIFT)) gridOther.clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! event.Skip(); } void onGridSelection(GridSelectEvent& event, Grid& gridOther) { if (!event.mouseClick_ && !wxGetKeyState(WXK_SHIFT)) //clear other grid during keyboard selection, unless user is holding SHIFT gridOther.clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! event.Skip(); } void onKeyDown(wxKeyEvent& event, const Grid& grid) { int keyCode = event.GetKeyCode(); if (grid.GetLayoutDirection() == wxLayout_RightToLeft) { if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT) keyCode = WXK_RIGHT; else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT) keyCode = WXK_LEFT; } //skip middle component when navigating via keyboard const size_t row = grid.getGridCursor(); if (event.ShiftDown()) ; else if (event.ControlDown()) ; else switch (keyCode) { case WXK_LEFT: case WXK_NUMPAD_LEFT: gridL_.setGridCursor(row, GridEventPolicy::allow); gridL_.SetFocus(); //since key event is likely originating from right grid, we need to set scrollMaster manually! setScrollMaster(gridL_); //onKeyDown is called *after* onGridAccessL()! return; //swallow event case WXK_RIGHT: case WXK_NUMPAD_RIGHT: gridR_.setGridCursor(row, GridEventPolicy::allow); gridR_.SetFocus(); setScrollMaster(gridR_); return; //swallow event } event.Skip(); } void onResizeColumn(GridColumnResizeEvent& event, const Grid& grid, Grid& gridOther) { //find stretch factor of resized column: type is unique due to makeConsistent()! std::vector cfgSrc = grid.getColumnConfig(); auto it = std::find_if(cfgSrc.begin(), cfgSrc.end(), [&](Grid::ColAttributes& ca) { return ca.type == event.colType_; }); if (it == cfgSrc.end()) return; const int stretchSrc = it->stretch; //we do not propagate resizings on stretched columns to the other side: awkward user experience if (stretchSrc > 0) return; //apply resized offset to other side, but only if stretch factors match! std::vector cfgTrg = gridOther.getColumnConfig(); for (Grid::ColAttributes& ca : cfgTrg) if (ca.type == event.colType_ && ca.stretch == stretchSrc) ca.offset = event.offset_; gridOther.setColumnConfig(cfgTrg); } void onPaintGrid(const Grid& grid) { #if 0 const std::string& logtext = "wxEVT_PAINT: " + printNumber("%llx", reinterpret_cast(&grid)) + "\n"; std::cerr << logtext; #endif /* keep scroll positions of all three grids in sync wxGrid::Scroll() *during* vs *after* paint event: ------------------------------------------------ macOS: doesn't matter; 3 paint events per mouse scroll Linux: no visible perf issue, but a) *during* paint event: 6 paint events b) *after* paint event: 4 paint events Windows: a) *during* paint event: 1. double-buffering(WS_EX_COMPOSITED) => excessive amount of additional paint events and accidental RECURSION!!! Apparently multiple paint events sent (with clipped DC), then during wxGrid::Scroll() -> wxWindow::Update() -> onPaintGrid() for *SAME* grid! 2. no double buffering => 4 paint events per mouse scroll b) *after* paint event: 1. double-buffering(WS_EX_COMPOSITED) => 6 paint events per mouse scroll => no visible perf-difference compared to 2. but 60% higher CPU time during excessive scrolling 2. no double buffering => 4 paint events per mouse scroll */ if (&grid == scrollMaster_ && !scrollPosAlignPending_) { scrollPosAlignPending_ = true; CallAfter([this] { auto scroll = [this](Grid& target, int y) //support polling { if (&target != scrollMaster_) { //scroll vertically only - scrolling horizontally becomes annoying if left and right sides have different widths; //e.g. h-scroll on left would be undone when scrolling vertically on right which doesn't have a h-scrollbar int yOld = 0; target.GetViewStart(nullptr, &yOld); if (yOld != y) target.Scroll(-1, y); //empirical test Windows/Ubuntu: this call does NOT trigger a wxEVT_SCROLLWIN event, // which would incorrectly set "scrollMaster" to "&target"! //CAVEAT: wxScrolledWindow::Scroll() internally calls wxWindow::Update(), leading to immediate WM_PAINT handling in the target grid! // and this while we're still in our WM_PAINT handler! => no recursion, thanks to scrollMaster_ (hopefully) } }; int y = 0; scrollMaster_->GetViewStart(nullptr, &y); scroll(gridC_, y); scroll(gridL_, y); scroll(gridR_, y); assert(scrollPosAlignPending_); scrollPosAlignPending_ = false; }); } //harmonize placement of horizontal scrollbar to avoid grids getting out of sync! //since this affects the grid that is currently repainted, run asynchronously! if (!scrollbarAlignPending_) //send one async event at most, else they may accumulate and create perf issues, see grid.cpp { scrollbarAlignPending_ = true; CallAfter([this] //update *outside* of wxPaint event { auto needsHorizontalScrollbars = [](const Grid& target) { const wxWindow& mainWin = target.getMainWin(); return mainWin.GetVirtualSize().GetWidth() > mainWin.GetClientSize().GetWidth(); //assuming Grid::updateWindowSizes() does its job well, this should suffice! //CAVEAT: if horizontal and vertical scrollbar are circular dependent from each other //(h-scrollbar is shown due to v-scrollbar consuming horizontal width, etc...) //while in fact both are NOT needed, this special case results in a bogus need for scrollbars! //see https://sourceforge.net/tracker/?func=detail&aid=3514183&group_id=234430&atid=1093083 // => since we're outside the Grid abstraction, we should not duplicate code to handle this special case as it seems to be insignificant }; Grid::ScrollBarStatus sbStatusX = needsHorizontalScrollbars(gridL_) || needsHorizontalScrollbars(gridR_) ? Grid::SB_SHOW_ALWAYS : Grid::SB_SHOW_NEVER; gridL_.showScrollBars(sbStatusX, Grid::SB_SHOW_NEVER); gridC_.showScrollBars(sbStatusX, Grid::SB_SHOW_NEVER); gridR_.showScrollBars(sbStatusX, Grid::SB_SHOW_AUTOMATIC); assert(scrollbarAlignPending_); scrollbarAlignPending_ = false; }); } } Grid& gridL_; Grid& gridC_; Grid& gridR_; const Grid* scrollMaster_ = &gridL_; //for address check only; this needn't be the grid having focus! //e.g. mouse wheel events should set window under cursor as scrollMaster, but *not* change focus GridDataCenter& provCenter_; bool scrollbarAlignPending_ = false; bool scrollPosAlignPending_ = false; }; } //######################################################################################################## void filegrid::init(Grid& gridLeft, Grid& gridCenter, Grid& gridRight) { auto sharedComp = makeSharedRef(); auto provLeft_ = std::make_shared(gridLeft, sharedComp); auto provCenter_ = std::make_shared(gridCenter, sharedComp); auto provRight_ = std::make_shared(gridRight, sharedComp); sharedComp.ref().evtMgr = std::make_unique(gridLeft, gridCenter, gridRight, *provCenter_); gridLeft .setDataProvider(provLeft_); //data providers reference grid => gridCenter.setDataProvider(provCenter_); //ownership must belong *exclusively* to grid! gridRight .setDataProvider(provRight_); gridCenter.enableColumnMove (false); gridCenter.enableColumnResize(false); gridCenter.showRowLabel(false); gridRight .showRowLabel(false); //gridLeft .showScrollBars(Grid::SB_SHOW_AUTOMATIC, Grid::SB_SHOW_NEVER); -> redundant: configuration happens in GridEventManager::onPaintGrid() //gridCenter.showScrollBars(Grid::SB_SHOW_NEVER, Grid::SB_SHOW_NEVER); const int widthCheckbox = screenToWxsize( loadImage("checkbox_true").GetWidth() + dipToScreen(3)); const int widthDifference = screenToWxsize(2 * loadImage("sort_ascending").GetWidth() + loadImage("cat_left_only_sicon").GetWidth() + loadImage("notch").GetWidth()); const int widthAction = screenToWxsize(3 * loadImage("so_create_left_sicon").GetWidth()); gridCenter.SetSize(widthDifference + widthCheckbox + widthAction, -1); gridCenter.setColumnConfig( { {static_cast(ColumnTypeCenter::checkbox), widthCheckbox, 0, true}, {static_cast(ColumnTypeCenter::difference), widthDifference, 0, true}, {static_cast(ColumnTypeCenter::action), widthAction, 0, true}, }); } void filegrid::setData(Grid& grid, FolderComparison& folderCmp) { if (auto* prov = dynamic_cast(grid.getDataProvider())) return prov->setData(folderCmp); throw std::runtime_error("filegrid was not initialized! " + std::string(__FILE__) + ':' + numberTo(__LINE__)); } FileView& filegrid::getDataView(Grid& grid) { if (auto* prov = dynamic_cast(grid.getDataProvider())) return prov->getDataView(); throw std::runtime_error("filegrid was not initialized! " + std::string(__FILE__) + ':' + numberTo(__LINE__)); } namespace { //resolve circular linker dependencies void IconUpdater::loadIconsAsynchronously(wxEvent& event) //loads all (not yet) drawn icons { std::vector> prefetchLoad; provLeft_ .getUnbufferedIconsForPreload(prefetchLoad); provRight_.getUnbufferedIconsForPreload(prefetchLoad); //make sure least-important prefetch rows are inserted first into workload (=> processed last) //priority index nicely considers both grids at the same time! std::sort(prefetchLoad.begin(), prefetchLoad.end(), [](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; }); //last inserted items are processed first in icon buffer: std::vector newLoad; for (const auto& [priority, filePath] : prefetchLoad) newLoad.push_back(filePath); provRight_.updateNewAndGetUnbufferedIcons(newLoad); provLeft_ .updateNewAndGetUnbufferedIcons(newLoad); iconBuffer_.setWorkload(newLoad); if (newLoad.empty()) //let's only pay for IconUpdater while needed stop(); } } void filegrid::setupIcons(Grid& gridLeft, Grid& gridCenter, Grid& gridRight, bool showFileIcons, IconBuffer::IconSize sz) { auto* provLeft = dynamic_cast(gridLeft .getDataProvider()); auto* provRight = dynamic_cast(gridRight.getDataProvider()); if (provLeft && provRight) { auto iconMgr = makeSharedRef(*provLeft, *provRight, sz, showFileIcons); provLeft ->setIconManager(iconMgr); const int newRowHeight = std::max(iconMgr.ref().getIconWxsize(), gridLeft.getMainWin().GetCharHeight()) + dipToWxsize(1); //add some space gridLeft .setRowHeight(newRowHeight); gridCenter.setRowHeight(newRowHeight); gridRight .setRowHeight(newRowHeight); } else assert(false); } void filegrid::setItemPathForm(Grid& grid, ItemPathFormat fmt) { if (auto* provLeft = dynamic_cast(grid.getDataProvider())) provLeft->setItemPathForm(fmt); else if (auto* provRight = dynamic_cast(grid.getDataProvider())) provRight->setItemPathForm(fmt); else assert(false); grid.Refresh(); } void filegrid::refresh(Grid& gridLeft, Grid& gridCenter, Grid& gridRight) { gridLeft .Refresh(); gridCenter.Refresh(); gridRight .Refresh(); } void filegrid::setScrollMaster(Grid& grid) { if (auto prov = dynamic_cast(grid.getDataProvider())) if (auto evtMgr = prov->getEventManager()) { evtMgr->setScrollMaster(grid); return; } assert(false); } void filegrid::setNavigationMarker(Grid& gridLeft, zen::Grid& gridRight, std::unordered_set&& markedFilesAndLinks, std::unordered_set&& markedContainer) { if (auto grid = dynamic_cast(gridLeft.getDataProvider())) grid->setNavigationMarker(std::move(markedFilesAndLinks), std::move(markedContainer)); else assert(false); gridLeft .Refresh(); gridRight.Refresh(); } void filegrid::setViewType(Grid& gridCenter, GridViewType vt) { if (auto prov = dynamic_cast(gridCenter.getDataProvider())) prov->setViewType(vt); else assert(false); gridCenter.Refresh(); } wxImage fff::getSyncOpImage(SyncOperation syncOp) { switch (syncOp) //evaluate comparison result and sync direction { case SO_CREATE_LEFT: return loadImage("so_create_left_sicon"); case SO_CREATE_RIGHT: return loadImage("so_create_right_sicon"); case SO_DELETE_LEFT: return loadImage("so_delete_left_sicon"); case SO_DELETE_RIGHT: return loadImage("so_delete_right_sicon"); case SO_MOVE_LEFT_FROM: return loadImage("so_move_left_source_sicon"); case SO_MOVE_LEFT_TO: return loadImage("so_move_left_target_sicon"); case SO_MOVE_RIGHT_FROM: return loadImage("so_move_right_source_sicon"); case SO_MOVE_RIGHT_TO: return loadImage("so_move_right_target_sicon"); case SO_OVERWRITE_LEFT: return loadImage("so_update_left_sicon"); case SO_OVERWRITE_RIGHT: return loadImage("so_update_right_sicon"); case SO_RENAME_LEFT: return loadImage("so_move_left_sicon"); case SO_RENAME_RIGHT: return loadImage("so_move_right_sicon"); case SO_DO_NOTHING: return loadImage("so_none_sicon"); case SO_EQUAL: return loadImage("cat_equal_sicon"); case SO_UNRESOLVED_CONFLICT: return loadImage("cat_conflict_small"); } assert(false); return wxNullImage; } wxImage fff::getCmpResultImage(CompareFileResult cmpResult) { switch (cmpResult) { case FILE_RENAMED: //similar to both "equal" and "conflict" case FILE_EQUAL: return loadImage("cat_equal_sicon"); case FILE_LEFT_ONLY: return loadImage("cat_left_only_sicon"); case FILE_RIGHT_ONLY: return loadImage("cat_right_only_sicon"); case FILE_LEFT_NEWER: return loadImage("cat_left_newer_sicon"); case FILE_RIGHT_NEWER: return loadImage("cat_right_newer_sicon"); case FILE_DIFFERENT_CONTENT: return loadImage("cat_different_sicon"); case FILE_TIME_INVALID: case FILE_CONFLICT: return loadImage("cat_conflict_small"); } assert(false); return wxNullImage; }