1 /**
2  * Handling of interaction with users via standard input.
3  *
4  * Provides functions for simple and common interacitons 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  * Gets a valid path folder from the user. The string will not contain
106  * quotes, if you are using in a system call and the path contain spaces
107  * wrapping in quotes may be required.
108  *
109  * --------
110  * auto confFile = pathLocation("Where is the configuration file?");
111  * --------
112  *
113  * Throws: NoInputException if the user does not provide a path.
114  */
115 string pathLocation(string action)
116 {
117 	string ans;
118 
119 	do
120 	{
121 		if(ans !is null)
122 			writeln("Could not locate that file.");
123 		ans = userInput(action);
124 		// Quotations will generally cause problems when
125 		// using the path with std.file and Windows. This removes the quotes.
126 		ans = ans.removechars("\";").strip();
127 		ans = ans[0] == '"' ? ans[1..$] : ans; // removechars skips first char
128 	} while(!exists(ans));
129 
130 	return ans;
131 }
132 
133 /**
134  * Creates a menu from a Range of strings.
135  * 
136  * It will require that a number is selected within the number of options.
137  * 
138  * If the the return type is a string, the string in the options parameter will
139  * be returned.
140  *
141  * Throws: NoInputException if the user wants to quit.
142  */
143 T menu(T = ElementType!(Range), Range) (string question, Range options)
144 					 if((is(T==ElementType!(Range)) || is(T==int)) &&
145 					   isForwardRange!(Range))
146 {
147 	string ans;
148 	int maxI;
149 	int i;
150 
151 	while(true)
152 	{
153 		writeln(question);
154 		i = 0;
155 		foreach(str; options)
156 		{
157 			writefln("%8s. %s", i+1, str);
158 			i++;
159 		}
160 		maxI = i+1;
161 
162 		writefln("%8s. %s", "No Input", "Quit");
163 		ans = userInput!(string)("").strip();
164 		int ians;
165 
166 		try
167 		{
168 			ians = to!(int)(ans);
169 		} catch(ConvException ce)
170 		{
171 			bool found;
172 			i = 0;
173 			foreach(o; options)
174 			{
175 				if(ans.toLower() == to!string(o).toLower())
176 				{
177 					found = true;
178 					ians = i+1;
179 					break;
180 				}
181 				i++;
182 			}
183 			if(!found)
184 				throw ce;
185 
186 		}
187 
188 		if(ians > 0 && ians <= maxI)
189 			static if(is(T==ElementType!(Range)))
190 				static if(isRandomAccessRange!(Range))
191 					return options[ians-1];
192 				else
193 				{
194 					take!(ians-1)(options);
195 					return options.front;
196 				}
197 			else
198 				return ians;
199 		else
200 			writeln("You did not select a valid entry.");
201 	}
202 }
203 
204 version(unittest_scriptlike_d)
205 unittest
206 {
207 	mixin(selfCom(["1","Green", "green","2"]));
208 	mixin(selfCom());
209 	auto color = menu!string("What color?", ["Blue", "Green"]);
210 	assert(color == "Blue", "Expected Blue got " ~ color);
211 
212 	auto ic = menu!int("What color?", ["Blue", "Green"]);
213 	assert(ic == 2, "Expected 2 got " ~ ic.to!string);
214 
215 	color = menu!string("What color?", ["Blue", "Green"]);
216 	assert(color == "Green", "Expected Green got " ~ color);
217 
218 	color = menu!string("What color?", ["Blue", "Green"]);
219 	assert(color == "Green", "Expected Green got " ~ color);
220 	outfile.rewind;
221 	assert(outfile.readln().strip == "What color?");
222 }
223 
224 
225 /**
226  * Requires that a value be provided and valid based on
227  * the delegate passed in. It must also check against null input.
228  *
229  * --------
230  * auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
231  * --------
232  *
233  * Throws: NoInputException if the user does not provide any value.
234  *         ConvError if the user does not provide any value.
235  */
236 T require(T, alias cond)(in string question, in string failure = null)
237 {
238 	alias unaryFun!(cond) call;
239 	T ans;
240 	while(1)
241 	{
242 		ans = userInput!T(question);
243 		if(call(ans))
244 			break;
245 		if(failure)
246 			writeln(failure);
247 	}
248 
249 	return ans;
250 }
251 
252 version(unittest_scriptlike_d)
253 unittest
254 {
255 	mixin(selfCom(["1","11","3"]));
256 	mixin(selfCom());
257 	auto num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
258 	assert(num == 1, "Expected 1 got" ~ num.to!string);
259 	num = require!(int, "a > 0 && a <= 10")("Enter a number from 1 to 10");
260 	assert(num == 3, "Expected 1 got" ~ num.to!string);
261 	outfile.rewind;
262 	assert(outfile.readln().strip == "Enter a number from 1 to 10");
263 }
264 
265 
266 /**
267  * Used when input was not provided.
268  */
269 class NoInputException: Exception
270 {
271 	this(string msg)
272 	{
273 		super(msg);
274 	}
275 }
276 
277 version(unittest_scriptlike_d)
278 private string selfCom()
279 {
280 	string ans = q{
281 		auto outfile = File.tmpfile();
282 		auto origstdout = stdout;
283 		scope(exit) stdout = origstdout;
284 		stdout = outfile;};
285 
286 	return ans;
287 }
288 
289 version(unittest_scriptlike_d)
290 private string selfCom(string[] input)
291 {
292 	string ans = q{
293 		auto infile = File.tmpfile();
294 		auto origstdin = stdin;
295 		scope(exit) stdin = origstdin;
296 		stdin = infile;};
297 
298 	foreach(i; input)
299 		ans ~= "infile.writeln(`"~i~"`);";
300 	ans ~= "infile.rewind;";
301 
302 	return ans;
303 }
304