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 }