1//============================================================================
2//
3// SSSS tt lll lll
4// SS SS tt ll ll
5// SS tttttt eeee ll ll aaaa
6// SSSS tt ee ee ll ll aa
7// SS tt eeeeee ll ll aaaaa -- "An Atari 2600 VCS Emulator"
8// SS SS tt ee ll ll aa aa
9// SSSS ttt eeeee llll llll aaaaa
10//
11// Copyright (c) 1995-2019 by Bradford W. Mott, Stephen Anthony
12// and the Stella Team
13//
14// See the file "License.txt" for information on usage and redistribution of
15// this file, and for a DISCLAIMER OF ALL WARRANTIES.
16//============================================================================
17
18#include "bspf.hxx"
19#include "Bankswitch.hxx"
20#include "BrowserDialog.hxx"
21#include "ContextMenu.hxx"
22#include "DialogContainer.hxx"
23#include "Dialog.hxx"
24#include "EditTextWidget.hxx"
25#include "FileListWidget.hxx"
26#include "FSNode.hxx"
27#include "MD5.hxx"
28#include "OptionsDialog.hxx"
29#include "GlobalPropsDialog.hxx"
30#include "StellaSettingsDialog.hxx"
31#include "MessageBox.hxx"
32#include "OSystem.hxx"
33#include "FrameBuffer.hxx"
34#include "FBSurface.hxx"
35#include "EventHandler.hxx"
36#include "StellaKeys.hxx"
37#include "Props.hxx"
38#include "PropsSet.hxx"
39#include "RomInfoWidget.hxx"
40#include "Settings.hxx"
41#include "Widget.hxx"
42#include "Font.hxx"
43#include "Version.hxx"
44#include "LauncherDialog.hxx"
45
46// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
47LauncherDialog::LauncherDialog(OSystem& osystem, DialogContainer& parent,
48 int x, int y, int w, int h)
49 : Dialog(osystem, parent, x, y, w, h),
50 myStartButton(nullptr),
51 myPrevDirButton(nullptr),
52 myOptionsButton(nullptr),
53 myQuitButton(nullptr),
54 myList(nullptr),
55 myPattern(nullptr),
56 myAllFiles(nullptr),
57 myRomInfoWidget(nullptr),
58 mySelectedItem(0),
59 myEventHandled(false)
60{
61 myUseMinimalUI = instance().settings().getBool("minimal_ui");
62
63 const GUI::Font& font = instance().frameBuffer().launcherFont();
64
65 const int HBORDER = 10;
66 const int BUTTON_GAP = 8;
67 const int fontWidth = font.getMaxCharWidth(),
68 fontHeight = font.getFontHeight(),
69 lineHeight = font.getLineHeight(),
70 bwidth = (_w - 2 * HBORDER - BUTTON_GAP * (4 - 1)),
71 bheight = myUseMinimalUI ? lineHeight - 4 : lineHeight + 4,
72 LBL_GAP = fontWidth;
73 int xpos = 0, ypos = 0, lwidth = 0, lwidth2 = 0;
74 WidgetArray wid;
75
76 string lblRom = "Select a ROM from the list" + ELLIPSIS;
77 const string& lblFilter = "Filter";
78 const string& lblAllFiles = "Show all files";
79 const string& lblFound = "XXXX items found";
80
81 lwidth = font.getStringWidth(lblRom);
82 lwidth2 = font.getStringWidth(lblAllFiles) + 20;
83 int lwidth3 = font.getStringWidth(lblFilter);
84 int lwidth4 = font.getStringWidth(lblFound);
85
86 if(w < HBORDER * 2 + lwidth + lwidth2 + lwidth3 + lwidth4 + fontWidth * 6 + LBL_GAP * 8)
87 {
88 // make sure there is space for at least 6 characters in the filter field
89 lblRom = "Select a ROM" + ELLIPSIS;
90 lwidth = font.getStringWidth(lblRom);
91 }
92
93 if(myUseMinimalUI)
94 {
95 // App information
96 ostringstream ver;
97 ver << "Stella " << STELLA_VERSION;
98 #if defined(RETRON77)
99 ver << " for RetroN 77";
100 #endif
101 ypos += 8;
102 new StaticTextWidget(this, font, xpos, ypos, _w - 20, fontHeight,
103 ver.str(), TextAlign::Center);
104 ypos += fontHeight - 4;
105 }
106
107 // Show the header
108 xpos += HBORDER; ypos += 8;
109 new StaticTextWidget(this, font, xpos, ypos, lblRom);
110 // Shop the files counter
111 xpos = _w - HBORDER - lwidth4;
112 myRomCount = new StaticTextWidget(this, font, xpos, ypos,
113 lwidth4, fontHeight,
114 "", TextAlign::Right);
115
116 // Add filter that can narrow the results shown in the listing
117 // It has to fit between both labels
118 if(!myUseMinimalUI && w >= 640)
119 {
120 int fwidth = std::min(15 * fontWidth, xpos - lwidth3 - lwidth2 - lwidth - HBORDER - LBL_GAP * 8);
121 // Show the filter input field
122 xpos -= fwidth + LBL_GAP;
123 myPattern = new EditTextWidget(this, font, xpos, ypos - 2, fwidth, lineHeight, "");
124 // Show the "Filter" label
125 xpos -= lwidth3 + LBL_GAP;
126 new StaticTextWidget(this, font, xpos, ypos, lblFilter);
127 // Show the checkbox for all files
128 xpos -= lwidth2 + LBL_GAP * 3;
129 myAllFiles = new CheckboxWidget(this, font, xpos, ypos, lblAllFiles, kAllfilesCmd);
130 wid.push_back(myAllFiles);
131 wid.push_back(myPattern);
132 }
133
134 // Add list with game titles
135 // Before we add the list, we need to know the size of the RomInfoWidget
136 xpos = HBORDER; ypos += lineHeight + 4;
137 int romWidth = 0;
138 int romSize = instance().settings().getInt("romviewer");
139 if(romSize > 1 && w >= 1000 && h >= 720)
140 romWidth = 660;
141 else if(romSize > 0 && w >= 640 && h >= 480)
142 romWidth = 365;
143
144 int listWidth = _w - (romWidth > 0 ? romWidth+8 : 0) - 20;
145 myList = new FileListWidget(this, font, xpos, ypos,
146 listWidth, _h - 43 - bheight - fontHeight - lineHeight);
147 myList->setEditable(false);
148 myList->setListMode(FilesystemNode::ListMode::All);
149 wid.push_back(myList);
150
151 // Add ROM info area (if enabled)
152 if(romWidth > 0)
153 {
154 xpos += myList->getWidth() + 8;
155 myRomInfoWidget = new RomInfoWidget(this,
156 romWidth < 660 ? instance().frameBuffer().smallFont() :
157 instance().frameBuffer().infoFont(),
158 xpos, ypos, romWidth, myList->getHeight());
159 }
160
161 // Add textfield to show current directory
162 xpos = HBORDER;
163 ypos += myList->getHeight() + 8;
164 lwidth = font.getStringWidth("Path") + LBL_GAP;
165 myDirLabel = new StaticTextWidget(this, font, xpos, ypos+2, lwidth, fontHeight,
166 "Path", TextAlign::Left);
167 xpos += lwidth;
168 myDir = new EditTextWidget(this, font, xpos, ypos, _w - xpos - HBORDER, lineHeight, "");
169 myDir->setEditable(false, true);
170 myDir->clearFlags(Widget::FLAG_RETAIN_FOCUS);
171
172 if(!myUseMinimalUI)
173 {
174 // Add four buttons at the bottom
175 xpos = HBORDER; ypos += myDir->getHeight() + 8;
176 #ifndef BSPF_MACOS
177 myStartButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 0) / 4, bheight,
178 "Select", kLoadROMCmd);
179 wid.push_back(myStartButton);
180 xpos += (bwidth + 0) / 4 + BUTTON_GAP;
181 myPrevDirButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 1) / 4, bheight,
182 "Go Up", kPrevDirCmd);
183 wid.push_back(myPrevDirButton);
184 xpos += (bwidth + 1) / 4 + BUTTON_GAP;
185 myOptionsButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 2) / 4, bheight,
186 "Options" + ELLIPSIS, kOptionsCmd);
187 wid.push_back(myOptionsButton);
188 xpos += (bwidth + 2) / 4 + BUTTON_GAP;
189 myQuitButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 3) / 4, bheight,
190 "Quit", kQuitCmd);
191 wid.push_back(myQuitButton);
192 #else
193 myQuitButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 0) / 4, bheight,
194 "Quit", kQuitCmd);
195 wid.push_back(myQuitButton);
196 xpos += (bwidth + 0) / 4 + BUTTON_GAP;
197 myOptionsButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 1) / 4, bheight,
198 "Options" + ELLIPSIS, kOptionsCmd);
199 wid.push_back(myOptionsButton);
200 xpos += (bwidth + 1) / 4 + BUTTON_GAP;
201 myPrevDirButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 2) / 4, bheight,
202 "Go Up", kPrevDirCmd);
203 wid.push_back(myPrevDirButton);
204 xpos += (bwidth + 2) / 4 + BUTTON_GAP;
205 myStartButton = new ButtonWidget(this, font, xpos, ypos, (bwidth + 3) / 4, bheight,
206 "Select", kLoadROMCmd);
207 wid.push_back(myStartButton);
208 #endif
209 }
210 if (myUseMinimalUI) // Highlight 'Rom Listing'
211 mySelectedItem = 0;
212 else
213 mySelectedItem = 2;
214
215 addToFocusList(wid);
216
217 // Create context menu for ROM list options
218 VariantList l;
219 VarList::push_back(l, "Power-on options" + ELLIPSIS, "override");
220 VarList::push_back(l, "Reload listing", "reload");
221 myMenu = make_unique<ContextMenu>(this, osystem.frameBuffer().font(), l);
222
223 // Create global props dialog, which is used to temporarily overrride
224 // ROM properties
225 myGlobalProps = make_unique<GlobalPropsDialog>(this,
226 myUseMinimalUI ? osystem.frameBuffer().launcherFont() : osystem.frameBuffer().font());
227
228 // Do we show only ROMs or all files?
229 bool onlyROMs = instance().settings().getBool("launcherroms");
230 showOnlyROMs(onlyROMs);
231 if(myAllFiles)
232 myAllFiles->setState(!onlyROMs);
233}
234
235// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
236const string& LauncherDialog::selectedRom() const
237{
238 return currentNode().getPath();
239}
240
241// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
242const string& LauncherDialog::selectedRomMD5()
243{
244 if(currentNode().isDirectory() || !Bankswitch::isValidRomName(currentNode()))
245 return EmptyString;
246
247 // Attempt to conserve memory
248 if(myMD5List.size() > 500)
249 myMD5List.clear();
250
251 // Lookup MD5, and if not present, cache it
252 auto iter = myMD5List.find(currentNode().getPath());
253 if(iter == myMD5List.end())
254 myMD5List[currentNode().getPath()] = MD5::hash(currentNode());
255
256 return myMD5List[currentNode().getPath()];
257}
258
259// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
260const FilesystemNode& LauncherDialog::currentNode() const
261{
262 return myList->selected();
263}
264
265// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
266void LauncherDialog::reload()
267{
268 myMD5List.clear();
269 myList->reload();
270}
271
272// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
273void LauncherDialog::loadConfig()
274{
275 // Should we use a temporary directory specified on the commandline, or the
276 // default one specified by the settings?
277 const string& tmpromdir = instance().settings().getString("tmpromdir");
278 const string& romdir = tmpromdir != "" ? tmpromdir :
279 instance().settings().getString("romdir");
280
281 // Assume that if the list is empty, this is the first time that loadConfig()
282 // has been called (and we should reload the list)
283 if(myList->getList().empty())
284 {
285 FilesystemNode node(romdir == "" ? "~" : romdir);
286 if(!(node.exists() && node.isDirectory()))
287 node = FilesystemNode("~");
288
289 myList->setDirectory(node, instance().settings().getString("lastrom"));
290 updateUI();
291 }
292 Dialog::setFocus(getFocusList()[mySelectedItem]);
293
294 if(myRomInfoWidget)
295 myRomInfoWidget->reloadProperties(currentNode());
296}
297
298// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
299void LauncherDialog::updateUI()
300{
301 // Only hilite the 'up' button if there's a parent directory
302 if(myPrevDirButton)
303 myPrevDirButton->setEnabled(myList->currentDir().hasParent());
304
305 // Show current directory
306 myDir->setText(myList->currentDir().getShortPath());
307
308 // Indicate how many files were found
309 ostringstream buf;
310 buf << (myList->getList().size() - 1) << " items found";
311 myRomCount->setLabel(buf.str());
312
313 // Update ROM info UI item
314 loadRomInfo();
315}
316
317// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
318void LauncherDialog::applyFiltering()
319{
320 myList->setNameFilter(
321 [&](const FilesystemNode& node) {
322 if(!node.isDirectory())
323 {
324 // Do we want to show only ROMs or all files?
325 if(myShowOnlyROMs && !Bankswitch::isValidRomName(node))
326 return false;
327
328 // Skip over files that don't match the pattern in the 'pattern' textbox
329 if(myPattern && myPattern->getText() != "" &&
330 !BSPF::containsIgnoreCase(node.getName(), myPattern->getText()))
331 return false;
332 }
333 return true;
334 }
335 );
336}
337
338// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
339void LauncherDialog::loadRomInfo()
340{
341 if(!myRomInfoWidget)
342 return;
343
344 const string& md5 = selectedRomMD5();
345 if(md5 != EmptyString)
346 {
347 // Get the properties for this entry
348 Properties props;
349 instance().propSet().getMD5WithInsert(currentNode(), md5, props);
350
351 myRomInfoWidget->setProperties(props, currentNode());
352 }
353 else
354 myRomInfoWidget->clearProperties();
355}
356
357// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
358void LauncherDialog::handleContextMenu()
359{
360 const string& cmd = myMenu->getSelectedTag().toString();
361
362 if(cmd == "override")
363 myGlobalProps->open();
364 else if(cmd == "reload")
365 reload();
366}
367
368// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
369void LauncherDialog::showOnlyROMs(bool state)
370{
371 myShowOnlyROMs = state;
372 instance().settings().setValue("launcherroms", state);
373 applyFiltering();
374}
375
376// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
377void LauncherDialog::handleKeyDown(StellaKey key, StellaMod mod, bool repeated)
378{
379 // Grab the key before passing it to the actual dialog and check for
380 // Control-R (reload ROM listing)
381 if(StellaModTest::isControl(mod) && key == KBDK_R)
382 reload();
383 else
384#if defined(RETRON77)
385 // handle keys used by R77
386 switch(key)
387 {
388 case KBDK_F8: // front ("Skill P2")
389 myGlobalProps->open();
390 break;
391
392 case KBDK_F4: // back ("COLOR", "B/W")
393 openSettings();
394 break;
395
396 case KBDK_F11: // front ("LOAD")
397 // convert unused previous item key into page-up event
398 _focusedWidget->handleEvent(Event::UIPgUp);
399 break;
400
401 case KBDK_F1: // front ("MODE")
402 // convert unused next item key into page-down event
403 _focusedWidget->handleEvent(Event::UIPgDown);
404 break;
405
406 default:
407 Dialog::handleKeyDown(key, mod);
408 break;
409 }
410#else
411 Dialog::handleKeyDown(key, mod);
412#endif
413}
414
415// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
416void LauncherDialog::handleJoyDown(int stick, int button, bool longPress)
417{
418 // open power-up options and settings for 2nd and 4th button if not mapped otherwise
419 Event::Type e = instance().eventHandler().eventForJoyButton(EventMode::kMenuMode, stick, button);
420
421 if (button == 1 && (e == Event::UIOK || e == Event::NoType))
422 myGlobalProps->open();
423 if (button == 3 && (e == Event::Event::UITabPrev || e == Event::NoType))
424 openSettings();
425 else
426 {
427 myEventHandled = false;
428 myList->setFlags(Widget::FLAG_WANTS_RAWDATA); // allow handling long button press
429 Dialog::handleJoyDown(stick, button, longPress);
430 }
431}
432
433// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
434void LauncherDialog::handleJoyUp(int stick, int button)
435{
436 if (!myEventHandled)
437 Dialog::handleJoyUp(stick, button);
438 myList->clearFlags(Widget::FLAG_WANTS_RAWDATA); // stop allowing to handle long button press
439}
440
441// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
442Event::Type LauncherDialog::getJoyAxisEvent(int stick, JoyAxis axis, JoyDir adir, int button)
443{
444 Event::Type e = instance().eventHandler().eventForJoyAxis(EventMode::kMenuMode, stick, axis, adir, button);
445
446 if(myUseMinimalUI)
447 {
448 // map axis events for launcher
449 switch(e)
450 {
451 case Event::UINavPrev:
452 // convert unused previous item event into page-up event
453 e = Event::UIPgUp;
454 break;
455
456 case Event::UINavNext:
457 // convert unused next item event into page-down event
458 e = Event::UIPgDown;
459 break;
460
461 default:
462 break;
463 }
464 }
465 return e;
466}
467
468// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
469void LauncherDialog::handleMouseDown(int x, int y, MouseButton b, int clickCount)
470{
471 // Grab right mouse button for context menu, send left to base class
472 if(b == MouseButton::RIGHT)
473 {
474 // Add menu at current x,y mouse location
475 myMenu->show(x + getAbsX(), y + getAbsY(), surface().dstRect());
476 }
477 else
478 Dialog::handleMouseDown(x, y, b, clickCount);
479}
480
481// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
482void LauncherDialog::handleCommand(CommandSender* sender, int cmd,
483 int data, int id)
484{
485 switch (cmd)
486 {
487 case kAllfilesCmd:
488 showOnlyROMs(myAllFiles ? !myAllFiles->getState() : true);
489 reload();
490 break;
491
492 case kLoadROMCmd:
493 case FileListWidget::ItemActivated:
494 loadRom();
495 break;
496
497 case kOptionsCmd:
498 openSettings();
499 break;
500
501 case kPrevDirCmd:
502 myList->selectParent();
503 break;
504
505 case FileListWidget::ItemChanged:
506 updateUI();
507 break;
508
509 case ListWidget::kLongButtonPressCmd:
510 myGlobalProps->open();
511 myEventHandled = true;
512 break;
513
514 case EditableWidget::kChangedCmd:
515 applyFiltering(); // pattern matching taken care of directly in this method
516 reload();
517 break;
518
519 case kQuitCmd:
520 close();
521 instance().eventHandler().quit();
522 break;
523
524 case kRomDirChosenCmd:
525 {
526 FilesystemNode node(instance().settings().getString("romdir"));
527 if(!(node.exists() && node.isDirectory()))
528 node = FilesystemNode("~");
529
530 myList->setDirectory(node);
531 break;
532 }
533
534 case ContextMenu::kItemSelectedCmd:
535 handleContextMenu();
536 break;
537
538 default:
539 Dialog::handleCommand(sender, cmd, data, 0);
540 }
541}
542
543// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
544void LauncherDialog::loadRom()
545{
546 const string& result = instance().createConsole(currentNode(), selectedRomMD5());
547 if(result == EmptyString)
548 {
549 instance().settings().setValue("lastrom", myList->getSelectedString());
550
551 // If romdir has never been set, set it now based on the selected rom
552 if(instance().settings().getString("romdir") == EmptyString)
553 instance().settings().setValue("romdir", currentNode().getParent().getShortPath());
554 }
555 else
556 instance().frameBuffer().showMessage(result, MessagePosition::MiddleCenter, true);
557}
558
559// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
560void LauncherDialog::openSettings()
561{
562 // Create an options dialog, similar to the in-game one
563 if (instance().settings().getBool("basic_settings"))
564 {
565 if (myStellaSettingsDialog == nullptr)
566 myStellaSettingsDialog = make_unique<StellaSettingsDialog>(instance(), parent(),
567 instance().frameBuffer().launcherFont(), _w, _h, Menu::AppMode::launcher);
568 myStellaSettingsDialog->open();
569 }
570 else
571 {
572 if (myOptionsDialog == nullptr)
573 myOptionsDialog = make_unique<OptionsDialog>(instance(), parent(), this, _w, _h,
574 Menu::AppMode::launcher);
575 myOptionsDialog->open();
576 }
577}
578