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