1 /* This file is part of the Amalthea library. 2 * 3 * Copyright (C) 2018-2021 Eugene 'Vindex' Stulin 4 * 5 * Distributed under the Boost Software License 1.0 or (at your option) 6 * the GNU Lesser General Public License 3.0 or later. 7 */ 8 9 module amalthea.dialog; 10 11 import std.array : split; 12 import std.datetime : Clock, Date, SysTime, TimeOfDay; 13 import std.process : environment, execute; 14 import std.string; 15 import std.typecons; 16 17 public import amalthea.libcore; 18 19 /******************************************************************************* 20 * Enuration for actions in dialog boxes. 21 */ 22 enum DialogAction { 23 YES = 0, 24 NO = 1, 25 OK = YES, 26 CANCEL = NO, 27 ESC = 255 28 } 29 private alias Action = DialogAction; 30 31 private alias fn_t = void delegate(); 32 33 34 /******************************************************************************* 35 * Base class for all `dialog` user interface boxes. 36 * 37 * `dialog` is a program used which displays text user interface widgets. 38 * - - - 39 * See also: $(EXT_LINK https://invisible-island.net/dialog/) 40 */ 41 abstract class Dialog { 42 43 /// Background title, can be used of an application name. 44 private static string backtitle = ""; 45 46 /*************************************************************************** 47 * Sets the background title for dialog widgets. 48 */ 49 static void setBackTitle(string btitle) { 50 backtitle = btitle; 51 } 52 53 /*************************************************************************** 54 * Returns current background title. 55 */ 56 static string getBackTitle() { 57 return backtitle; 58 } 59 60 protected immutable baseCmd = ["dialog", "--stdout"]; 61 62 protected fn_t[Action] callbacks; 63 64 protected string title; 65 protected string dialogType; 66 protected string text; 67 protected uint height, width; 68 69 /*************************************************************************** 70 * Base constructor for all dialog boxes. 71 * Params: 72 * dialogType = A box name (msgbox, calendar, checklist, menu, etc.) 73 * title = A form name, a title for current box example. 74 * text = Main message of the box (for description or clarification). 75 * h = Height, number of lines. 76 * w = Width, number of columns. 77 */ 78 this(string dialogType, 79 string title, 80 string text, 81 uint h, 82 uint w) 83 { 84 this.dialogType = dialogType; 85 this.title = title; 86 this.text = text; 87 this.height = h;// + 4; //4 rows are parts of border 88 this.width = w; 89 90 callbacks[Action.YES] = null; 91 callbacks[Action.NO] = null; 92 callbacks[Action.ESC] = null; 93 } 94 95 /*************************************************************************** 96 * It is possible to assign callbacks to events such as pressing 97 * OK (or YES), NO (or CANCEL), ESC. 98 * ESC is always available. 99 */ 100 void setButtonHandler(Action act, fn_t fn) { 101 callbacks[act] = fn; 102 } 103 104 /*************************************************************************** 105 * Changes a box text. 106 */ 107 void setText(string text) { 108 this.text = text; 109 } 110 111 protected string[] prepareCmd() { 112 return baseCmd ~ [ 113 "--backtitle", backtitle, 114 "--title", this.title, 115 "--"~dialogType, this.text, 116 this.height.to!string, this.width.to!string 117 ]; 118 } 119 120 121 /*************************************************************************** 122 * Execution launch. 123 */ 124 void run() { 125 string[] cmdArray = prepareCmd(); 126 auto answer = execute(cmdArray); 127 this.entry = std..string.strip(answer.output); 128 if (this.entry.startsWith("Can't make new window")) { 129 throw new BadWindowSize(this.entry); 130 } 131 if (this.entry.startsWith("Can't open input file")) { 132 throw new FileException(this.entry); 133 } 134 auto f = callbacks[cast(Action)answer.status]; 135 if (f !is null) f(); 136 } 137 138 protected string entry; 139 } 140 141 142 /******************************************************************************* 143 * Template with simplified constructor for many dialog classes. 144 */ 145 mixin template Constructor(alias boxname) { 146 /*************************************************************************** 147 * Creates an object. 148 * Params: 149 * title = a form name, a title for current box example 150 * text = main message of the box (for description or clarification) 151 * h = height, number of lines 152 * w = width, number of columns 153 */ 154 this(string title="", string text="", uint h=8, uint w=48) { 155 super(boxname, title, text, h, w); 156 } 157 } 158 159 160 /******************************************************************************* 161 * Template with constructor for Calendar and TimeBox. 162 */ 163 mixin template DateTimeConstructor(alias boxname) { 164 /*************************************************************************** 165 * Creates an object. 166 * Params: 167 * title = a form name, a title for current box example 168 * text = main message of the box (for description or clarification) 169 */ 170 this(string title="", string text="") { 171 super(boxname, title, text, 0, 0); 172 } 173 } 174 175 176 /******************************************************************************* 177 * A yes/no dialog box. 178 * Mixins: 179 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"yesno"`. 180 */ 181 class YesNo : Dialog { 182 mixin Constructor!"yesno"; 183 } 184 185 186 /******************************************************************************* 187 * A message box has only a single OK button. 188 * Mixins: 189 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"msgbox"`. 190 */ 191 class MsgBox : Dialog { 192 mixin Constructor!"msgbox"; 193 } 194 195 196 /******************************************************************************* 197 * A info box has no buttons and closes after a specified time interval. 198 * Mixins: 199 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"infobox"`. 200 */ 201 class InfoBox : Dialog { 202 mixin Constructor!"infobox"; 203 204 /*************************************************************************** 205 * The function sets the lifetime of the widget. 206 */ 207 void setSleepTime(uint sleepTime) { 208 this.sleepTime = sleepTime; 209 } 210 211 override string[] prepareCmd() { 212 return baseCmd ~ [ 213 "--backtitle", backtitle, 214 "--title", this.title, 215 "--sleep", to!string(this.sleepTime), 216 "--"~dialogType, this.text, 217 this.height.to!string, this.width.to!string 218 ]; 219 } 220 221 protected uint sleepTime = 0; 222 } 223 224 225 /******************************************************************************* 226 * An input box is useful when you want to ask questions that require the user 227 * to input a string as the answer (description taken from `man dialog`). 228 * Provides a field for entering text. 229 * Mixins: 230 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"inputbox"`. 231 */ 232 class InputBox : Dialog { 233 mixin Constructor!"inputbox"; 234 235 /*************************************************************************** 236 * This method provides access to the saved string entered by user. 237 * Note: call after run() only. 238 */ 239 string getEntry() { 240 return entry; 241 } 242 } 243 244 245 /******************************************************************************* 246 * A password box is similar to an input box, except that the text the user 247 * enters is not displayed. 248 * Mixins: 249 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"passwordbox"`. 250 */ 251 class PasswordBox : Dialog { 252 mixin Constructor!"passwordbox"; 253 254 /*************************************************************************** 255 * This method provides access to the saved string entered by user. 256 * Note: call after run() only. 257 */ 258 string getEntry() { 259 return entry; 260 } 261 } 262 263 264 protected struct Item { 265 string tag; 266 string descr; 267 bool status; 268 } 269 270 271 /******************************************************************************* 272 * Template contains methods for CheckList, RadioList, BuildList. 273 */ 274 mixin template ListBoxFunctional(T) { 275 /*************************************************************************** 276 * Adds new item. 277 */ 278 T addItem(string tag, string descr, bool status=false) { 279 foreach(elem; items) { 280 if (elem.tag == tag) { 281 throw new RepeatedItem( 282 tag ~ ": item with such tag already exists." 283 ); 284 } 285 } 286 items ~= Item(tag, descr, status); 287 return this; 288 } 289 290 override string[] prepareCmd() { 291 return baseCmd ~ [ 292 "--backtitle", backtitle, 293 "--title", this.title, 294 "--"~dialogType, this.text, 295 this.height.to!string, this.width.to!string, "0" 296 ]; 297 } 298 299 override void run() { 300 auto cmdArray = prepareCmd(); 301 foreach (item; items) { 302 auto state = item.status ? "on" : "off"; 303 cmdArray ~= [item.tag, item.descr, state]; 304 } 305 auto answer = execute(cmdArray); 306 if (answer.output.startsWith("Can't make new window")) { 307 throw new BadWindowSize(this.entry); 308 } 309 selectedItems = split(answer.output); 310 auto f = callbacks[cast(Action)answer.status]; 311 if (f !is null) f(); 312 } 313 314 protected string[] selectedItems; 315 protected Item[] items; 316 } 317 318 319 /******************************************************************************* 320 * There are multiple entries presented in the form of a menu. 321 * Mixins: 322 * Uses mixin-templates `$(DIALOG_REF2 Constructor)!"checklist"` 323 * and `$(DIALOG_REF2 ListBoxFunctional)!CheckList`. 324 */ 325 class CheckList : Dialog { 326 mixin Constructor!"checklist"; 327 mixin ListBoxFunctional!CheckList; 328 329 /*************************************************************************** 330 * This method provides access to the selected names of checklist items. 331 * Note: call after run() only. 332 */ 333 string[] getSelectedItems() { 334 return selectedItems; 335 } 336 } 337 338 339 /******************************************************************************* 340 * Provides a list to select one item. 341 * Mixins: 342 * Uses mixin-templates `$(DIALOG_REF2 Constructor)!"radiolist"` 343 * and `$(DIALOG_REF2 ListBoxFunctional)!RadioList`. 344 */ 345 class RadioList : Dialog { 346 mixin Constructor!"radiolist"; 347 mixin ListBoxFunctional!RadioList; 348 349 /*************************************************************************** 350 * This method provides access to the selected item tag. 351 * Note: call after run() only. 352 */ 353 string getSelectedItem() { 354 if (!selectedItems.empty) { 355 return selectedItems[0]; 356 } 357 return ""; 358 } 359 } 360 361 362 /******************************************************************************* 363 * A buildlist dialog displays two lists, side-by-side. 364 * The list on the left shows unselected items. 365 * The list on the right shows selected items. 366 * As items are selected or unselected, they move between the lists. 367 * (Description taken from `man dialog`.) 368 * Mixins: 369 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"buildlist"` 370 * and `$(DIALOG_REF2 ListBoxFunctional)!BuildList`. 371 */ 372 class BuildList : Dialog { 373 mixin Constructor!"buildlist"; 374 mixin ListBoxFunctional!BuildList; 375 376 /*************************************************************************** 377 * This method provides access to the selected names of buildlist items. 378 * Note: call after run() only. 379 */ 380 string[] getSelectedItems() { 381 return selectedItems; 382 } 383 } 384 385 386 private struct MenuElement { 387 string tag; 388 string descr; 389 fn_t handler; 390 } 391 392 393 /******************************************************************************* 394 * A dialog box that can be used to present a list of choices in the form of 395 * a menu for the user to choose (description taken from `man dialog`). 396 * Mixins: 397 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"menu"`. 398 */ 399 class Menu : Dialog { 400 mixin Constructor!"menu"; 401 402 /*************************************************************************** 403 * Adds new item in menu. 404 */ 405 Menu addItem(string tag, string description, fn_t fn=null) { 406 // check all previous items 407 foreach(item; items) { 408 if (item.tag == tag) { 409 throw new RepeatedItem( 410 tag ~ ": item with such tag already exists." 411 ); 412 } 413 } 414 // add new element in a special array 415 items ~= MenuElement(tag, description, fn); 416 // for conveyor calls 417 return this; 418 } 419 420 /*************************************************************************** 421 * Assign a callback to a specific menu item. 422 */ 423 void setItemHandler(string tag, fn_t fn) { 424 foreach(ref item; items) { 425 if (item.tag != tag) continue; 426 item.handler = fn; 427 } 428 } 429 430 431 override string[] prepareCmd() { 432 auto menuHeight = items.length.to!string; 433 return baseCmd ~ [ 434 "--backtitle", backtitle, 435 "--title", this.title, 436 "--"~dialogType, this.text, 437 this.height.to!string, this.width.to!string, menuHeight 438 ]; 439 } 440 441 override void run() { 442 string[] cmdArray = prepareCmd(); 443 foreach (item; this.items) { 444 cmdArray ~= [item.tag, item.descr]; 445 } 446 auto answer = execute(cmdArray); 447 if (answer.output.startsWith("Can't make new window")) { 448 throw new BadWindowSize(this.entry); 449 } 450 const tag = std..string.strip(answer.output); 451 auto action = cast(Action)answer.status; 452 auto f = callbacks[action]; 453 if (f !is null) f(); 454 if (action != Action.OK) { 455 return; 456 } 457 foreach(item; items) { 458 if (item.tag != tag) continue; 459 auto itemFn = item.handler; 460 if (itemFn !is null) itemFn(); 461 } 462 } 463 464 private MenuElement[] items; 465 } 466 467 468 /******************************************************************************* 469 * A calendar box displays month, day and year in separately adjustable windows. 470 * Mixins: 471 * Uses mixin-template `$(DIALOG_REF2 DateTimeConstructor)!"calendar"`. 472 */ 473 class Calendar : Dialog { 474 mixin DateTimeConstructor!"calendar"; 475 476 override string[] prepareCmd() { 477 SysTime t = Clock.currTime(); 478 return baseCmd ~ [ 479 "--backtitle", backtitle, 480 "--title", this.title, 481 "--"~dialogType, this.text, 482 this.height.to!string, this.width.to!string, 483 to!string(t.day), to!string(cast(int)t.month), to!string(t.year) 484 ]; 485 } 486 487 /*************************************************************************** 488 * Returns tuple with year, month and day. 489 * Note: call after run() only. 490 */ 491 Tuple!(int, "year", int, "month", int, "day") getDateTuple() { 492 if (this.entry == "") { 493 return tuple!("year", "month", "day")(1970, 1, 1); 494 } 495 auto dateArray = split(this.entry, "/"); 496 return tuple!("year", "month", "day") 497 (to!int(dateArray[2]), to!int(dateArray[1]), to!int(dateArray[0])); 498 } 499 500 /*************************************************************************** 501 * Returns object of `std.datetime.date.Date`. 502 * Note: call after run() only. 503 */ 504 Date getDate() { 505 auto dateTuple = this.getDateTuple(); 506 return Date(dateTuple.year, dateTuple.month, dateTuple.day); 507 } 508 } 509 510 511 /******************************************************************************* 512 * A dialog is displayed which allows you to select hour, minute and second. 513 * Mixins: 514 * Uses mixin-template `$(DIALOG_REF2 DateTimeConstructor)!"timebox"`. 515 */ 516 class TimeBox : Dialog { 517 mixin DateTimeConstructor!"timebox"; 518 519 override string[] prepareCmd() { 520 SysTime t = Clock.currTime(); 521 return baseCmd ~ [ 522 "--backtitle", backtitle, 523 "--title", this.title, 524 "--"~dialogType, this.text, 525 this.height.to!string, this.width.to!string, 526 to!string(t.hour), to!string(t.minute), to!string(t.second) 527 ]; 528 } 529 530 /*************************************************************************** 531 * Returns tuple with hour, minute and second. Call after run() only. 532 */ 533 auto getTimeTuple() { 534 auto timeArray = split(this.entry, ":"); 535 return tuple!("hour", "minute", "second") 536 (to!int(timeArray[0]), to!int(timeArray[1]), to!int(timeArray[2])); 537 } 538 539 /*************************************************************************** 540 * Returns object of `std.datetime.date.TimeOfDay`. 541 * Note: call after run() only. 542 */ 543 TimeOfDay getTime() { 544 return TimeOfDay.fromISOExtString(this.entry); 545 } 546 } 547 548 549 /******************************************************************************* 550 * Template contains methods for template FileSystemBox 551 * and for classes EditBox and TextBox. 552 */ 553 mixin template FileBox(alias boxName, alias height, alias width) { 554 /*************************************************************************** 555 * Creates an object. 556 * 557 * Params: 558 * title = a form name, a title for current box example 559 * path = file (or directory) path 560 * h = height, number of lines 561 * w = width, number of columns 562 */ 563 this(string title="", string path="", uint h=height, uint w=width) { 564 if (path == "") { 565 path = environment.get("HOME", "") ~ "/"; 566 } 567 super(boxName, title, path, h, w); 568 } 569 570 /*************************************************************************** 571 * Sets start path. 572 */ 573 void setPath(string path) { 574 this.text = path; 575 } 576 } 577 578 579 /******************************************************************************* 580 * Template with base implementation of methods for FSelect, DSelect. 581 * Mixins: 582 * Uses mixin-template `$(DIALOG_REF2 FileBox)!(boxName, 10, 60)`. 583 */ 584 mixin template FileSystemBox(alias boxName) { 585 mixin FileBox!(boxName, 10, 60); 586 587 /*************************************************************************** 588 * Gets a user-selected file path. 589 * Note: call after run() only. 590 */ 591 string getPath() { 592 return this.entry; 593 } 594 } 595 596 597 /******************************************************************************* 598 * FSelect displays a text-entry window in which you can type 599 * a filename (or directory), and above that two windows with directory names 600 * and filenames (description taken from `man dialog`, the `--fselect` section). 601 * Mixins: 602 * Uses mixin-template `$(DIALOG_REF2 FileSystemBox)!"fselect"`. 603 */ 604 class FSelect : Dialog { 605 /// FS functions. 606 mixin FileSystemBox!"fselect"; 607 } 608 609 610 /******************************************************************************* 611 * DSelect displays a text-entry window in which you can type 612 * a directory, and above that a windows with directory names 613 * (description taken from `man dialog`, the `--dselect` section). 614 * Mixins: 615 * Uses mixin-template `$(DIALOG_REF2 FileSystemBox)!"dselect"`. 616 */ 617 class DSelect : Dialog { 618 mixin FileSystemBox!"dselect"; 619 } 620 621 622 /******************************************************************************* 623 * Allow the user to select from a range of values, e.g., using a slider. 624 * Mixins: 625 * Uses mixin-template `$(DIALOG_REF2 Constructor)!"rangebox"`. 626 */ 627 class RangeBox : Dialog { 628 mixin Constructor!"rangebox"; 629 630 override string[] prepareCmd() { 631 return baseCmd ~ [ 632 "--backtitle", backtitle, 633 "--title", this.title, 634 "--"~dialogType, this.text, 635 this.height.to!string, this.width.to!string, 636 this.minValue.to!string, 637 this.maxValue.to!string, 638 this.defaultValue.to!string 639 ]; 640 } 641 642 /*************************************************************************** 643 * Sets a range of valid values and a default value. 644 */ 645 void setRange(long minValue, ulong maxValue, ulong defaultValue) { 646 this.minValue = minValue; 647 this.maxValue = maxValue; 648 this.defaultValue = defaultValue; 649 } 650 651 /*************************************************************************** 652 * This method provides access to the selected value. 653 * Note: call after run() only. 654 */ 655 long getValue() { 656 return entry.to!long; 657 } 658 659 protected: 660 661 long minValue = 0; 662 long maxValue = 100; 663 long defaultValue = 50; 664 } 665 666 667 /******************************************************************************* 668 * A pause box displays a meter along the bottom of the box. 669 * The meter indicates how many seconds remain until the end of the pause. 670 * This box exits when timeout is reached or the user presses 671 * the OK button (status OK) or the user presses the CANCEL button or Esc key. 672 * (Description taken from `man dialog`.) 673 */ 674 class Pause : Dialog { 675 /*************************************************************************** 676 * Creates an object. 677 * 678 * Params: 679 * title = a form name, a title for current box example 680 * text = main message of the box (for description or clarification) 681 * h = height, number of lines 682 * w = width, number of columns 683 * t = time to countdown 684 */ 685 this(string title="", string text="", uint h=8, uint w=48, uint t=10) { 686 this.timeout = t; 687 super("pause", title, text, h, w); 688 } 689 690 /*************************************************************************** 691 * Sets countdown. 692 */ 693 void setTimeout(uint t) { 694 this.timeout = t; 695 } 696 697 override string[] prepareCmd() { 698 return baseCmd ~ [ 699 "--backtitle", backtitle, 700 "--title", this.title, 701 "--"~dialogType, this.text, 702 this.height.to!string, this.width.to!string, 703 this.timeout.to!string 704 ]; 705 } 706 707 protected uint timeout; 708 } 709 710 711 /******************************************************************************* 712 * A text box lets you display the contents of a text file in a dialog box. 713 * It is like a simple text file viewer. 714 * (Description taken from `man dialog`.) 715 * Mixins: 716 * Uses mixin-template `$(DIALOG_REF2 FileBox)!("textbox", 16, 64)`. 717 */ 718 class TextBox : Dialog { 719 mixin FileBox!("textbox", 16, 64); 720 } 721 722 723 /******************************************************************************* 724 * This window displays a copy of the file for editing. 725 * After editing, changed file content will be saved in this object. 726 * Mixins: 727 * Uses mixin-template `$(DIALOG_REF2 FileBox)!("textbox", 16, 64)`. 728 */ 729 class EditBox : Dialog { 730 mixin FileBox!("editbox", 16, 64); 731 732 /*************************************************************************** 733 * Returns changed content of the file. 734 * Note: Call after run() only. 735 */ 736 string getContent() { 737 return this.entry; 738 } 739 } 740 741 742 /******************************************************************************* 743 * Base exception for the dialog module. 744 */ 745 abstract class DialogException : Exception { 746 mixin RealizeException; 747 } 748 749 750 /******************************************************************************* 751 * Exception for list-based dialog boxes. 752 * Thrown if an attempt was made to add an item with a duplicate tag. 753 */ 754 class RepeatedItem : DialogException { 755 mixin RealizeException; 756 } 757 758 759 /******************************************************************************* 760 * Thrown if a dialog window cannot be created. 761 */ 762 class BadWindowSize : DialogException { 763 mixin RealizeException; 764 } 765