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