Skip to content

Commit 32c8a39

Browse files
authored
Add ability to sort save files by name or date (#9793)
1 parent dba41eb commit 32c8a39

File tree

6 files changed

+304
-63
lines changed

6 files changed

+304
-63
lines changed

src/engine/tools.h

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#include <functional>
3131
#include <iomanip>
3232
#include <limits>
33+
#include <locale>
3334
#include <optional>
3435
#include <sstream>
3536
#include <string>
@@ -106,6 +107,35 @@ namespace fheroes2
106107
// Appends the given modifier to the end of the given string (e.g. "Coliseum +2")
107108
void appendModifierToString( std::string & str, const int mod );
108109

110+
// Performs case-insensitive string comparison, suitable for string sorting purposes. Returns true if the first parameter is
111+
// "less than" the second, otherwise returns false.
112+
template <typename CharType>
113+
bool compareStringsCaseInsensitively( const std::basic_string<CharType> & lhs, const std::basic_string<CharType> & rhs )
114+
{
115+
typename std::basic_string<CharType>::const_iterator li = lhs.begin();
116+
typename std::basic_string<CharType>::const_iterator ri = rhs.begin();
117+
118+
const std::locale currentGlobalLocale;
119+
120+
while ( li != lhs.end() && ri != rhs.end() ) {
121+
const CharType lc = std::tolower( *li, currentGlobalLocale );
122+
const CharType rc = std::tolower( *ri, currentGlobalLocale );
123+
124+
if ( lc < rc ) {
125+
return true;
126+
}
127+
if ( lc > rc ) {
128+
return false;
129+
}
130+
// The characters are "equal", so proceed to checking the next pair
131+
++li;
132+
++ri;
133+
}
134+
135+
// We have reached the end of one (or both) of the strings, the first parameter is considered "less than" the second if it is shorter
136+
return li == lhs.end() && ri != rhs.end();
137+
}
138+
109139
// Performs a checked conversion of an integer value of type From to an integer type To. Returns an empty std::optional<To> if
110140
// the source value does not fit into the target type.
111141
template <typename To, typename From, std::enable_if_t<std::is_integral_v<To> && std::is_integral_v<From>, bool> = true>

src/fheroes2/dialog/dialog_selectfile.cpp

Lines changed: 192 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,18 @@ namespace
228228
// Do nothing.
229229
}
230230

231+
void sortMapInfos( MapsFileInfoList & mapInfos )
232+
{
233+
const SaveFileSortingMethod sortType = Settings::Get().getSaveFileSortingMethod();
234+
if ( sortType == SaveFileSortingMethod::FILENAME ) {
235+
std::sort( mapInfos.begin(), mapInfos.end(), Maps::FileInfo::CompareByFileName{} );
236+
}
237+
else {
238+
assert( sortType == SaveFileSortingMethod::TIMESTAMP );
239+
std::sort( mapInfos.begin(), mapInfos.end(), Maps::FileInfo::CompareByTimestamp{} );
240+
}
241+
}
242+
231243
MapsFileInfoList getSortedMapsFileInfoList()
232244
{
233245
ListFiles files;
@@ -244,19 +256,92 @@ namespace
244256
}
245257
}
246258

247-
std::sort( mapInfos.begin(), mapInfos.end(), Maps::FileInfo::sortByFileName );
259+
sortMapInfos( mapInfos );
248260

249261
return mapInfos;
250262
}
251263

