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 	string ans;
165 
166 	do
167 	{
168 		if(ans !is null)
169 			writeln("Could not locate that file.");
170 		ans = userInput(action);
171 		// Quotations will generally cause problems when
172 		// using the path with std.file and Windows. This removes the quotes.
173 		ans = ans.removechars("\";").strip();
174 		ans = ans[0] == '"' ? ans[1..$] : ans; // removechars skips first char
175 	} while(!exists(ans));
176 
177 	return ans;
178 }
179 
180 /**
181  * Creates a menu from a Range of strings.
182  * 
183  * It will require that a number is selected within the number of options.
184  * 
185  * If the the return type is a string, the string in the options parameter will
186  * be returned.
187  *
188  * Throws: NoInputException if the user wants to quit.
189  */
190 T menu(T = ElementType!(Range), Range) (string question, Range options)
191 					 if((is(T==ElementType!(Range)) || is(T==int)) &&
192 					   isForwardRange!(Range))
193 {
194 	string ans;
195 	int maxI;
196 	int i;
197 
198 	while(true)
199 	{
200 		writeln(question);
201 		i = 0;
202 		foreach(str; options)
203 		{
204 			writefln("%8s. %s", i+1, str);
205 			i++;
206 		}
207 		maxI = i+1;
208 
209 		writefln("%8s. %s", "No Input", "Quit");
210 		ans = userInput!(string)("").strip();
211 		int ians;
212 
213 		try
214 		{
215 			ians = to!(int)(ans);
216 		} catch(ConvException ce)
217 		{
218 			bool found;
219 			i = 0;
220 			foreach(o; options)
221 			{
222 				if(ans.toLower() == to!string(o).toLower())
223 				{
224 					found = true;
225 					ians = i+1;
226 					break;
227 				}
228 				i++;
229 			}
230 			if(!found)
231 				throw ce;
232 
233 		}
234 
235 		if(ians > 0 && ians <= maxI)
236 			static if(is(T==ElementType!(Range)))
237 				static if(isRandomAccessRange!(Range))
238 					return options[ians-1];
239 				else
240 				{
241 					take!(ians-1)(options);
242 					return options.front;
243 				}
244 			else
245 				return ians;
246 		else
247 			writeln("You did not select a valid entry.");
248 	}
249 }
250 
251 version(unittest_scriptlike_d)
252 unittest
253 {
254 	mixin(selfCom(["1","Green", "green","2"]));
255 	mixin(selfCom());
256 	auto color = menu!string("What color?", ["Blue", "Green"]);
257 	assert(color == "Blue", "Expected Blue got " ~ color);
258 
259 	auto ic = menu!int("What color?", ["Blue", "Green"]);
260 	assert(ic == 2, "Expected 2 got " ~ ic.to!string);
261 
262 	color = menu!string("What color?", ["Blue", "Green"]);
263 	assert(color == "Green", "Expected Green got " ~ color);
264 
265 	color = menu!string("What color?", ["Blue", "Green"]);
266 	assert(color == "Green", "Expected Green got " ~ color);
267 	outfile.rewind;
268 	assert(outfile.readln().strip == "What color?");
269 }
270 
271 
272 /**
273  * Requires that a value be provided and valid based on
274  * the delegate passed in. It must also check against null input.
275  *
276  * --------
277  * auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
278  * --------
279  *
280  * Throws: NoInputException if the user does not provide any value.
281  *         ConvError if the user does not provide any value.
282  */
283 T require(T, alias cond)(in string question, in string failure = null)
284 {
285 	alias unaryFun!(cond) call;
286 	T ans;
287 	while(1)
288 	{
289 		ans = userInput!T(question);
290 		if(call(ans))
291 			break;
292 		if(failure)
293 			writeln(failure);
294 	}
295 
296 	return ans;
297 }
298 
299 version(unittest_scriptlike_d)
300 unittest
301 {
302 	mixin(selfCom(["1","11","3"]));
303 	mixin(selfCom());
304 	auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
305 	assert(num == 1, "Expected 1 got" ~ num.to!string);
306 	num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
307 	assert(num == 3, "Expected 1 got" ~ num.to!string);
308 	outfile.rewind;
309 	assert(outfile.readln().strip == "Enter a number from 1 to 10");
310 }
311 
312 
313 /**
314  * Used when input was not provided.
315  */
316 class NoInputException: Exception
317 {
318 	this(string msg)
319 	{
320 		super(msg);
321 	}
322 }
323 
324 version(unittest_scriptlike_d)
325 private string selfCom()
326 {
327 	string ans = q{
328 		auto outfile = File.tmpfile();
329 		auto origstdout = stdout;
330 		scope(exit) stdout = origstdout;
331 		stdout = outfile;};
332 
333 	return ans;
334 }
335 
336 version(unittest_scriptlike_d)
337 private string selfCom(string[] input)
338 {
339 	string ans = q{
340 		auto infile = File.tmpfile();
341 		auto origstdin = stdin;
342 		scope(exit) stdin = origstdin;
343 		stdin = infile;};
344 
345 	foreach(i; input)
346 		ans ~= "infile.writeln(`"~i~"`);";
347 	ans ~= "infile.rewind;";
348 
349 	return ans;
350 }