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 you 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 	auto ans = readln();
66 
67 	static if(is(T == bool))
68 	{
69 		switch(ans.front)
70 		{
71 			case 'y', 'Y':
72 				return true;
73 			default:
74 		}
75 		switch(ans.strip())
76 		{
77 			case "continue":
78 			case "ok":
79 				return true;
80 			default:
81 				return false;
82 		}
83 	} else
84 	{
85 		if(ans == "\x0a")
86 			throw new NoInputException("Value required, "
87 			                           "cannot continue operation.");
88 		static if(isSomeChar!T)
89 		{
90 			return to!(T)(ans[0]);
91 		} else
92 			return to!(T)(ans.strip());
93 	}
94 }
95 
96 ///ditto
97 void userInput(T = string)(string question, ref T result)
98 {
99 	result = userInput!T(question);
100 }
101 
102 version(unittest_scriptlike_d)
103 unittest
104 {
105 	mixin(selfCom(["10PM", "9PM"]));
106 	mixin(selfCom());
107 	auto s = userInput("What time is it?");
108 	assert(s == "10PM", "Expected 10PM got" ~ s);
109 	outfile.rewind;
110 	assert(outfile.readln().strip == "What time is it?");
111 	
112 	outfile.rewind;
113 	userInput("What time?", s);
114 	assert(s == "9PM", "Expected 9PM got" ~ s);
115 	outfile.rewind;
116 	assert(outfile.readln().strip == "What time?");
117 }
118 
119 /**
120  * Pauses and prompts the user to press Enter (or "Return" on OSX).
121  * 
122  * This is similar to the Windows command line's PAUSE command.
123  *
124  * --------
125  * pause();
126  * pause("Thanks. Please press Enter again...");
127  * --------
128  */
129 void pause(string prompt = defaultPausePrompt)
130 {
131 	//TODO: This works, but needs a little work. Currently, it echoes all
132 	//      input until Enter is pressed. Fixing that requires some low-level
133 	//      os-specific work.
134 	//
135 	//      For reference:
136 	//      http://stackoverflow.com/questions/6856635/hide-password-input-on-terminal
137 	//      http://linux.die.net/man/3/termios
138 	
139 	write(prompt);
140 	stdout.flush();
141 	getchar();
142 }
143 
144 version(OSX)
145 	enum defaultPausePrompt = "Press Return to continue..."; ///
146 else
147 	enum defaultPausePrompt = "Press Enter to continue..."; ///
148 
149 
150 /**
151  * Gets a valid path folder from the user. The string will not contain
152  * quotes, if you are using in a system call and the path contain spaces
153  * wrapping in quotes may be required.
154  *
155  * --------
156  * auto confFile = pathLocation("Where is the configuration file?");
157  * --------
158  *
159  * Throws: NoInputException if the user does not provide a path.
160  */
161 string pathLocation(string action)
162 {
163 	string ans;
164 
165 	do
166 	{
167 		if(ans !is null)
168 			writeln("Could not locate that file.");
169 		ans = userInput(action);
170 		// Quotations will generally cause problems when
171 		// using the path with std.file and Windows. This removes the quotes.
172 		ans = ans.removechars("\";").strip();
173 		ans = ans[0] == '"' ? ans[1..$] : ans; // removechars skips first char
174 	} while(!exists(ans));
175 
176 	return ans;
177 }
178 
179 /**
180  * Creates a menu from a Range of strings.
181  * 
182  * It will require that a number is selected within the number of options.
183  * 
184  * If the the return type is a string, the string in the options parameter will
185  * be returned.
186  *
187  * Throws: NoInputException if the user wants to quit.
188  */
189 T menu(T = ElementType!(Range), Range) (string question, Range options)
190 					 if((is(T==ElementType!(Range)) || is(T==int)) &&
191 					   isForwardRange!(Range))
192 {
193 	string ans;
194 	int maxI;
195 	int i;
196 
197 	while(true)
198 	{
199 		writeln(question);
200 		i = 0;
201 		foreach(str; options)
202 		{
203 			writefln("%8s. %s", i+1, str);
204 			i++;
205 		}
206 		maxI = i+1;
207 
208 		writefln("%8s. %s", "No Input", "Quit");
209 		ans = userInput!(string)("").strip();
210 		int ians;
211 
212 		try
213 		{
214 			ians = to!(int)(ans);
215 		} catch(ConvException ce)
216 		{
217 			bool found;
218 			i = 0;
219 			foreach(o; options)
220 			{
221 				if(ans.toLower() == to!string(o).toLower())
222 				{
223 					found = true;
224 					ians = i+1;
225 					break;
226 				}
227 				i++;
228 			}
229 			if(!found)
230 				throw ce;
231 
232 		}
233 
234 		if(ians > 0 && ians <= maxI)
235 			static if(is(T==ElementType!(Range)))
236 				static if(isRandomAccessRange!(Range))
237 					return options[ians-1];
238 				else
239 				{
240 					take!(ians-1)(options);
241 					return options.front;
242 				}
243 			else
244 				return ians;
245 		else
246 			writeln("You did not select a valid entry.");
247 	}
248 }
249 
250 version(unittest_scriptlike_d)
251 unittest
252 {
253 	mixin(selfCom(["1","Green", "green","2"]));
254 	mixin(selfCom());
255 	auto color = menu!string("What color?", ["Blue", "Green"]);
256 	assert(color == "Blue", "Expected Blue got " ~ color);
257 
258 	auto ic = menu!int("What color?", ["Blue", "Green"]);
259 	assert(ic == 2, "Expected 2 got " ~ ic.to!string);
260 
261 	color = menu!string("What color?", ["Blue", "Green"]);
262 	assert(color == "Green", "Expected Green got " ~ color);
263 
264 	color = menu!string("What color?", ["Blue", "Green"]);
265 	assert(color == "Green", "Expected Green got " ~ color);
266 	outfile.rewind;
267 	assert(outfile.readln().strip == "What color?");
268 }
269 
270 
271 /**
272  * Requires that a value be provided and valid based on
273  * the delegate passed in. It must also check against null input.
274  *
275  * --------
276  * auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
277  * --------
278  *
279  * Throws: NoInputException if the user does not provide any value.
280  *         ConvError if the user does not provide any value.
281  */
282 T require(T, alias cond)(in string question, in string failure = null)
283 {
284 	alias unaryFun!(cond) call;
285 	T ans;
286 	while(1)
287 	{
288 		ans = userInput!T(question);
289 		if(call(ans))
290 			break;
291 		if(failure)
292 			writeln(failure);
293 	}
294 
295 	return ans;
296 }
297 
298 version(unittest_scriptlike_d)
299 unittest
300 {
301 	mixin(selfCom(["1","11","3"]));
302 	mixin(selfCom());
303 	auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
304 	assert(num == 1, "Expected 1 got" ~ num.to!string);
305 	num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
306 	assert(num == 3, "Expected 1 got" ~ num.to!string);
307 	outfile.rewind;
308 	assert(outfile.readln().strip == "Enter a number from 1 to 10");
309 }
310 
311 
312 /**
313  * Used when input was not provided.
314  */
315 class NoInputException: Exception
316 {
317 	this(string msg)
318 	{
319 		super(msg);
320 	}
321 }
322 
323 version(unittest_scriptlike_d)
324 private string selfCom()
325 {
326 	string ans = q{
327 		auto outfile = File.tmpfile();
328 		auto origstdout = stdout;
329 		scope(exit) stdout = origstdout;
330 		stdout = outfile;};
331 
332 	return ans;
333 }
334 
335 version(unittest_scriptlike_d)
336 private string selfCom(string[] input)
337 {
338 	string ans = q{
339 		auto infile = File.tmpfile();
340 		auto origstdin = stdin;
341 		scope(exit) stdin = origstdin;
342 		stdin = infile;};
343 
344 	foreach(i; input)
345 		ans ~= "infile.writeln(`"~i~"`);";
346 	ans ~= "infile.rewind;";
347 
348 	return ans;
349 }