438 lines
19 KiB
C++
438 lines
19 KiB
C++
// *****************************************************************************
|
|
// * 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 "rename_dlg.h"
|
|
#include <chrono>
|
|
//#include <zen/file_path.h>
|
|
#include <wx/valtext.h>
|
|
#include <wx+/window_layout.h>
|
|
#include <wx+/image_resources.h>
|
|
#include "gui_generated.h"
|
|
#include "../base/multi_rename.h"
|
|
|
|
|
|
using namespace zen;
|
|
using namespace fff;
|
|
|
|
|
|
namespace
|
|
{
|
|
enum class ColumnTypeRename
|
|
{
|
|
oldName,
|
|
newName,
|
|
};
|
|
|
|
|
|
class GridDataRename : public GridData
|
|
{
|
|
public:
|
|
GridDataRename(const std::vector<std::wstring>& fileNamesOld,
|
|
const SharedRef<const RenameBuf>& renameBuf) :
|
|
fileNamesOld_(fileNamesOld),
|
|
renameBuf_(renameBuf) {}
|
|
|
|
bool updatePreview(std::wstring_view renamePhrase, size_t selectBegin, size_t selectEnd) //support polling
|
|
{
|
|
//normalize input: trim and adapt selection
|
|
{
|
|
const std::wstring_view renamePhraseTrm = trimCpy(renamePhrase);
|
|
|
|
if (selectBegin <= selectEnd && selectEnd <= renamePhrase.size())
|
|
{
|
|
selectBegin -= std::min(selectBegin, makeUnsigned(renamePhraseTrm.data() - renamePhrase.data())); //careful:
|
|
selectEnd -= std::min(selectEnd, makeUnsigned(renamePhraseTrm.data() - renamePhrase.data())); //avoid underflow
|
|
|
|
selectBegin = std::min(selectBegin, renamePhraseTrm.size());
|
|
selectEnd = std::min(selectEnd, renamePhraseTrm.size());
|
|
}
|
|
else
|
|
{
|
|
assert(false);
|
|
selectBegin = selectEnd = 0;
|
|
}
|
|
|
|
renamePhrase = renamePhraseTrm;
|
|
}
|
|
|
|
auto currentPhrase = std::make_tuple(renamePhrase, selectBegin, selectEnd);
|
|
if (currentPhrase != lastUsedPhrase_) //only update when needed
|
|
{
|
|
lastUsedPhrase_ = currentPhrase;
|
|
|
|
fileNamesNewSelectBefore_ = resolvePlaceholderPhrase(renamePhrase.substr(0, selectBegin), renameBuf_.ref());
|
|
fileNamesNewSelected_ = resolvePlaceholderPhrase(renamePhrase.substr(selectBegin, selectEnd - selectBegin), renameBuf_.ref());
|
|
fileNamesNewSelectAfter_ = resolvePlaceholderPhrase(renamePhrase.substr(selectEnd), renameBuf_.ref());
|
|
|
|
assert(fileNamesNewSelectBefore_.size() == fileNamesOld_.size());
|
|
assert(fileNamesNewSelected_ .size() == fileNamesOld_.size());
|
|
assert(fileNamesNewSelectAfter_ .size() == fileNamesOld_.size());
|
|
|
|
previewChangeTime_ = std::chrono::steady_clock::now();
|
|
return true;
|
|
}
|
|
else
|
|
return false;
|
|
}
|
|
|
|
std::vector<std::wstring> getNewNames() const { return resolvePlaceholderPhrase(std::get<std::wstring>(lastUsedPhrase_), renameBuf_.ref()); }
|
|
|
|
size_t getRowCount() const override { return fileNamesOld_.size(); }
|
|
|
|
std::wstring getValue(size_t row, ColumnType colType) const override
|
|
{
|
|
if (row < fileNamesOld_.size())
|
|
switch (static_cast<ColumnTypeRename>(colType))
|
|
{
|
|
case ColumnTypeRename::oldName:
|
|
return fileNamesOld_[row];
|
|
|
|
case ColumnTypeRename::newName:
|
|
return fileNamesNewSelectBefore_[row] + fileNamesNewSelected_[row] + fileNamesNewSelectAfter_[row];
|
|
}
|
|
return std::wstring();
|
|
}
|
|
|
|
void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override
|
|
{
|
|
//clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default
|
|
}
|
|
|
|
void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override
|
|
{
|
|
//draw border on right
|
|
clearArea(dc, {rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height}, wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW));
|
|
|
|
wxRect rectTmp = rect;
|
|
rectTmp.x += getColumnGapLeft();
|
|
rectTmp.width -= getColumnGapLeft() + dipToWxsize(1);
|
|
|
|
switch (static_cast<ColumnTypeRename>(colType))
|
|
{
|
|
case ColumnTypeRename::oldName:
|
|
drawCellText(dc, rectTmp, getValue(row, colType));
|
|
break;
|
|
|
|
case ColumnTypeRename::newName:
|
|
{
|
|
const std::wstring& fulltext = fileNamesNewSelectBefore_[row] + fileNamesNewSelected_[row] + fileNamesNewSelectAfter_[row];
|
|
//macOS: drawCellText() is not accurate for partial strings => draw full text + calculate deltas:
|
|
const wxSize extentBefore = dc.GetTextExtent(fileNamesNewSelectBefore_[row]);
|
|
const wxSize extentFullText = dc.GetTextExtent(fulltext);
|
|
|
|
drawCellText(dc, rectTmp, fulltext, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &extentFullText);
|
|
|
|
if (!fileNamesNewSelected_[row].empty()) //highlight text selection:
|
|
{
|
|
const wxSize extentBeforeAndSel = dc.GetTextExtent(fileNamesNewSelectBefore_[row] + fileNamesNewSelected_[row]);
|
|
|
|
const wxRect rectSel{rectTmp.x + extentBefore.GetWidth(),
|
|
rectTmp.y,
|
|
extentBeforeAndSel.GetWidth() - extentBefore.GetWidth(),
|
|
rectTmp.height};
|
|
|
|
clearArea(dc, rectSel, wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHT));
|
|
|
|
RecursiveDcClipper dummy(dc, rectSel);
|
|
|
|
wxDCTextColourChanger textColor(dc, wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHTTEXT)); //accessibility: always set both foreground AND background colors!
|
|
drawCellText(dc, rectTmp, fulltext, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &extentFullText); //draw everything: might fix partially cleared character
|
|
}
|
|
else //draw input cursor
|
|
if (showCursor_ || std::chrono::steady_clock::now() < previewChangeTime_ + std::chrono::milliseconds(400))
|
|
{
|
|
const wxRect rectLine{rectTmp.x + extentBefore.GetWidth(),
|
|
rectTmp.y,
|
|
dipToWxsize(1),
|
|
rectTmp.height};
|
|
clearArea(dc, rectLine, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override
|
|
{
|
|
// -> synchronize renderCell() <-> getBestSize()
|
|
return dc.GetTextExtent(getValue(row, colType)).GetWidth() + 2 * getColumnGapLeft() + dipToWxsize(1); //gap on left and right side + border
|
|
}
|
|
|
|
std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override { return std::wstring(); }
|
|
|
|
std::wstring getColumnLabel(ColumnType colType) const override
|
|
{
|
|
switch (static_cast<ColumnTypeRename>(colType))
|
|
{
|
|
case ColumnTypeRename::oldName:
|
|
return _("Old name");
|
|
case ColumnTypeRename::newName:
|
|
return _("New name");
|
|
}
|
|
//assert(false); may be ColumnType::none
|
|
return std::wstring();
|
|
}
|
|
|
|
void setCursorShown(bool show) { showCursor_ = show; }
|
|
|
|
private:
|
|
const std::vector<std::wstring> fileNamesOld_;
|
|
|
|
std::tuple<std::wstring /*renamePhrase*/, size_t /*selectBegin*/, size_t /*selectEnd*/> lastUsedPhrase_;
|
|
|
|
std::vector<std::wstring> fileNamesNewSelectBefore_{fileNamesOld_.size()};
|
|
std::vector<std::wstring> fileNamesNewSelected_ {fileNamesOld_.size()};
|
|
std::vector<std::wstring> fileNamesNewSelectAfter_ {fileNamesOld_.size()};
|
|
|
|
bool showCursor_ = false;
|
|
std::chrono::steady_clock::time_point previewChangeTime_ = std::chrono::steady_clock::now();
|
|
|
|
const SharedRef<const RenameBuf> renameBuf_;
|
|
};
|
|
|
|
|
|
class RenameDialog : public RenameDlgGenerated
|
|
{
|
|
public:
|
|
RenameDialog(wxWindow* parent, const std::vector<std::wstring>& fileNamesOld, std::vector<Zstring>& fileNamesNew);
|
|
|
|
private:
|
|
void onOkay (wxCommandEvent& event) override;
|
|
void onCancel(wxCommandEvent& event) override { EndModal(static_cast<int>(ConfirmationButton::cancel)); }
|
|
void onClose (wxCloseEvent& event) override { EndModal(static_cast<int>(ConfirmationButton::cancel)); }
|
|
|
|
void onLocalKeyEvent(wxKeyEvent& event);
|
|
|
|
void updatePreview()
|
|
{
|
|
const std::wstring renamePhrase = copyStringTo<std::wstring>(m_textCtrlNewName->GetValue());
|
|
|
|
long selectBegin = 0;
|
|
long selectEnd = 0;
|
|
m_textCtrlNewName->GetSelection(&selectBegin, &selectEnd);
|
|
|
|
assert(selectBegin == m_textCtrlNewName->GetInsertionPoint()); //apparently this is true for all Win/macOS/Linux
|
|
|
|
if (getDataView().updatePreview(renamePhrase, selectBegin, selectEnd))
|
|
m_gridRenamePreview->Refresh();
|
|
}
|
|
|
|
GridDataRename& getDataView()
|
|
{
|
|
if (auto* prov = dynamic_cast<GridDataRename*>(m_gridRenamePreview->getDataProvider()))
|
|
return *prov;
|
|
throw std::runtime_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] m_gridRenamePreview was not initialized.");
|
|
}
|
|
|
|
wxTimer timer_; //poll for text selection changes
|
|
wxTimer timerCursor_; //second timer just for cursor blinking
|
|
|
|
//output-only parameters:
|
|
std::vector<Zstring>& fileNamesNewOut_;
|
|
};
|
|
|
|
|
|
RenameDialog::RenameDialog(wxWindow* parent,
|
|
const std::vector<std::wstring>& fileNamesOld,
|
|
std::vector<Zstring>& fileNamesNew) :
|
|
RenameDlgGenerated(parent),
|
|
fileNamesNewOut_(fileNamesNew)
|
|
{
|
|
setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel));
|
|
|
|
setMainInstructionFont(*m_staticTextHeader);
|
|
|
|
setImage(*m_bitmapRename, loadImage("rename"));
|
|
|
|
m_staticTextHeader->SetLabelText(_P("Do you really want to rename the following item?",
|
|
"Do you really want to rename the following %x items?", fileNamesOld.size()));
|
|
|
|
m_buttonOK->SetLabelText(wxControl::RemoveMnemonics(_("&Rename"))); //no access key needed: use ENTER!
|
|
|
|
auto [renamePhrase, renameBuf] = getPlaceholderPhrase(fileNamesOld);
|
|
const std::wstring renamePhraseOld = renamePhrase; //save copy *before* trimming
|
|
|
|
trim(renamePhrase); //leading/trailing whitespace makes no sense for file names
|
|
|
|
|
|
std::wstring placeholders;
|
|
for (const wchar_t c : renamePhrase)
|
|
if (isRenamePlaceholderChar(c))
|
|
placeholders += c;
|
|
|
|
m_staticTextPlaceholderDescription->SetLabelText(placeholders + L": " + m_staticTextPlaceholderDescription->GetLabelText());
|
|
|
|
//-----------------------------------------------------------
|
|
m_gridRenamePreview->setDataProvider(std::make_shared<GridDataRename>(fileNamesOld, renameBuf));
|
|
m_gridRenamePreview->showRowLabel(false);
|
|
m_gridRenamePreview->setRowHeight(m_gridRenamePreview->getMainWin().GetCharHeight() + dipToWxsize(1) /*extra space*/);
|
|
|
|
//-----------------------------------------------------------
|
|
if (fileNamesOld.size() > 1) //calculate reasonable default preview grid size
|
|
{
|
|
//quick and dirty: get (likely) maximum string width while avoiding excessive wxDC::GetTextExtent() calls
|
|
std::vector<std::wstring> longNames = fileNamesOld;
|
|
if (longNames.size() > 10) //find the 10 longest strings according to std::wstring::size()
|
|
{
|
|
std::nth_element(longNames.begin(), longNames.begin() + 9, longNames.end(),
|
|
/**/[](const std::wstring& lhs, const std::wstring& rhs) { return lhs.size() > rhs.size(); }); //complexity: O(n)
|
|
longNames.resize(10);
|
|
}
|
|
|
|
wxInfoDC infoDc(m_gridRenamePreview);
|
|
infoDc.SetFont(m_gridRenamePreview->GetFont()); //the font parameter of GetTextExtent() is not evaluated on OS X, wxWidgets 2.9.5, so apply it to the DC directly!
|
|
|
|
int maxStringWidth = 0;
|
|
for (const std::wstring& str : longNames)
|
|
maxStringWidth = std::max(maxStringWidth, infoDc.GetTextExtent(str).GetWidth());
|
|
|
|
const int defaultColWidthOld = maxStringWidth + 2 * GridData::getColumnGapLeft() + dipToWxsize(1) /*border*/ + dipToWxsize(10) /*extra space: less cramped*/;
|
|
const int defaultColWidthNew = maxStringWidth + 2 * GridData::getColumnGapLeft() + dipToWxsize(1) /*border*/ + dipToWxsize(50) /*extra space: for longer new name*/;
|
|
|
|
m_gridRenamePreview->setColumnConfig(
|
|
{
|
|
{static_cast<ColumnType>(ColumnTypeRename::oldName), defaultColWidthOld, 0, true}, //"old name" is fixed =>
|
|
{static_cast<ColumnType>(ColumnTypeRename::newName), -defaultColWidthOld, 1, true}, //stretch "new name" only
|
|
});
|
|
|
|
const int previewDefaultWidth = std::min(defaultColWidthOld + defaultColWidthNew + dipToWxsize(25), //scroll bar width (guess!)
|
|
dipToWxsize(900));
|
|
|
|
const int previewDefaultHeight = std::min(m_gridRenamePreview->getColumnLabelHeight() +
|
|
static_cast<int>(fileNamesOld.size()) * m_gridRenamePreview->getRowHeight(),
|
|
dipToWxsize(400));
|
|
|
|
m_gridRenamePreview->SetMinSize({previewDefaultWidth, previewDefaultHeight});
|
|
|
|
m_staticTextHeader->Wrap(std::max(previewDefaultWidth, dipToWxsize(400))); //needs to be reapplied after SetLabel()
|
|
}
|
|
else //renaming single file
|
|
{
|
|
m_gridRenamePreview ->Hide();
|
|
m_staticlinePreview ->Hide();
|
|
m_staticTextPlaceholderDescription->Hide();
|
|
|
|
wxInfoDC infoDc(m_textCtrlNewName);
|
|
infoDc.SetFont(m_textCtrlNewName->GetFont()); //the font parameter of GetTextExtent() is not evaluated on OS X, wxWidgets 2.9.5, so apply it to the DC directly!
|
|
|
|
const int textCtrlDefaultWidth = std::min(infoDc.GetTextExtent(renamePhrase).GetWidth() + 20 /*borders (non-DIP!)*/ +
|
|
dipToWxsize(50) /*extra space: for longer new name*/,
|
|
dipToWxsize(900));
|
|
m_textCtrlNewName->SetMinSize({textCtrlDefaultWidth, -1});
|
|
|
|
m_staticTextHeader->Wrap(std::max(textCtrlDefaultWidth, dipToWxsize(400))); //needs to be reapplied after SetLabel()
|
|
}
|
|
//-----------------------------------------------------------
|
|
|
|
m_textCtrlNewName->Bind(wxEVT_COMMAND_TEXT_UPDATED, [this, renamePhraseOld, needPreview = fileNamesOld.size() > 1](wxCommandEvent& event)
|
|
{
|
|
if (needPreview)
|
|
updatePreview(); //(almost?) redundant, considering timer_ is doing the same!?
|
|
|
|
//disable OK button, until user changes input
|
|
const std::wstring renamePhraseNew = trimCpy(copyStringTo<std::wstring>(m_textCtrlNewName->GetValue()));
|
|
m_buttonOK->Enable(!renamePhraseNew.empty() && renamePhraseNew != renamePhraseOld); //supports polling
|
|
});
|
|
|
|
wxTextValidator inputValidator(wxFILTER_EXCLUDE_CHAR_LIST);
|
|
|
|
inputValidator.SetCharExcludes(L"/\\"); //let's not silently forbid "fileNameForbiddenChars", but let it fail explicitly!
|
|
m_textCtrlNewName->SetValidator(inputValidator);
|
|
m_textCtrlNewName->SetValue(renamePhrase); //SetValue() generates a text change event, unlike ChangeValue()
|
|
|
|
|
|
if (fileNamesOld.size() > 1)
|
|
{
|
|
timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { updatePreview(); }); //poll to detect text selection changes
|
|
timer_.Start(100 /*unit: [ms]*/);
|
|
|
|
timerCursor_.Bind(wxEVT_TIMER, [this, show = true](wxTimerEvent& event) mutable //trigger blinking cursor
|
|
{
|
|
getDataView().setCursorShown(show);
|
|
m_gridRenamePreview->Refresh();
|
|
show = !show;
|
|
});
|
|
timerCursor_.Start(wxCaret::GetBlinkTime() /*unit: [ms]*/);
|
|
}
|
|
|
|
Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events
|
|
|
|
//-----------------------------------------------------------
|
|
GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize()
|
|
#ifdef __WXGTK3__
|
|
Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088
|
|
//Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404
|
|
#endif
|
|
Center(); //apply *after* dialog size change!
|
|
|
|
m_textCtrlNewName->SetFocus(); //[!] required *before* SetSelection() on wxGTK
|
|
//-----------------------------------------------------------
|
|
|
|
//macOS issue: the *whole* text control is selected by default, unless we SetSelection() *after* wxDialog::Show()!
|
|
CallAfter([this, nameCount = fileNamesOld.size(), renamePhrase = renamePhrase]
|
|
{
|
|
//pre-select name part that user will most likely change
|
|
//assert(contains(renamePhrase, L'\u2776') == nameCount > 1); -> fails, if user selects same item on left and right grid
|
|
auto it = std::find_if(renamePhrase.begin(), renamePhrase.end(), isRenamePlaceholderChar); //❶
|
|
if (it == renamePhrase.end())
|
|
it = findLast(renamePhrase.begin(), renamePhrase.end(), L'.'); //select everything except file extension
|
|
|
|
if (it == renamePhrase.end())
|
|
m_textCtrlNewName->SelectAll();
|
|
else
|
|
{
|
|
const long selectEnd = static_cast<long>(it - renamePhrase.begin());
|
|
m_textCtrlNewName->SetSelection(0, selectEnd);
|
|
}
|
|
|
|
updatePreview(); //consider new selection
|
|
});
|
|
}
|
|
|
|
|
|
void RenameDialog::onLocalKeyEvent(wxKeyEvent& event)
|
|
{
|
|
switch (event.GetKeyCode())
|
|
{
|
|
case WXK_RETURN:
|
|
case WXK_NUMPAD_ENTER:
|
|
if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter
|
|
{
|
|
wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED);
|
|
m_buttonOK->Command(dummy); //simulate click
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
event.Skip();
|
|
}
|
|
|
|
|
|
void RenameDialog::onOkay(wxCommandEvent& event)
|
|
{
|
|
updatePreview(); //ensure GridDataRename::getNewNames() is current
|
|
|
|
fileNamesNewOut_.clear();
|
|
for (const std::wstring& newName : getDataView().getNewNames())
|
|
fileNamesNewOut_.push_back(utfTo<Zstring>(newName));
|
|
|
|
EndModal(static_cast<int>(ConfirmationButton::accept));
|
|
}
|
|
}
|
|
|
|
|
|
ConfirmationButton fff::showRenameDialog(wxWindow* parent,
|
|
const std::vector<Zstring>& fileNamesOld,
|
|
std::vector<Zstring>& fileNamesNew)
|
|
{
|
|
std::vector<std::wstring> namesOld;
|
|
for (const Zstring& name : fileNamesOld)
|
|
namesOld.push_back(utfTo<std::wstring>(getUnicodeNormalForm(name))); //[!] don't care about Normalization form differences!
|
|
|
|
RenameDialog dlg(parent, namesOld, fileNamesNew);
|
|
return static_cast<ConfirmationButton>(dlg.ShowModal());
|
|
}
|