1 ///
2 module dli.text_menu;
3 
4 import dli.display_scenario;
5 import dli.exceptions;
6 import dli.io : request;
7 import dli.i_text_menu;
8 import dli.internal.lifo;
9 
10 import std.exception;
11 import std.range.interfaces;
12 import std.range.primitives;
13 import std.typecons : Tuple, tuple;
14 import std..string : format;
15 import std.conv;
16 
17 package static Lifo!ITextMenu runningMenusStack;
18 package static ITextMenu activeTextMenu()
19 {
20     return !runningMenusStack.empty ? runningMenusStack.front :
21                                       null;
22 }
23 
24 private enum PrintItemKeyKeyword = "%item_key%"; /// String to be used in place of the MenuItem identifier in itemPrintFormat
25 private enum PrintItemTextKeyword = "%item_text%"; /// String to be used in place of the MenuItem text in itemPrintFormat
26 
27 ///
28 public abstract class TextMenu(inputStreamT, outputStreamT, keyT) : ITextMenu
29 {
30     protected inputStreamT inputStream;
31     protected outputStreamT outputStream;
32 
33     protected MenuItem[keyT] menuItems;
34 
35     private string _welcomeMsg = "Please, select an option:"; /// Welcome message for this menu
36     private DisplayScenario _welcomeMsgDisplayScenario; /// Scenario when the welcome message should be displayed
37 
38     private string _promptMsg = "> "; /// String to be printed before asking the user for input
39     private string _onItemExecutedMsg = "\n"; /// String to be printed after any menu item is executed. Generally, you will want this to be EOL.
40     private void delegate() _onStart; /// Delegate to be called when the menu starts running;
41     private void delegate() _onExit; /// Delegate to be called when the menu exits
42     private string _onInvalidItemSelectedMsg = "Please, select a valid item from the list.";
43     private string _itemPrintFormat = PrintItemKeyKeyword ~ " - " ~ PrintItemTextKeyword; /// Stores the format in which menu items are printed to the output stream
44     private Status _status = Status.Stopped;
45 
46     @property
47     protected Status status()
48     {
49         return _status;
50     }
51 
52     mixin(generateMenuCustomizingSetter!string(__traits(identifier, _welcomeMsg)));
53     mixin(generateMenuCustomizingSetter!DisplayScenario(__traits(identifier, _welcomeMsgDisplayScenario)));
54     mixin(generateMenuCustomizingSetter!string(__traits(identifier, _promptMsg)));
55     mixin(generateMenuCustomizingSetter!(void delegate())(__traits(identifier, _onStart)));
56     mixin(generateMenuCustomizingSetter!(void delegate())(__traits(identifier, _onExit)));
57     mixin(generateMenuCustomizingSetter!string(__traits(identifier, _onInvalidItemSelectedMsg)));
58     mixin(generateMenuCustomizingSetter!string(__traits(identifier, _itemPrintFormat)));
59 
60     /// Removes the menu item associated with key. If no item was associated with such key, nothing happens.
61     public final void removeItem(keyT key)
62     {
63         menuItems.remove(key);
64     }
65 
66     /// Removes all items from this menu.
67     public final void removeAllItems()
68     {
69         menuItems.clear();
70     }
71 
72     /// Associates the given item with the given key in this menu.
73     public void addItem(MenuItem item, keyT key)
74     in
75     {
76         assert(item !is null);
77     }
78     do
79     {
80         enforce!InvalidKeyException(key !in menuItems,
81             format("Tried to call addItem with key %s, but it is already in use.", key));
82 
83         item.bind(this);
84         menuItems[key] = item;
85     }
86 
87     /** Starts the menu.
88 
89         Throws: InvalidMenuStatusException if the menu was not stopped prior
90                 to calling this method.
91     */
92     public override final void run()
93     {
94         enforce!InvalidMenuStatusException(_status == Status.Stopped,
95          "run may not be called while the menu is running.");
96 
97         try
98         {
99             _status = Status.Starting;
100 
101             if (_onStart !is null)
102                 _onStart();
103 
104             runningMenusStack.put(this);
105 
106             /* Before actually starting the menu, we need to provide the user with a way to
107                exit the menu. We create an ad hoc MenuItem for this purpose and add it here */
108             addExitMenuItem(new MenuItem("Exit", 
109                 {
110                     _status = Status.Stopping;
111                 }
112             ));
113 
114             _status = Status.Running;
115 
116             while (_status == Status.Running)
117             {
118                 printWelcomeMsg();
119                 printEnabledItems();
120                 try
121                 {
122                     keyT selectedItemKey;
123                     if(request(_promptMsg, &selectedItemKey))
124                         tryExecuteItem(selectedItemKey);
125                     else
126                         throw new InvalidItemException("The user input was not convertible to a key.");
127                 }
128                 catch(InvalidItemException e)
129                 {
130                     outputStream.writeln(_onInvalidItemSelectedMsg);
131                 }
132                 write(_onItemExecutedMsg);
133             }
134         }
135         finally
136         {
137             _status = Status.Stopping;
138             // In order to leave things as they were prior to calling this method, the ad hoc MenuItem used to exit the menu is removed
139             removeExitMenuItem();
140             if (_onExit !is null)
141                 _onExit();
142 
143             runningMenusStack.pop();
144             _status = Status.Stopped;
145         }
146     }
147 
148     public override final void writeln(string s)
149     {
150         outputStream.writeln(s);
151     }
152 
153     public override final void write(string s)
154     {
155         outputStream.write(s);
156     }
157 
158     public override final string readln()
159     {
160         return inputStream.readln();
161     }
162 
163     protected this(inputStreamT inputStream, outputStreamT outputStream)
164     {
165         this.inputStream = inputStream;
166         this.outputStream = outputStream;   
167     }
168 
169     protected this(textMenuT)(textMenuT streamSource)
170     {
171         this.inputStream = streamSource.inputStream;
172         this.outputStream = streamSource.outputStream;
173     }
174 
175     private void printEnabledItems()
176     {
177         void printItem(string key, string itemText)
178         {
179             import std..string : replace;
180 
181             string toBePrinted = _itemPrintFormat.replace(PrintItemKeyKeyword, key).
182                                 replace(PrintItemTextKeyword, itemText);
183             outputStream.writeln(toBePrinted);
184         }
185 
186         foreach(Tuple!(keyT, MenuItem) itemTuple; sortItemsForDisplay())
187             if(itemTuple[1].enabled)
188                 printItem(to!string(itemTuple[0]), itemTuple[1].displayString);
189     }
190 
191     private void tryExecuteItem(keyT key)
192     {
193         MenuItem menuItem = getMenuItem(key);
194         enforce!InvalidItemException(menuItem.enabled, format!("User tried to select disabled " ~
195                 "menu item with key %s")(key));
196         
197         menuItem.execute();
198     }
199 
200     private void printWelcomeMsg()
201     {
202         outputStream.writeln(_welcomeMsg);
203     }
204 
205     // TODO is this useful?
206     protected final MenuItem getMenuItem(keyT key)
207     {
208         import std.format : format;
209         enforce!InvalidItemException(key in menuItems, format!("Tried to retrieve " ~
210                 "unexisting menu item with key %s")(key));
211 
212         return menuItems[key];
213     }
214 
215     protected Tuple!(keyT, MenuItem)[] sortItemsForDisplay()
216     {
217         //Default implementation simply returns the items as they are found in menuItems
218         Tuple!(keyT, MenuItem)[] sortedItems;
219 
220         foreach(keyT key, MenuItem item; menuItems)
221             sortedItems ~= tuple(key, item);
222 
223         return sortedItems;
224     }
225 
226     abstract protected void addExitMenuItem(MenuItem exitMenuItem);
227     abstract protected void removeExitMenuItem();
228 
229     protected enum Status
230     {
231         Stopping,
232         Stopped,
233         Starting,
234         Running
235     }
236 
237     // Helper function to generate setters via mixins
238     private static string generateMenuCustomizingSetter(T)(string fieldName) pure
239     in
240     {
241         assert(fieldName[0] == '_');
242     }
243     do
244     {
245         import std.uni : toUpper;
246 
247         string propertyIdentifier = fieldName[1..$];
248 
249         return format("@property
250                     public void %s(%s a)
251                     body
252                     {
253                         %s = a;
254                     }", propertyIdentifier, T.stringof, fieldName);
255     }
256 }
257 
258 
259 ///
260 public class MenuItem
261 {
262     /// Description of this item printed by the menu
263     immutable string displayString;
264 
265     protected ITextMenu textMenu;
266     private bool _enabled;
267     private void delegate() action;
268 
269     /// Whether this item is enabled or not.
270     @property
271     public bool enabled() const
272     {
273         return _enabled;
274     }
275 
276     /// Allows for enabling or disabling this item
277     @property
278     public void enabled(bool enable)
279     {
280         _enabled = enable;
281     }
282 
283     ///
284     this(string displayString, void delegate() action, bool enabled = true)
285     in
286     {
287         assert(displayString !is null);
288         assert(action !is null);
289     }
290     do
291     {
292         this.displayString = displayString;
293         this.action = action;
294         this.enabled = enabled;
295     }
296 
297     ///
298     this(string displayString, void function() action, bool enabled = true)
299     in
300     {
301         assert(displayString !is null);
302         assert(action !is null);
303     }
304     do
305     {
306         this(displayString, {action();}, enabled);
307     }
308 
309     private void execute()
310     {
311         action();
312     }
313 
314     /**
315         Binds this MenuItem to a specific ITextMenu. This method may be only
316         be called once.
317     */
318     private void bind(ITextMenu textMenu)
319     in
320     {
321         assert(textMenu !is null);
322     }
323     do
324     {
325         enforce!ItemBoundException(this.textMenu is null, 
326                                    format("Cannot bind MenuItem %s to %s. It is already bound to %s",
327                                    this, textMenu, this.textMenu));
328 
329         this.textMenu = textMenu;
330     }
331 }
332 
333 // TESTS
334 version(unittest)
335 {
336     import dli.string_stream.input_string_stream;
337     import dli.string_stream.output_string_stream;
338 
339     private class TextMenuTestImplementation : TextMenu!(shared InputStringStream, shared OutputStringStream, int)
340     {
341         private enum int exitItemKey = -1;
342 
343         this(shared InputStringStream inputStream = new shared InputStringStream(), 
344              shared OutputStringStream outputStream = new shared OutputStringStream())
345         {
346             super(inputStream, outputStream);
347         }
348 
349         override void addItem(MenuItem item, int key)
350         {
351             if (status == Status.Stopped)
352             {
353                 enforce(key != exitItemKey, 
354                         "Tried to add an item with key associated to " ~
355                         "the exit item in the test implementation"
356                        );
357             }
358             else
359             {
360                 enforce(status == Status.Starting);
361                 enforce(key == exitItemKey);
362             }
363 
364             super.addItem(item, key);
365         }
366 
367         override void addExitMenuItem(MenuItem item)
368         {
369             addItem(item, exitItemKey);
370         }
371 
372         override void removeExitMenuItem()
373         {
374             removeItem(exitItemKey);
375         }
376     }
377 
378     @("TextMenu calls onStart and onExit callbacks")
379     unittest
380     {
381         auto inputStream = new shared InputStringStream();
382         auto menu = new TextMenuTestImplementation(inputStream);
383         bool onStartCallbackExecuted;
384         bool onExitCallbackExecuted;
385 
386         menu.onStart = {
387             onStartCallbackExecuted = true; 
388             assert(!onExitCallbackExecuted);
389         };
390         menu.onExit = {onExitCallbackExecuted = true;};
391         
392         inputStream.appendLine(to!string(TextMenuTestImplementation.exitItemKey));
393         menu.run();
394 
395         assert(onStartCallbackExecuted);
396         assert(onExitCallbackExecuted);
397     }
398 
399     @("TextMenu does not allow two items to be added with the same key")
400     unittest
401     {
402         auto menu = new TextMenuTestImplementation();
403         auto item1 = new MenuItem("", {});
404         auto item2 = new MenuItem("", {});
405 
406         menu.addItem(item1, 1);
407         
408         assertThrown!InvalidKeyException(menu.addItem(item1, 1));
409         assertThrown!InvalidKeyException(menu.addItem(item2, 1));
410     }
411 
412     @("TextMenu does not crash if EOF is reached when asking for item key")
413     unittest
414     {
415         auto inputStream = new shared InputStringStream();
416         auto menu = new TextMenuTestImplementation(inputStream);
417 
418         inputStream.append("" ~ eof);
419         inputStream.appendLine("-1");
420         menu.run();
421     }
422 
423     @("MenuItem cannot be added to a menu twice")
424     unittest
425     {
426         import std.exception : assertThrown;
427 
428         auto menu1 = new TextMenuTestImplementation();
429         auto menu2 = new TextMenuTestImplementation();
430         auto item = new MenuItem("",{});
431 
432         menu1.addItem(item, 1); // item is now bound to menu1
433         
434         assertThrown!ItemBoundException(menu1.addItem(item, 2));
435         assertThrown!ItemBoundException(menu2.addItem(item, 1));
436     }
437 
438     @("MenuItem calls actions when selected by the user in a TextMenu")
439     unittest
440     {
441         auto inputStream = new shared InputStringStream();
442         auto menu = new TextMenuTestImplementation(inputStream);
443         bool actionCalled;
444         void foo()
445         {
446             actionCalled = true;
447         }
448         auto item = new MenuItem("", &foo);
449 
450         menu.addItem(item, 1);
451         inputStream.appendLine("1");
452         inputStream.appendLine(to!string(TextMenuTestImplementation.exitItemKey));
453         menu.run();
454 
455         assert(actionCalled);
456     }
457 }