// ***************************************************************************** // * 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 "tree_grid.h" #include #include #include #include #include #include #include #include #include #include #include "../icon_buffer.h" using namespace zen; using namespace fff; namespace { //let's NOT create wxWidgets objects statically: const int PERCENTAGE_BAR_WIDTH_DIP = 60; const int TREE_GRID_GAP_SIZE_DIP = 4; inline wxColor getColorPercentBorder () { return {198, 198, 198}; } inline wxColor getColorPercentBackground() { return {0xf8, 0xf8, 0xf8}; } Zstring getFolderPairName(const FolderPair& folder) { if (folder.hasEquivalentItemNames()) return folder.getItemName(); else return folder.getItemName() + Zstr(" | ") + folder.getItemName(); } } TreeView::TreeView(FolderComparison& folderCmp, const SortInfo& si) : currentSort_(si) { for (SharedRef& baseObj : folderCmp) //remove truly empty folder pairs as early as this: we want to distinguish single/multiple folder pair cases by looking at "folderCmp" if (!AFS::isNullPath(baseObj.ref().getAbstractPath()) || !AFS::isNullPath(baseObj.ref().getAbstractPath())) folderCmp_.push_back(baseObj.ptr()); } inline void TreeView::compressNode(Container& cont) //remove single-element sub-trees -> gain clarity + usability (call *after* inclusion check!!!) { if (cont.subDirs.empty()) //single files node cont.showFilesNode = false; #if 0 //let's not go overboard: empty folders should not be condensed => used for file exclusion filter; user expects to see them if (!cont.showFilesNode && //single dir node... cont.subDirs.size() == 1 && // !cont.subDirs[0].showFilesNode && //...that is empty cont.subDirs[0].subDirs.empty()) // cont.subDirs.clear(); #endif } template //(const FileSystemObject&) -> bool void TreeView::extractVisibleSubtree(ContainerObject& conObj, //in TreeView::Container& cont, //out Function pred) { auto getBytes = [](const FilePair& file) //MSVC screws up miserably if we put this lambda into std::for_each { #if 0 //give accumulated bytes the semantics of a sync preview? switch (getEffectiveSyncDir(file.getSyncOperation())) { case SyncDirection::none: break; case SyncDirection::left: return file.getFileSize(); case SyncDirection::right: return file.getFileSize(); } #endif //prefer file-browser semantics over sync preview (=> always show useful numbers, even for SyncDirection::none) //discussion: https://freefilesync.org/forum/viewtopic.php?t=1595 return std::max(file.isEmpty() ? 0 : file.getFileSize(), file.isEmpty() ? 0 : file.getFileSize()); }; for (FilePair& file : conObj.files()) if (pred(file)) { cont.bytesNet += getBytes(file); ++cont.itemCountNet; } for (SymlinkPair& symlink : conObj.symlinks()) if (pred(symlink)) ++cont.itemCountNet; cont.showFilesNode = cont.itemCountNet > 0; cont.bytesGross += cont.bytesNet; cont.itemCountGross += cont.itemCountNet; cont.subDirs.reserve(conObj.subfolders().size()); //avoid expensive reallocations! for (FolderPair& folder : conObj.subfolders()) { const bool included = pred(folder); cont.subDirs.emplace_back(); // auto& subDirCont = cont.subDirs.back(); TreeView::extractVisibleSubtree(folder, subDirCont, pred); if (included) ++subDirCont.itemCountGross; cont.bytesGross += subDirCont.bytesGross; cont.itemCountGross += subDirCont.itemCountGross; if (!included && subDirCont.subDirs.empty() && !subDirCont.showFilesNode) cont.subDirs.pop_back(); else { subDirCont.containerRef = std::static_pointer_cast(folder.shared_from_this()); compressNode(subDirCont); } } } namespace { //generate "nice" percentage numbers which precisely add up to 100 void calcPercentage(std::vector>& workList) { uint64_t bytesTotal = 0; for (const auto& [bytes, percentOut] : workList) bytesTotal += bytes; if (bytesTotal == 0U) //this case doesn't work with the error minimizing algorithm below { for (auto& [bytes, percentOut] : workList) *percentOut = 0; return; } int remainingPercent = 100; for (auto& [bytes, percentOut] : workList) { *percentOut = static_cast(bytes * 100U / bytesTotal); //round down remainingPercent -= *percentOut; } assert(remainingPercent >= 0); assert(remainingPercent < std::ssize(workList)); //distribute remaining percent so that overall error is minimized as much as possible: remainingPercent = std::min(remainingPercent, static_cast(workList.size())); if (remainingPercent > 0) { std::nth_element(workList.begin(), workList.begin() + remainingPercent - 1, workList.end(), [bytesTotal](const std::pair& lhs, const std::pair& rhs) { return lhs.first * 100U % bytesTotal > rhs.first * 100U % bytesTotal; }); std::for_each(workList.begin(), workList.begin() + remainingPercent, [&](std::pair& pair) { ++*pair.second; }); } } } template struct TreeView::LessShortName { bool operator()(const TreeLine& lhs, const TreeLine& rhs) const { //files last (irrespective of sort direction) if (lhs.type == NodeType::files) return false; else if (rhs.type == NodeType::files) return true; if (lhs.type != rhs.type) // return lhs.type < rhs.type; //shouldn't happen! root nodes not mixed with files or directories switch (lhs.type) { case NodeType::root: return makeSortDirection(LessNaturalSort() /*even on Linux*/, std::bool_constant())(utfTo(static_cast(lhs.node)->displayName), utfTo(static_cast(rhs.node)->displayName)); case NodeType::folder: { const auto* folderL = static_cast(lhs.node->containerRef.lock().get()); const auto* folderR = static_cast(rhs.node->containerRef.lock().get()); if (!folderL) return false; else if (!folderR) return true; return makeSortDirection(LessNaturalSort(), std::bool_constant())(getFolderPairName(*folderL), getFolderPairName(*folderR)); } case NodeType::files: break; } assert(false); return false; //:= all equal } }; template void TreeView::sortSingleLevel(std::vector& items, ColumnTypeOverview columnType) { auto getBytes = [](const TreeLine& line) -> uint64_t { switch (line.type) { case NodeType::root: case NodeType::folder: return line.node->bytesGross; case NodeType::files: return line.node->bytesNet; } assert(false); return 0U; }; auto getCount = [](const TreeLine& line) -> int { switch (line.type) { case NodeType::root: case NodeType::folder: return line.node->itemCountGross; case NodeType::files: return line.node->itemCountNet; } assert(false); return 0; }; const auto lessBytes = [&](const TreeLine& lhs, const TreeLine& rhs) { return getBytes(lhs) < getBytes(rhs); }; const auto lessCount = [&](const TreeLine& lhs, const TreeLine& rhs) { return getCount(lhs) < getCount(rhs); }; switch (columnType) { case ColumnTypeOverview::folder: std::sort(items.begin(), items.end(), LessShortName()); break; case ColumnTypeOverview::itemCount: std::sort(items.begin(), items.end(), makeSortDirection(lessCount, std::bool_constant())); break; case ColumnTypeOverview::bytes: std::sort(items.begin(), items.end(), makeSortDirection(lessBytes, std::bool_constant())); break; } } void TreeView::getChildren(const Container& cont, unsigned int level, std::vector& output) { output.clear(); output.reserve(cont.subDirs.size() + 1); //keep pointers in "workList" valid std::vector> workList; for (const Container& subDir : cont.subDirs) { output.push_back({level, 0, &subDir, NodeType::folder}); workList.emplace_back(subDir.bytesGross, &output.back().percent); } if (cont.showFilesNode) { output.push_back({level, 0, &cont, NodeType::files}); workList.emplace_back(cont.bytesNet, &output.back().percent); } calcPercentage(workList); if (currentSort_.ascending) sortSingleLevel(output, currentSort_.sortCol); else sortSingleLevel(output, currentSort_.sortCol); } void TreeView::applySubView(std::vector&& newView) { //preserve current node expansion status auto getContainer = [](const TreeView::TreeLine& tl) -> const ContainerObject* { switch (tl.type) { case NodeType::root: case NodeType::folder: return tl.node->containerRef.lock().get(); case NodeType::files: break; //none!!! } return nullptr; }; std::unordered_set expandedNodes; if (!flatTree_.empty()) { auto it = flatTree_.begin(); for (auto itNext = flatTree_.begin() + 1; itNext != flatTree_.end(); ++itNext, ++it) if (it->level < itNext->level) if (auto conObj = getContainer(*it)) expandedNodes.insert(conObj); } //update view on full data folderCmpView_.swap(newView); //newView may be an alias for folderCmpView! see sorting! //set default flat tree flatTree_.clear(); if (folderCmp_.size() == 1) //single folder pair case (empty pairs were already removed!) do NOT use folderCmpView for this check! { if (!folderCmpView_.empty()) //possibly empty! getChildren(folderCmpView_[0], 0, flatTree_); //do not show root } else { //following is almost identical with TreeView::getChildren(): // however we *cannot* reuse code here since "std::vector" is not a "Container"! flatTree_.reserve(folderCmpView_.size()); //keep pointers in "workList" valid std::vector> workList; for (const RootNodeImpl& root : folderCmpView_) { flatTree_.push_back({0, 0, &root, NodeType::root}); workList.emplace_back(root.bytesGross, &flatTree_.back().percent); } calcPercentage(workList); if (currentSort_.ascending) sortSingleLevel(flatTree_, currentSort_.sortCol); else sortSingleLevel(flatTree_, currentSort_.sortCol); } //restore node expansion status for (size_t row = 0; row < flatTree_.size(); ++row) //flatTree size changes during loop! { const TreeLine& line = flatTree_[row]; if (auto conObj = getContainer(line)) if (expandedNodes.contains(conObj)) { std::vector newLines; getChildren(*line.node, line.level + 1, newLines); flatTree_.insert(flatTree_.begin() + row + 1, newLines.begin(), newLines.end()); } } } template void TreeView::updateView(Predicate pred) { //update view on full data std::vector newView; newView.reserve(folderCmp_.size()); //avoid expensive reallocations! for (const std::weak_ptr& baseObjRef : folderCmp_) if (BaseFolderPair* baseObj = baseObjRef.lock().get()) { newView.emplace_back(); RootNodeImpl& root = newView.back(); this->extractVisibleSubtree(*baseObj, root, pred); //"this->" is bogus for a static method, but GCC screws this one up //warning: the following lines are almost 1:1 copy from extractVisibleSubtree, adapted for BaseFolderPair: if (root.subDirs.empty() && !root.showFilesNode) newView.pop_back(); else { root.containerRef = baseObjRef; root.displayName = getShortDisplayNameForFolderPair(baseObj->getAbstractPath(), baseObj->getAbstractPath()); this->compressNode(root); //"this->" required by two-pass lookup as enforced by GCC 4.7 } } lastViewFilterPred_ = pred; applySubView(std::move(newView)); } void TreeView::setSortDirection(ColumnTypeOverview colType, bool ascending) //apply permanently! { currentSort_ = SortInfo{colType, ascending}; //reapply current view applySubView(std::move(folderCmpView_)); } TreeView::NodeStatus TreeView::getStatus(size_t row) const { if (row < flatTree_.size()) { if (row + 1 < flatTree_.size() && flatTree_[row + 1].level > flatTree_[row].level) return NodeStatus::expanded; //it's either reduced or empty switch (flatTree_[row].type) { case NodeType::root: case NodeType::folder: return flatTree_[row].node->showFilesNode || !flatTree_[row].node->subDirs.empty() ? NodeStatus::reduced : NodeStatus::empty; case NodeType::files: return NodeStatus::empty; } } return NodeStatus::empty; } void TreeView::expandNode(size_t row) { if (getStatus(row) != NodeStatus::reduced) { assert(false); return; } if (row < flatTree_.size()) { std::vector newLines; switch (flatTree_[row].type) { case NodeType::root: case NodeType::folder: getChildren(*flatTree_[row].node, flatTree_[row].level + 1, newLines); break; case NodeType::files: break; } flatTree_.insert(flatTree_.begin() + row + 1, newLines.begin(), newLines.end()); } } void TreeView::reduceNode(size_t row) { if (row < flatTree_.size()) { const unsigned int parentLevel = flatTree_[row].level; bool done = false; flatTree_.erase(std::remove_if(flatTree_.begin() + row + 1, flatTree_.end(), [&](const TreeLine& line) -> bool { if (done) return false; if (line.level > parentLevel) return true; else { done = true; return false; } }), flatTree_.end()); } } ptrdiff_t TreeView::getParent(size_t row) const { if (row < flatTree_.size()) { const auto level = flatTree_[row].level; while (row-- > 0) if (flatTree_[row].level < level) return row; } return -1; } void TreeView::applyDifferenceFilter(bool showExcluded, bool leftOnlyFilesActive, bool rightOnlyFilesActive, bool leftNewerFilesActive, bool rightNewerFilesActive, bool differentFilesActive, bool equalFilesActive, bool conflictFilesActive) { updateView([showExcluded, //make sure the predicate can be stored safely! leftOnlyFilesActive, rightOnlyFilesActive, leftNewerFilesActive, rightNewerFilesActive, differentFilesActive, equalFilesActive, conflictFilesActive](const FileSystemObject& fsObj) -> bool { if (!fsObj.isActive() && !showExcluded) return false; switch (fsObj.getCategory()) { case FILE_LEFT_ONLY: return leftOnlyFilesActive; case FILE_RIGHT_ONLY: return rightOnlyFilesActive; case FILE_LEFT_NEWER: return leftNewerFilesActive; case FILE_RIGHT_NEWER: return rightNewerFilesActive; case FILE_DIFFERENT_CONTENT: return differentFilesActive; case FILE_EQUAL: return equalFilesActive; case FILE_RENAMED: case FILE_CONFLICT: case FILE_TIME_INVALID: return conflictFilesActive; } assert(false); return true; }); } void TreeView::applyActionFilter(bool showExcluded, bool syncCreateLeftActive, bool syncCreateRightActive, bool syncDeleteLeftActive, bool syncDeleteRightActive, bool syncDirOverwLeftActive, bool syncDirOverwRightActive, bool syncDirNoneActive, bool syncEqualActive, bool conflictFilesActive) { updateView([showExcluded, //make sure the predicate can be stored safely! syncCreateLeftActive, syncCreateRightActive, syncDeleteLeftActive, syncDeleteRightActive, syncDirOverwLeftActive, syncDirOverwRightActive, syncDirNoneActive, syncEqualActive, conflictFilesActive](const FileSystemObject& fsObj) -> bool { if (!fsObj.isActive() && !showExcluded) return false; switch (fsObj.getSyncOperation()) { case SO_CREATE_LEFT: return syncCreateLeftActive; case SO_CREATE_RIGHT: return syncCreateRightActive; case SO_DELETE_LEFT: return syncDeleteLeftActive; case SO_DELETE_RIGHT: return syncDeleteRightActive; case SO_OVERWRITE_RIGHT: case SO_RENAME_RIGHT: case SO_MOVE_RIGHT_FROM: case SO_MOVE_RIGHT_TO: return syncDirOverwRightActive; case SO_OVERWRITE_LEFT: case SO_RENAME_LEFT: case SO_MOVE_LEFT_FROM: case SO_MOVE_LEFT_TO: return syncDirOverwLeftActive; case SO_DO_NOTHING: return syncDirNoneActive; case SO_EQUAL: return syncEqualActive; case SO_UNRESOLVED_CONFLICT: return conflictFilesActive; } assert(false); return true; }); } std::unique_ptr TreeView::getLine(size_t row) const { if (row < flatTree_.size()) { const auto level = flatTree_[row].level; const int percent = flatTree_[row].percent; switch (flatTree_[row].type) { case NodeType::root: { const auto& root = *static_cast(flatTree_[row].node); if (BaseFolderPair* baseFolder = static_cast(root.containerRef.lock().get())) return std::make_unique(percent, root.bytesGross, root.itemCountGross, getStatus(row), *baseFolder, root.displayName); } break; case NodeType::folder: { const Container& contObj = *flatTree_[row].node; if (FolderPair* folder = static_cast(contObj.containerRef.lock().get())) return std::make_unique(percent, contObj.bytesGross, contObj.itemCountGross, level, getStatus(row), *folder); } break; case NodeType::files: { const auto& parentFolder = *flatTree_[row].node; if (ContainerObject* conObj = parentFolder.containerRef.lock().get()) { std::vector filesAndLinks; //lazy evaluation: recheck "lastViewFilterPred" again rather than buffer and bloat "lastViewFilterPred" for (FileSystemObject& fsObj : conObj->files()) if (lastViewFilterPred_(fsObj)) filesAndLinks.push_back(&fsObj); for (FileSystemObject& fsObj : conObj->symlinks()) if (lastViewFilterPred_(fsObj)) filesAndLinks.push_back(&fsObj); return std::make_unique(percent, parentFolder.bytesNet, parentFolder.itemCountNet, level, std::move(filesAndLinks)); } } break; } } return nullptr; } //########################################################################################################## namespace { wxColor getColorForLevel(size_t level) { switch (level % 12) { case 0: return {0xcc, 0xcc, 0xff}; case 1: return {0xcc, 0xff, 0xcc}; case 2: return {0xff, 0xff, 0x99}; case 3: return {0xdd, 0xdd, 0xdd}; case 4: return {0xff, 0xcc, 0xff}; case 5: return {0x99, 0xff, 0xcc}; case 6: return {0xcc, 0xcc, 0x99}; case 7: return {0xff, 0xcc, 0xcc}; case 8: return {0xcc, 0xff, 0x99}; case 9: return {0xff, 0xff, 0xcc}; case 10: return {0xcc, 0xff, 0xff}; case 11: return {0xff, 0xcc, 0x99}; } assert(false); return *wxBLACK; } class GridDataTree : private wxEvtHandler, public GridData { public: GridDataTree(Grid& grid) : widthNodeIcon_(screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small))), widthLevelStep_(widthNodeIcon_), widthNodeStatus_(screenToWxsize(loadImage("node_expanded").GetWidth())), rootIcon_(loadImage("root_folder", wxsizeToScreen(widthNodeIcon_))), grid_(grid) { grid.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event); }); grid.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onMouseLeft (event); }); grid.Bind(EVENT_GRID_MOUSE_LEFT_DOUBLE, [this](GridClickEvent& event) { onMouseLeftDouble(event); }); grid.Bind(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, [this](GridLabelClickEvent& event) { onGridLabelContext (event); }); grid.Bind(EVENT_GRID_COL_LABEL_MOUSE_LEFT, [this](GridLabelClickEvent& event) { onGridLabelLeftClick(event); }); } void setData(FolderComparison& folderCmp) { const TreeView::SortInfo sortCfg = treeDataView_.ref().getSortConfig(); //preserve! treeDataView_ = makeSharedRef(); //clear old data view first! avoid memory peaks! treeDataView_ = makeSharedRef(folderCmp, sortCfg); } const TreeView& getDataView() const { return treeDataView_.ref(); } /**/ TreeView& getDataView() { return treeDataView_.ref(); } void setShowPercentage(bool value) { showPercentBar_ = value; grid_.Refresh(); } bool getShowPercentage() const { return showPercentBar_; } private: size_t getRowCount() const override { return getDataView().rowsTotal(); } std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override { switch (static_cast(colType)) { case ColumnTypeOverview::folder: if (std::unique_ptr node = getDataView().getLine(row)) if (const TreeView::RootNode* root = dynamic_cast(node.get())) { const std::wstring& dirLeft = AFS::getDisplayPath(root->baseFolder.getAbstractPath()); const std::wstring& dirRight = AFS::getDisplayPath(root->baseFolder.getAbstractPath()); if (dirLeft.empty()) return dirRight; else if (dirRight.empty()) return dirLeft; return dirLeft + /*L' ' + EM_DASH + */ L'\n' + dirRight; } break; case ColumnTypeOverview::itemCount: case ColumnTypeOverview::bytes: break; } return std::wstring(); } std::wstring getValue(size_t row, ColumnType colType) const override { if (std::unique_ptr node = getDataView().getLine(row)) switch (static_cast(colType)) { case ColumnTypeOverview::folder: if (const TreeView::RootNode* root = dynamic_cast(node.get())) return root->displayName; else if (const TreeView::DirNode* dir = dynamic_cast(node.get())) return utfTo(getFolderPairName(dir->folder)); else if (dynamic_cast(node.get())) return _("Files"); break; case ColumnTypeOverview::itemCount: return formatNumber(node->itemCount_); case ColumnTypeOverview::bytes: return formatFilesizeShort(node->bytes_); } return std::wstring(); } void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) override { const auto colTypeTree = static_cast(colType); const wxRect rectInner = drawColumnLabelBackground(dc, rect, highlighted); wxRect rectRemain = rectInner; rectRemain.x += getColumnGapLeft(); rectRemain.width -= getColumnGapLeft(); drawColumnLabelText(dc, rectRemain, getColumnLabel(colType), enabled); if (const auto [sortCol, ascending] = getDataView().getSortConfig(); colTypeTree == sortCol) { const wxImage sortMarker = loadImage(ascending ? "sort_ascending" : "sort_descending"); drawBitmapRtlNoMirror(dc, enabled ? sortMarker : sortMarker.ConvertToDisabled(), rectInner, wxALIGN_CENTER_HORIZONTAL); } } void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override { if (enabled && selected) GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); else ; //clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default } enum class HoverAreaTree { node, item, }; 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= drawCellBorder(dc, rect); wxRect rectTmp = rect; // Partitioning: // ________________________________________________________________________________ // | space | gap | percentage bar | 2 x gap | node status | gap |icon | gap | rest | // -------------------------------------------------------------------------------- // -> synchronize renderCell() <-> getBestSize() <-> getMouseHover() if (static_cast(colType) == ColumnTypeOverview::folder) { if (std::unique_ptr node = getDataView().getLine(row)) { auto drawIcon = [&](wxImage icon, const 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(); drawBitmapRtlNoMirror(dc, icon, rectIcon, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); }; //consume space rectTmp.x += static_cast(node->level_) * widthLevelStep_; rectTmp.width -= static_cast(node->level_) * widthLevelStep_; rectTmp.x += gapSize_; rectTmp.width -= gapSize_; if (rectTmp.width > 0) { //percentage bar if (showPercentBar_) { wxRect areaPerc(rectTmp.x, rectTmp.y + dipToWxsize(2), percentageBarWidth_, rectTmp.height - dipToWxsize(4)); //clear background drawFilledRectangle(dc, areaPerc, getColorPercentBackground(), getColorPercentBorder(), dipToWxsize(1)); areaPerc.Deflate(dipToWxsize(1)); //inner area wxRect areaPercTmp = areaPerc; areaPercTmp.width = numeric::intDivRound(areaPercTmp.width * node->percent_, 100); clearArea(dc, areaPercTmp, getColorForLevel(node->level_)); wxDCTextColourChanger textColorPercent(dc, *wxBLACK); //accessibility: always set both foreground AND background colors! drawCellText(dc, areaPerc, numberTo(node->percent_) + L"%", wxALIGN_CENTER); rectTmp.x += percentageBarWidth_ + 2 * gapSize_; rectTmp.width -= percentageBarWidth_ + 2 * gapSize_; } if (rectTmp.width > 0) { //node status const bool drawMouseHover = static_cast(rowHover) == HoverAreaTree::node; switch (node->status_) { case TreeView::NodeStatus::expanded: drawIcon(loadImage(drawMouseHover ? "node_expanded_hover" : "node_expanded"), rectTmp, true /*drawActive*/); break; case TreeView::NodeStatus::reduced: drawIcon(loadImage(drawMouseHover ? "node_reduced_hover" : "node_reduced"), rectTmp, true /*drawActive*/); break; case TreeView::NodeStatus::empty: break; } rectTmp.x += widthNodeStatus_ + gapSize_; rectTmp.width -= widthNodeStatus_ + gapSize_; if (rectTmp.width > 0) { wxImage nodeIcon; bool isActive = true; //icon if (dynamic_cast(node.get())) nodeIcon = rootIcon_; else if (auto dir = dynamic_cast(node.get())) { nodeIcon = dirIcon_; isActive = dir->folder.isActive(); } else if (dynamic_cast(node.get())) nodeIcon = fileIcon_; drawIcon(nodeIcon, rectTmp, isActive); if (static_cast(rowHover) == HoverAreaTree::item) drawRectangleBorder(dc, rectTmp, mouseHighlightColor_, dipToWxsize(1)); rectTmp.x += widthNodeIcon_ + gapSize_; rectTmp.width -= widthNodeIcon_ + gapSize_; if (rectTmp.width > 0) { if (!isActive && (!enabled || !selected)) textColor.Set(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); } } } } } } else { int alignment = wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL; //have file size and item count right-justified (but don't change for RTL languages) if ((static_cast(colType) == ColumnTypeOverview::bytes || static_cast(colType) == ColumnTypeOverview::itemCount) && grid_.GetLayoutDirection() != wxLayout_RightToLeft) { rectTmp.width -= 2 * gapSize_; alignment = wxALIGN_RIGHT | wxALIGN_CENTER_VERTICAL; } else //left-justified { rectTmp.x += 2 * gapSize_; rectTmp.width -= 2 * gapSize_; } drawCellText(dc, rectTmp, getValue(row, colType), alignment); } } int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override { // -> synchronize renderCell() <-> getBestSize() <-> getMouseHover() if (static_cast(colType) == ColumnTypeOverview::folder) { if (std::unique_ptr node = getDataView().getLine(row)) return node->level_ * widthLevelStep_ + gapSize_ + (showPercentBar_ ? percentageBarWidth_ + 2 * gapSize_ : 0) + widthNodeStatus_ + gapSize_ + widthNodeIcon_ + gapSize_ + dc.GetTextExtent(getValue(row, colType)).GetWidth() + gapSize_; //additional gap from right else return 0; } else return 2 * gapSize_ + dc.GetTextExtent(getValue(row, colType)).GetWidth() + 2 * gapSize_; //include gap from right! } HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) override { if (static_cast(colType) == ColumnTypeOverview::folder) if (std::unique_ptr node = getDataView().getLine(row)) { const int nodeStatusXFirst = static_cast(node->level_) * widthLevelStep_ + gapSize_ + (showPercentBar_ ? percentageBarWidth_ + 2 * gapSize_ : 0); const int nodeStatusXLast = nodeStatusXFirst + widthNodeStatus_; // -> synchronize renderCell() <-> getBestSize() <-> getMouseHover() const int tolerance = dipToWxsize(5); if (nodeStatusXFirst - tolerance <= cellRelativePosX && cellRelativePosX < nodeStatusXLast + tolerance) return static_cast(HoverAreaTree::node); } return static_cast(HoverAreaTree::item); } std::wstring getColumnLabel(ColumnType colType) const override { switch (static_cast(colType)) { case ColumnTypeOverview::folder: return _("Folder"); case ColumnTypeOverview::itemCount: return _("Items"); case ColumnTypeOverview::bytes: return _("Size"); } return std::wstring(); } void onMouseLeft(GridClickEvent& event) { switch (static_cast(event.hoverArea_)) { case HoverAreaTree::node: switch (getDataView().getStatus(event.row_)) { case TreeView::NodeStatus::expanded: return reduceNode(event.row_); case TreeView::NodeStatus::reduced: return expandNode(event.row_); case TreeView::NodeStatus::empty: break; } break; case HoverAreaTree::item: break; } event.Skip(); } void onMouseLeftDouble(GridClickEvent& event) { switch (getDataView().getStatus(event.row_)) { case TreeView::NodeStatus::expanded: return reduceNode(event.row_); case TreeView::NodeStatus::reduced: return expandNode(event.row_); case TreeView::NodeStatus::empty: break; } event.Skip(); } void onKeyDown(wxKeyEvent& event) { 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; } const size_t rowCount = grid_.getRowCount(); if (rowCount == 0) return; const size_t row = grid_.getGridCursor(); if (event.ShiftDown()) ; else if (event.ControlDown()) ; else switch (keyCode) { case WXK_LEFT: case WXK_NUMPAD_LEFT: case WXK_NUMPAD_SUBTRACT: //https://docs.microsoft.com/en-us/previous-versions/windows/desktop/dnacc/guidelines-for-keyboard-user-interface-design#windows-shortcut-keys switch (getDataView().getStatus(row)) { case TreeView::NodeStatus::expanded: return reduceNode(row); case TreeView::NodeStatus::reduced: case TreeView::NodeStatus::empty: const int parentRow = getDataView().getParent(row); if (parentRow >= 0) grid_.setGridCursor(parentRow, GridEventPolicy::allow); break; } return; //swallow event case WXK_RIGHT: case WXK_NUMPAD_RIGHT: case WXK_NUMPAD_ADD: switch (getDataView().getStatus(row)) { case TreeView::NodeStatus::expanded: grid_.setGridCursor(std::min(rowCount - 1, row + 1), GridEventPolicy::allow); break; case TreeView::NodeStatus::reduced: return expandNode(row); case TreeView::NodeStatus::empty: break; } return; //swallow event } event.Skip(); } void onGridLabelContext(GridLabelClickEvent& event) { ContextMenu menu; //-------------------------------------------------------------------------------------------------------- menu.addCheckBox(_("Percentage"), [this] { setShowPercentage(!getShowPercentage()); }, getShowPercentage()); //-------------------------------------------------------------------------------------------------------- auto toggleColumn = [&](ColumnType ct) { auto colAttr = grid_.getColumnConfig(); Grid::ColAttributes* caFolderName = nullptr; Grid::ColAttributes* caToggle = nullptr; for (Grid::ColAttributes& ca : colAttr) if (ca.type == static_cast(ColumnTypeOverview::folder)) caFolderName = &ca; else if (ca.type == ct) caToggle = &ca; assert(caFolderName && caFolderName->stretch > 0 && caFolderName->visible); assert(caToggle && caToggle->stretch == 0); if (caFolderName && caToggle) { caToggle->visible = !caToggle->visible; //take width of newly visible column from stretched folder name column caFolderName->offset -= caToggle->visible ? caToggle->offset : -caToggle->offset; grid_.setColumnConfig(colAttr); } }; for (const Grid::ColAttributes& ca : grid_.getColumnConfig()) { menu.addCheckBox(getColumnLabel(ca.type), [ct = ca.type, toggleColumn] { toggleColumn(ct); }, ca.visible, ca.type != static_cast(ColumnTypeOverview::folder)); //do not allow user to hide file name column! } //-------------------------------------------------------------------------------------------------------- menu.addSeparator(); auto setDefaultColumns = [&] { setShowPercentage(overviewPanelShowPercentageDefault); grid_.setColumnConfig(convertColAttributes(getOverviewDefaultColAttribs(), getOverviewDefaultColAttribs())); }; menu.addItem(_("&Default"), setDefaultColumns, loadImage("reset_sicon")); //'&' -> reuse text from "default" buttons elsewhere //-------------------------------------------------------------------------------------------------------- menu.popup(grid_, {event.mousePos_.x, grid_.getColumnLabelHeight()}); //event.Skip(); } void onGridLabelLeftClick(GridLabelClickEvent& event) { const auto colTypeTree = static_cast(event.colType_); bool sortAscending = getDefaultSortDirection(colTypeTree); const auto [sortCol, ascending] = getDataView().getSortConfig(); if (sortCol == colTypeTree) sortAscending = !ascending; getDataView().setSortDirection(colTypeTree, sortAscending); grid_.Refresh(); //just in case, but setSortDirection() should not change grid size grid_.clearSelection(GridEventPolicy::allow); } void expandNode(size_t row) { getDataView().expandNode(row); grid_.Refresh(); //implicitly clears selection (changed row count after expand) grid_.setGridCursor(row, GridEventPolicy::allow); //grid_.autoSizeColumns(); -> doesn't look as good as expected } void reduceNode(size_t row) { getDataView().reduceNode(row); grid_.Refresh(); grid_.setGridCursor(row, GridEventPolicy::allow); } SharedRef treeDataView_ = makeSharedRef(); const int gapSize_ = dipToWxsize(TREE_GRID_GAP_SIZE_DIP); const int percentageBarWidth_ = dipToWxsize(PERCENTAGE_BAR_WIDTH_DIP); const wxImage fileIcon_ = IconBuffer::genericFileIcon(IconBuffer::IconSize::small); const wxImage dirIcon_ = IconBuffer::genericDirIcon (IconBuffer::IconSize::small); const int widthNodeIcon_; const int widthLevelStep_; const int widthNodeStatus_; const wxImage rootIcon_; const wxColor mouseHighlightColor_ = enhanceContrast(*wxBLUE, //primarily needed for dark mode! wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 5 /*contrastRatioMin*/); //W3C recommends >= 4.5 Grid& grid_; bool showPercentBar_ = true; }; } void treegrid::init(Grid& grid) { grid.setDataProvider(std::make_shared(grid)); grid.showRowLabel(false); const int rowHeight = std::max(screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small)) + dipToWxsize(2), //1 extra pixel on top/bottom; dearly needed on OS X! grid.getMainWin().GetCharHeight()); //seems to already include 3 margin pixels on top/bottom (consider percentage area) grid.setRowHeight(rowHeight); } void treegrid::setData(zen::Grid& grid, FolderComparison& folderCmp) { if (auto* prov = dynamic_cast(grid.getDataProvider())) return prov->setData(folderCmp); throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] treegrid was not initialized."); } TreeView& treegrid::getDataView(Grid& grid) { if (auto* prov = dynamic_cast(grid.getDataProvider())) return prov->getDataView(); throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] treegrid was not initialized."); } void treegrid::setShowPercentage(Grid& grid, bool value) { if (auto* prov = dynamic_cast(grid.getDataProvider())) prov->setShowPercentage(value); else assert(false); } bool treegrid::getShowPercentage(const Grid& grid) { if (auto* prov = dynamic_cast(grid.getDataProvider())) return prov->getShowPercentage(); assert(false); return true; }