264+
MapsFileInfoList::const_iterator findInMapInfos( const MapsFileInfoList::const_iterator & begin, const MapsFileInfoList::const_iterator & end,
265+
const std::string & lastChoice )
266+
{
267+
return std::find_if( begin, end, [&lastChoice]( const Maps::FileInfo & info ) { return info.filename == lastChoice; } );
268+
}
269+
270+
MapsFileInfoList::const_iterator findInMapInfos( const MapsFileInfoList::const_iterator & begin, const MapsFileInfoList::const_iterator & end,
271+
const std::string & lastChoice, const uint32_t lastChoiceTimestamp, const SaveFileSortingMethod sortingMethod )
272+
{
273+
switch ( sortingMethod ) {
274+
case SaveFileSortingMethod::FILENAME: {
275+
#ifdef WITH_DEBUG
276+
assert( std::is_sorted( begin, end, Maps::FileInfo::CompareByFileName{} ) );
277+
#endif
278+
279+
// With case-insensitive sorting, there may be several "identical" file names on the case-sensitive file system
280+
const auto [beginSameFileName, endSameFileName] = std::equal_range( begin, end, lastChoice, Maps::FileInfo::CompareByFileName{} );
281+
282+
if ( const MapsFileInfoList::const_iterator iter = findInMapInfos( beginSameFileName, endSameFileName, lastChoice ); iter != endSameFileName ) {
283+
return iter;
284+
}
285+
286+
break;
287+
}
288+
case SaveFileSortingMethod::TIMESTAMP: {
289+
#ifdef WITH_DEBUG
290+
assert( std::is_sorted( begin, end, Maps::FileInfo::CompareByTimestamp{} ) );
291+
#endif
292+
293+
const auto [beginSameTimestamp, endSameTimestamp] = std::equal_range( begin, end, lastChoiceTimestamp, Maps::FileInfo::CompareByTimestamp{} );
294+
295+
if ( const MapsFileInfoList::const_iterator iter = findInMapInfos( beginSameTimestamp, endSameTimestamp, lastChoice ); iter != endSameTimestamp ) {
296+
return iter;
297+
}
298+
299+
break;
300+
}
301+
default:
302+
assert( 0 );
303+
break;
304+
}
305+
306+
return end;
307+
}
308+
309+
MapsFileInfoList::const_iterator findInMapInfos( const MapsFileInfoList::const_iterator & begin, const MapsFileInfoList::const_iterator & end,
310+
const std::string & lastChoice, const SaveFileSortingMethod sortingMethod )
311+
{
312+
switch ( sortingMethod ) {
313+
case SaveFileSortingMethod::FILENAME: {
314+
#ifdef WITH_DEBUG
315+
assert( std::is_sorted( begin, end, Maps::FileInfo::CompareByFileName{} ) );
316+
#endif
317+
318+
// With case-insensitive sorting, there may be several "identical" file names on the case-sensitive file system
319+
const auto [beginSameFileName, endSameFileName] = std::equal_range( begin, end, lastChoice, Maps::FileInfo::CompareByFileName{} );
320+
321+
if ( const MapsFileInfoList::const_iterator iter = findInMapInfos( beginSameFileName, endSameFileName, lastChoice ); iter != endSameFileName ) {
322+
return iter;
323+
}
324+
325+
break;
326+
}
327+
case SaveFileSortingMethod::TIMESTAMP:
328+
return findInMapInfos( begin, end, lastChoice );
329+
default:
330+
assert( 0 );
331+
break;
332+
}
333+
334+
return end;
335+
}
336+
252337
std::string selectFileListSimple( const std::string & header, const std::string & lastfile, const bool isEditing )
253338
{
254339
// setup cursor
255340
const CursorRestorer cursorRestorer( true, Cursor::POINTER );
256341

257342
MapsFileInfoList lists = getSortedMapsFileInfoList();
258343

259-
const int32_t listHeightDeduction = 112;
344+
const int32_t listHeightDeduction = 120;
260345
const int32_t listAreaOffsetY = 3;
261346
const int32_t listAreaHeightDeduction = 4;
262347

@@ -272,20 +357,24 @@ namespace
272357
fheroes2::StandardWindow background( maxFileNameWidth + 204, std::min( display.height() - 100, maxDialogHeight ), true, display );
273358

274359
const fheroes2::Rect dialogArea( background.activeArea() );
275-
const fheroes2::Rect listRoi( dialogArea.x + 24, dialogArea.y + 37, dialogArea.width - 75, dialogArea.height - listHeightDeduction );
276-
const fheroes2::Rect textInputRoi( listRoi.x, listRoi.y + listRoi.height + 12, maxFileNameWidth + 8, 21 );
277-
const int32_t dateTimeoffsetX = textInputRoi.x + textInputRoi.width;
360+
const fheroes2::Rect listRoi( dialogArea.x + 24, dialogArea.y + 57, dialogArea.width - 75, dialogArea.height - listHeightDeduction );
361+
const fheroes2::Rect nameHeaderRoi( listRoi.x, listRoi.y - 28, maxFileNameWidth + 8, 28 );
362+
const fheroes2::Rect textInputRoi( listRoi.x, listRoi.y + listRoi.height + 3, maxFileNameWidth + 8, 23 );
363+
const int32_t dateTimeOffsetX = textInputRoi.x + textInputRoi.width;
278364
const int32_t dateTimeWidth = listRoi.width - textInputRoi.width;
365+
const fheroes2::Rect dateHeaderRoi( dateTimeOffsetX, nameHeaderRoi.y, dateTimeWidth, nameHeaderRoi.height );
279366

280367
// We divide the save-files list: file name and file date/time.
281368
background.applyTextBackgroundShading( { listRoi.x, listRoi.y, textInputRoi.width, listRoi.height } );
282369
background.applyTextBackgroundShading( { listRoi.x + textInputRoi.width, listRoi.y, dateTimeWidth, listRoi.height } );
370+
background.applyTextBackgroundShading( nameHeaderRoi );
371+
background.applyTextBackgroundShading( dateHeaderRoi );
283372
background.applyTextBackgroundShading( textInputRoi );
284373
// Make background for the selected file date and time.
285-
background.applyTextBackgroundShading( { dateTimeoffsetX, textInputRoi.y, dateTimeWidth, textInputRoi.height } );
374+
background.applyTextBackgroundShading( { dateTimeOffsetX, textInputRoi.y, dateTimeWidth, textInputRoi.height } );
286375

287376
fheroes2::ImageRestorer textInputBackground( display, textInputRoi.x, textInputRoi.y, textInputRoi.width, textInputRoi.height );
288-
fheroes2::ImageRestorer dateBackground( display, dateTimeoffsetX, textInputRoi.y, dateTimeWidth, textInputRoi.height );
377+
fheroes2::ImageRestorer dateBackground( display, dateTimeOffsetX, textInputRoi.y, dateTimeWidth, textInputRoi.height );
289378
const fheroes2::Rect textInputAndDateROI( textInputRoi.x, textInputRoi.y, listRoi.width, textInputRoi.height );
290379

291380
// Prepare OKAY and CANCEL buttons and render their shadows.
@@ -304,7 +393,9 @@ namespace
304393

305394
listbox.SetAreaItems( { listRoi.x, listRoi.y + 3, listRoi.width - listAreaOffsetY, listRoi.height - listAreaHeightDeduction } );
306395

307-
const bool isEvilInterface = Settings::Get().isEvilInterfaceEnabled();
396+
Settings & settings = Settings::Get();
397+
const bool isEvilInterface = settings.isEvilInterfaceEnabled();
398+
const SaveFileSortingMethod fileSortingMethod = settings.getSaveFileSortingMethod();
308399

309400
int32_t scrollbarOffsetX = dialogArea.x + dialogArea.width - 35;
310401
background.renderScrollbarBackground( { scrollbarOffsetX, listRoi.y, listRoi.width, listRoi.height }, isEvilInterface );
@@ -327,16 +418,10 @@ namespace
327418
if ( !lastfile.empty() ) {
328419
filename = System::GetStem( lastfile );
329420
charInsertPos = filename.size();
421+
const MapsFileInfoList::const_iterator it = findInMapInfos( lists.cbegin(), lists.cend(), lastfile, fileSortingMethod );
330422

331-
MapsFileInfoList::iterator it = lists.begin();
332-
for ( ; it != lists.end(); ++it ) {
333-
if ( ( *it ).filename == lastfile ) {
334-
break;
335-
}
336-
}
337-
338-
if ( it != lists.end() ) {
339-
listbox.SetCurrent( std::distance( lists.begin(), it ) );
423+
if ( it != lists.cend() ) {
424+
listbox.SetCurrent( std::distance( lists.cbegin(), it ) );
340425
}
341426
else {
342427
if ( !isEditing ) {
@@ -370,7 +455,68 @@ namespace
370455
std::unique_ptr<fheroes2::TextInputField> textInput;
371456

372457
const fheroes2::Text title( header, fheroes2::FontType::normalYellow() );
373-
title.drawInRoi( dialogArea.x + ( dialogArea.width - title.width() ) / 2, dialogArea.y + 16, display, dialogArea );
458+
title.drawInRoi( dialogArea.x + ( dialogArea.width - title.width() ) / 2, dialogArea.y + 9, display, dialogArea );
459+
460+
const fheroes2::Text nameHeader( _( "saveLoadDialog|Name" ), fheroes2::FontType::normalYellow() );
461+
nameHeader.drawInRoi( nameHeaderRoi.x, nameHeaderRoi.y + ( nameHeaderRoi.height - nameHeader.height() ) / 2 + 3, nameHeaderRoi.width, display, nameHeaderRoi );
462+
463+
const fheroes2::Text dateHeader( _( "saveLoadDialog|Date" ), fheroes2::FontType::normalYellow() );
464+
dateHeader.drawInRoi( dateHeaderRoi.x, dateHeaderRoi.y + ( dateHeaderRoi.height - dateHeader.height() ) / 2 + 3, dateHeaderRoi.width, display, dateHeaderRoi );
465+
466+
// Draw radio buttons for toggling between sorting methods.
467+
const int sortMarkIcnID = isEvilInterface ? ICN::CELLWIN_EVIL : ICN::CELLWIN;
468+
const fheroes2::Sprite & markBackground = fheroes2::AGG::GetICN( sortMarkIcnID, 4 );
469+
470+
const fheroes2::Rect markBackgroundNameRoi( nameHeaderRoi.x + 5, nameHeaderRoi.y + 6, markBackground.width(), markBackground.height() );
471+
const fheroes2::Rect markBackgroundDateRoi( dateTimeOffsetX + 5, nameHeaderRoi.y + 6, markBackground.width(), markBackground.height() );
472+
fheroes2::Blit( markBackground, display, markBackgroundNameRoi );
473+
fheroes2::Blit( markBackground, display, markBackgroundDateRoi );
474+
475+
const fheroes2::Sprite & mark = fheroes2::AGG::GetICN( sortMarkIcnID, 5 );
476+
477+
if ( fileSortingMethod == SaveFileSortingMethod::FILENAME ) {
478+
fheroes2::Blit( mark, display, { markBackgroundNameRoi.x + 3, markBackgroundNameRoi.y + 3, mark.width(), mark.height() } );
479+
}
480+
else {
481+
fheroes2::Blit( mark, display, { markBackgroundDateRoi.x + 3, markBackgroundDateRoi.y + 3, mark.width(), mark.height() } );
482+
}
483+
484+
// Redraw sort radio buttons, sort file list in new method, and return whether to redraw the list.
485+
auto switchFileSorting
486+
= [&markBackgroundNameRoi, &markBackgroundDateRoi, &markBackground, &mark, &display, &listbox, &lists, &settings]( const bool doSortByDate ) {
487+
const fheroes2::Rect roiMarkBackground = doSortByDate ? markBackgroundNameRoi : markBackgroundDateRoi;
488+
const fheroes2::Rect roiMark = doSortByDate ? markBackgroundDateRoi : markBackgroundNameRoi;
489+
fheroes2::Blit( markBackground, display, roiMarkBackground );
490+
fheroes2::Blit( mark, display, { roiMark.x + 3, roiMark.y + 3, roiMark.width, roiMark.height } );
491+
492+
const int currentId = listbox.getCurrentId();
493+
std::string lastChoice{};
494+
uint32_t lastChoiceTimestamp{};
495+
if ( currentId >= 0 && static_cast<size_t>( currentId ) < lists.size() ) {
496+
lastChoice = lists[currentId].filename;
497+
lastChoiceTimestamp = lists[currentId].timestamp;
498+
}
499+
500+
settings.setSaveFileSortingMethod( doSortByDate ? SaveFileSortingMethod::TIMESTAMP : SaveFileSortingMethod::FILENAME );
501+
sortMapInfos( lists );
502+
503+
bool redrawNeeded = false;
504+
505+
// Re-select the last selected file if any, unless we're typing in the list box
506+
if ( !lastChoice.empty() ) {
507+
const MapsFileInfoList::const_iterator it
508+
= findInMapInfos( lists.cbegin(), lists.cend(), lastChoice, lastChoiceTimestamp, settings.getSaveFileSortingMethod() );
509+
510+
if ( it != lists.cend() ) {
511+
const int newId = static_cast<int>( std::distance( lists.cbegin(), it ) );
512+
if ( newId != currentId ) {
513+
listbox.SetCurrent( newId );
514+
redrawNeeded = true;
515+
}
516+
}
517+
}
518+
return redrawNeeded;
519+
};
374520

375521
if ( isEditing ) {
376522
// Render a button to open the Virtual Keyboard window.
@@ -383,14 +529,14 @@ namespace
383529

384530
if ( !listbox.isSelected() ) {
385531
textInput->draw( filename, static_cast<int32_t>( charInsertPos ) );
386-
redrawDateTime( display, std::time( nullptr ), dateTimeoffsetX, textInputRoi.y + 4, fheroes2::FontType::normalWhite() );
532+
redrawDateTime( display, std::time( nullptr ), dateTimeOffsetX, textInputRoi.y + 4, fheroes2::FontType::normalWhite() );
387533
}
388534
}
389535

390536
if ( listbox.isSelected() ) {
391537
// Render the saved file name, date and time.
392538
redrawTextInputField( filename, textInputRoi, display );
393-
redrawDateTime( display, listbox.GetCurrent().timestamp, dateTimeoffsetX, textInputRoi.y + 4, fheroes2::FontType::normalYellow() );
539+
redrawDateTime( display, listbox.GetCurrent().timestamp, dateTimeOffsetX, textInputRoi.y + 4, fheroes2::FontType::normalYellow() );
394540
}
395541

396542
display.render( background.totalArea() );
@@ -412,7 +558,7 @@ namespace
412558
}
413559

414560
if ( le.MouseClickLeft( buttonCancel.area() ) || Game::HotKeyPressEvent( Game::HotKeyEvent::DEFAULT_CANCEL ) ) {
415-
return {};
561+
break;
416562
}
417563

418564
const int listId = listbox.getCurrentId();
@@ -422,6 +568,7 @@ namespace
422568
bool isListboxSelected = listbox.isSelected();
423569

424570
bool needRedraw = ( listId != listbox.getCurrentId() );
571+
bool listUpdated = false;
425572

426573
if ( le.isKeyPressed( fheroes2::Key::KEY_DELETE ) && isListboxSelected ) {
427574
listbox.SetCurrent( listId );
@@ -461,6 +608,14 @@ namespace
461608
result = System::concatPath( Game::GetSaveDir(), filename + Game::GetSaveFileExtension() );
462609
}
463610
}
611+
else if ( le.MouseClickLeft( nameHeaderRoi ) && settings.getSaveFileSortingMethod() != SaveFileSortingMethod::FILENAME ) {
612+
listUpdated = true;
613+
needRedraw = switchFileSorting( false );
614+
}
615+
else if ( le.MouseClickLeft( dateHeaderRoi ) && settings.getSaveFileSortingMethod() != SaveFileSortingMethod::TIMESTAMP ) {
616+
listUpdated = true;
617+
needRedraw = switchFileSorting( true );
618+
}
464619
else if ( isEditing ) {
465620
assert( textInput != nullptr );
466621

@@ -521,8 +676,15 @@ namespace
521676
else if ( isEditing && le.isMouseRightButtonPressedInArea( buttonVirtualKB->area() ) ) {
522677
fheroes2::showStandardTextMessage( _( "Open Virtual Keyboard" ), _( "Click to open the Virtual Keyboard dialog." ), Dialog::ZERO );
523678
}
679+
else if ( le.isMouseRightButtonPressedInArea( nameHeaderRoi ) ) {
680+
fheroes2::showStandardTextMessage( _( "Sort by Name" ), _( "Click here if you wish to sort save files by their name." ), Dialog::ZERO );
681+
}
682+
else if ( le.isMouseRightButtonPressedInArea( dateHeaderRoi ) ) {
683+
fheroes2::showStandardTextMessage( _( "Sort by Date" ), _( "Click here if you you wish to sort save files by their last modified date." ), Dialog::ZERO );
684+
}
524685

525-
const bool needRedrawListbox = listbox.IsNeedRedraw();
686+
// TODO: ListBox::SetCurrent() call should update needRedraw variable as it changes the internal UI view of the class.
687+
const bool needRedrawListbox = listUpdated || listbox.IsNeedRedraw();
526688

527689
if ( isEditing && !needRedraw && !isListboxSelected && textInput->eventProcessing() ) {
528690
// Text input blinking cursor render is done in Save Game dialog when no file is selected
@@ -549,7 +711,7 @@ namespace
549711
dateBackground.restore();
550712

551713
redrawTextInputField( filename, textInputRoi, display );
552-
redrawDateTime( display, listbox.GetCurrent().timestamp, dateTimeoffsetX, textInputRoi.y + 4, fheroes2::FontType::normalYellow() );
714+
redrawDateTime( display, listbox.GetCurrent().timestamp, dateTimeOffsetX, textInputRoi.y + 4, fheroes2::FontType::normalYellow() );
553715
}
554716
else if ( isEditing ) {
555717
// Empty last selected save file name so that we can replace the input field's name if we select the same save file again.
@@ -558,7 +720,7 @@ namespace
558720

559721
dateBackground.restore();
560722
textInput->draw( filename, static_cast<int32_t>( charInsertPos ) );
561-
redrawDateTime( display, std::time( nullptr ), dateTimeoffsetX, textInputRoi.y + 4, fheroes2::FontType::normalWhite() );
723+
redrawDateTime( display, std::time( nullptr ), dateTimeOffsetX, textInputRoi.y + 4, fheroes2::FontType::normalWhite() );
562724
}
563725
}
564726

@@ -571,6 +733,12 @@ namespace
571733
}
572734
}
573735

736+
const SaveFileSortingMethod lastFileSortingMethod = settings.getSaveFileSortingMethod();
737+
738+
if ( lastFileSortingMethod != fileSortingMethod ) {
739+
settings.Save( Settings::configFileName );
740+
}
741+
574742
return result;
575743
}
576744
}

0 commit comments

Comments
 (0)