1 /**
2  * Handling of interaction with users via standard input.
3  *
4  * Provides functions for simple and common interactions with users in
5  * the form of question and answer.
6  *
7  * Copyright: Copyright Jesse Phillips 2010
8  * License:   $(LINK2 https://github.com/Abscissa/scriptlike/blob/master/LICENSE.txt, zlib/libpng)
9  * Authors:   Jesse Phillips
10  *
11  * Synopsis:
12  *
13  * --------
14  * import scriptlike.interact;
15  *
16  * auto age = userInput!int("Please Enter your age");
17  * 
18  * if(userInput!bool("Do you want to continue?"))
19  * {
20  *     auto outputFolder = pathLocation("Where you do want to place the output?");
21  *     auto color = menu!string("What color would you like to use?", ["Blue", "Green"]);
22  * }
23  *
24  * auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
25  * --------
26  */
27 module scriptlike.interact;
28 
29 import std.conv;
30 import std.file;
31 import std.functional;
32 import std.range;
33 import std.stdio;
34 import std..string;
35 import std.traits;
36 
37 /**
38  * The $(D userInput) function provides a means to accessing a single
39  * value from the user. Each invocation outputs a provided 
40  * statement/question and takes an entire line of input. The result is then
41  * converted to the requested type; default is a string.
42  *
43  * --------
44  * auto name = userInput("What is your name");
45  * //or
46  * string name;
47  * userInput("What is your name", name);
48  * --------
49  *
50  * Returns: User response as type T.
51  *
52  * Where type is bool: 
53  *
54  *          true on "ok", "continue", 
55  *          and if the response starts with 'y' or 'Y'.
56  *
57  *          false on all other input, include no response (will not throw).
58  *
59  * Throws: $(D NoInputException) if the user does not enter anything.
60  * 	     $(D ConvError) when the string could not be converted to the desired type.
61  */
62 T userInput(T = string)(string question = "")
63 {
64 	write(question ~ "\n> ");
65 	stdout.flush;
66 	auto ans = readln();
67 
68 	static if(is(T == bool))
69 	{
70 		switch(ans.front)
71 		{
72 			case 'y', 'Y':
73 				return true;
74 			default:
75 		}
76 		switch(ans.strip())
77 		{
78 			case "continue":
79 			case "ok":
80 				return true;
81 			default:
82 				return false;
83 		}
84 	} else
85 	{
86 		if(ans == "\x0a")
87 			throw new NoInputException("Value required, "~
88 			                           "cannot continue operation.");
89 		static if(isSomeChar!T)
90 		{
91 			return to!(T)(ans[0]);
92 		} else
93 			return to!(T)(ans.strip());
94 	}
95 }
96 
97 ///ditto
98 void userInput(T = string)(string question, ref T result)
99 {
100 	result = userInput!T(question);
101 }
102 
103 version(unittest_scriptlike_d)
104 unittest
105 {
106 	mixin(selfCom(["10PM", "9PM"]));
107 	mixin(selfCom());
108 	auto s = userInput("What time is it?");
109 	assert(s == "10PM", "Expected 10PM got" ~ s);
110 	outfile.rewind;
111 	assert(outfile.readln().strip == "What time is it?");
112 	
113 	outfile.rewind;
114 	userInput("What time?", s);
115 	assert(s == "9PM", "Expected 9PM got" ~ s);
116 	outfile.rewind;
117 	assert(outfile.readln().strip == "What time?");
118 }
119 
120 /**
121  * Pauses and prompts the user to press Enter (or "Return" on OSX).
122  * 
123  * This is similar to the Windows command line's PAUSE command.
124  *
125  * --------
126  * pause();
127  * pause("Thanks. Please press Enter again...");
128  * --------
129  */
130 void pause(string prompt = defaultPausePrompt)
131 {
132 	//TODO: This works, but needs a little work. Currently, it echoes all
133 	//      input until Enter is pressed. Fixing that requires some low-level
134 	//      os-specific work.
135 	//
136 	//      For reference:
137 	//      http://stackoverflow.com/questions/6856635/hide-password-input-on-terminal
138 	//      http://linux.die.net/man/3/termios
139 	
140 	write(prompt);
141 	stdout.flush();
142 	getchar();
143 }
144 
145 version(OSX)
146 	enum defaultPausePrompt = "Press Return to continue..."; ///
147 else
148 	enum defaultPausePrompt = "Press Enter to continue..."; ///
149 
150 
151 /**
152  * Gets a valid path folder from the user. The string will not contain
153  * quotes, if you are using in a system call and the path contain spaces
154  * wrapping in quotes may be required.
155  *
156  * --------
157  * auto confFile = pathLocation("Where is the configuration file?");
158  * --------
159  *
160  * Throws: NoInputException if the user does not provide a path.
161  */
162 string pathLocation(string action)
163 {
164 	import std.algorithm;
165 	import std.utf : byChar;
166 	string ans;
167 
168 	do
169 	{
170 		if(ans !is null)
171 			writeln("Could not locate that file.");
172 		ans = userInput(action);
173 		// Quotations will generally cause problems when
174 		// using the path with std.file and Windows. This removes the quotes.
175 		ans = std..string.strip( ans.byChar.filter!(a => a != '"' && a != ';').array );
176 		//ans = ans.removechars("\";").strip();
177 		//ans = ans[0] == '"' ? ans[1..$] : ans; // removechars skips first char
178 	} while(!exists(ans));
179 
180 	return ans;
181 }
182 
183 /**
184  * Creates a menu from a Range of strings.
185  * 
186  * It will require that a number is selected within the number of options.
187  * 
188  * If the the return type is a string, the string in the options parameter will
189  * be returned.
190  *
191  * Throws: NoInputException if the user wants to quit.
192  */
193 T menu(T = ElementType!(Range), Range) (string question, Range options)
194 					 if((is(T==ElementType!(Range)) || is(T==int)) &&
195 					   isForwardRange!(Range))
196 {
197 	string ans;
198 	int maxI;
199 	int i;
200 
201 	while(true)
202 	{
203 		writeln(question);
204 		i = 0;
205 		foreach(str; options)
206 		{
207 			writefln("%8s. %s", i+1, str);
208 			i++;
209 		}
210 		maxI = i+1;
211 
212 		writefln("%8s. %s", "No Input", "Quit");
213 		ans = userInput!(string)("").strip();
214 		int ians;
215 
216 		try
217 		{
218 			ians = to!(int)(ans);
219 		} catch(ConvException ce)
220 		{
221 			bool found;
222 			i = 0;
223 			foreach(o; options)
224 			{
225 				if(ans.toLower() == to!string(o).toLower())
226 				{
227 					found = true;
228 					ians = i+1;
229 					break;
230 				}
231 				i++;
232 			}
233 			if(!found)
234 				throw ce;
235 
236 		}
237 
238 		if(ians > 0 && ians <= maxI)
239 			static if(is(T==ElementType!(Range)))
240 				static if(isRandomAccessRange!(Range))
241 					return options[ians-1];
242 				else
243 				{
244 					take!(ians-1)(options);
245 					return options.front;
246 				}
247 			else
248 				return ians;
249 		else
250 			writeln("You did not select a valid entry.");
251 	}
252 }
253 
254 version(unittest_scriptlike_d)
255 unittest
256 {
257 	mixin(selfCom(["1","Green", "green","2"]));
258 	mixin(selfCom());
259 	auto color = menu!string("What color?", ["Blue", "Green"]);
260 	assert(color == "Blue", "Expected Blue got " ~ color);
261 
262 	auto ic = menu!int("What color?", ["Blue", "Green"]);
263 	assert(ic == 2, "Expected 2 got " ~ ic.to!string);
264 
265 	color = menu!string("What color?", ["Blue", "Green"]);
266 	assert(color == "Green", "Expected Green got " ~ color);
267 
268 	color = menu!string("What color?", ["Blue", "Green"]);
269 	assert(color == "Green", "Expected Green got " ~ color);
270 	outfile.rewind;
271 	assert(outfile.readln().strip == "What color?");
272 }
273 
274 
275 /**
276  * Requires that a value be provided and valid based on
277  * the delegate passed in. It must also check against null input.
278  *
279  * --------
280  * auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
281  * --------
282  *
283  * Throws: NoInputException if the user does not provide any value.
284  *         ConvError if the user does not provide any value.
285  */
286 T require(T, alias cond)(in string question, in string failure = null)
287 {
288 	alias unaryFun!(cond) call;
289 	T ans;
290 	while(1)
291 	{
292 		ans = userInput!T(question);
293 		if(call(ans))
294 			break;
295 		if(failure)
296 			writeln(failure);
297 	}
298 
299 	return ans;
300 }
301 
302 version(unittest_scriptlike_d)
303 unittest
304 {
305 	mixin(selfCom(["1","11","3"]));
306 	mixin(selfCom());
307 	auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
308 	assert(num == 1, "Expected 1 got" ~ num.to!string);
309 	num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
310 	assert(num == 3, "Expected 1 got" ~ num.to!string);
311 	outfile.rewind;
312 	assert(outfile.readln().strip == "Enter a number from 1 to 10");
313 }
314 
315 
316 /**
317  * Used when input was not provided.
318  */
319 class NoInputException: Exception
320 {
321 	this(string msg)
322 	{
323 		super(msg);
324 	}
325 }
326 
327 version(unittest_scriptlike_d)
328 private string selfCom()
329 {
330 	string ans = q{
331 		auto outfile = File.tmpfile();
332 		auto origstdout = stdout;
333 		scope(exit) stdout = origstdout;
334 		stdout = outfile;};
335 
336 	return ans;
337 }
338 
339 version(unittest_scriptlike_d)
340 private string selfCom(string[] input)
341 {
342 	string ans = q{
343 		auto infile = File.tmpfile();
344 		auto origstdin = stdin;
345 		scope(exit) stdin = origstdin;
346 		stdin = infile;};
347 
348 	foreach(i; input)
349 		ans ~= "infile.writeln(`"~i~"`);";
350 	ans ~= "infile.rewind;";
351 
352 	return ans;
353 }